refactor(projects): 1、优化新增 产品和新增项目;2、调整角色提示信息

This commit is contained in:
2026-05-18 22:25:04 +08:00
parent 2367e03146
commit acd41555f9
22 changed files with 3588 additions and 643 deletions

View File

@@ -33,8 +33,6 @@ interface ProductMemberResponse {
roleId: string | number; roleId: string | number;
roleName: string; roleName: string;
roleCode: string; roleCode: string;
/** 多角色合并展示的非主角色名列表 */
additionalRoleNames?: string[] | null;
managerFlag: boolean; managerFlag: boolean;
status: 0 | 1; status: 0 | 1;
joinedTime: string; joinedTime: string;
@@ -76,7 +74,6 @@ export function normalizeProductMember(response: ProductMemberResponse): Api.Pro
roleId: normalizeStringId(response.roleId), roleId: normalizeStringId(response.roleId),
roleName: response.roleName || '', roleName: response.roleName || '',
roleCode: response.roleCode || '', roleCode: response.roleCode || '',
additionalRoleNames: response.additionalRoleNames ?? [],
managerFlag: Boolean(response.managerFlag), managerFlag: Boolean(response.managerFlag),
status: response.status, status: response.status,
joinedTime: response.joinedTime, joinedTime: response.joinedTime,

View File

@@ -574,6 +574,19 @@ export async function fetchCreateProductMember(id: string, data: Api.Product.Cre
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId); return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
} }
export async function fetchBatchCreateProductMembers(id: string, data: Api.Product.BatchCreateProductMembersParams) {
const result = await request<Array<string | number>>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/${id}/members/batch`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<Array<string | number>>, list =>
Array.isArray(list) ? list.map(normalizeStringId) : []
);
}
export function fetchUpdateProductMember(id: string, memberId: string, data: Api.Product.UpdateProductMemberParams) { export function fetchUpdateProductMember(id: string, memberId: string, data: Api.Product.UpdateProductMemberParams) {
return request<boolean>({ return request<boolean>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
@@ -583,6 +596,15 @@ export function fetchUpdateProductMember(id: string, memberId: string, data: Api
}); });
} }
export function fetchBatchInactiveProductMembers(id: string, data: Api.Product.BatchInactiveProductMembersParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/${id}/members/batch/inactive`,
method: 'post',
data
});
}
export function fetchInactiveProductMember( export function fetchInactiveProductMember(
id: string, id: string,
memberId: string, memberId: string,

View File

@@ -147,8 +147,6 @@ export interface ProjectMemberResponse {
roleId: string | number; roleId: string | number;
roleName: string; roleName: string;
roleCode: string; roleCode: string;
/** 多角色合并展示的非主角色名列表 */
additionalRoleNames?: string[] | null;
managerFlag: boolean; managerFlag: boolean;
status: 0 | 1; status: 0 | 1;
joinedTime: string; joinedTime: string;
@@ -227,7 +225,6 @@ export function normalizeProjectMember(response: ProjectMemberResponse): Api.Pro
roleId: normalizeStringId(response.roleId), roleId: normalizeStringId(response.roleId),
roleName: response.roleName || '', roleName: response.roleName || '',
roleCode: response.roleCode || '', roleCode: response.roleCode || '',
additionalRoleNames: response.additionalRoleNames ?? [],
managerFlag: Boolean(response.managerFlag), managerFlag: Boolean(response.managerFlag),
status: response.status, status: response.status,
joinedTime: response.joinedTime, joinedTime: response.joinedTime,

View File

@@ -284,6 +284,28 @@ export function fetchInactiveProjectMember(
}); });
} }
export async function fetchBatchCreateProjectMembers(id: string, data: Api.Project.BatchCreateProjectMembersParams) {
const result = await request<Array<string | number>>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}/members/batch`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<Array<string | number>>, list =>
Array.isArray(list) ? list.map(normalizeStringId) : []
);
}
export function fetchBatchInactiveProjectMembers(id: string, data: Api.Project.BatchInactiveProjectMembersParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/${id}/members/batch/inactive`,
method: 'post',
data
});
}
/** 获取项目设置 */ /** 获取项目设置 */
export async function fetchGetProjectSettings(id: string) { export async function fetchGetProjectSettings(id: string) {
const result = await fetchGetProject(id); const result = await fetchGetProject(id);

View File

@@ -99,15 +99,10 @@ declare namespace Api {
userNickname: string; userNickname: string;
/** 角色 ID */ /** 角色 ID */
roleId: string; roleId: string;
/** 角色名称(主角色) */ /** 角色名称 */
roleName: string; roleName: string;
/** 角色编码(主角色) */ /** 角色编码 */
roleCode: string; roleCode: string;
/**
* 非主角色的中文名列表(多角色合并展示用,按字典序升序)
* 单角色时为空数组 [];典型场景:创建者 + 经理重合时,主行 managercreator 名进此列表
*/
additionalRoleNames: string[];
/** 是否当前产品经理 */ /** 是否当前产品经理 */
managerFlag: boolean; managerFlag: boolean;
/** 成员状态 */ /** 成员状态 */
@@ -215,6 +210,20 @@ declare namespace Api {
previousManagerRoleId?: string | null; previousManagerRoleId?: string | null;
} }
/**
* 批量新增产品成员参数
*
* 刻意不复用 CreateProductMemberParams批量接口不承担「产品经理交接」语义
* 后端兜底拒绝 roleId 为产品经理角色的项。
*/
interface BatchCreateProductMembersParams {
members: Array<{
userId: string;
roleId: string;
remark?: string | null;
}>;
}
/** /**
* 产品创建(含初始团队)原子接口参数 * 产品创建(含初始团队)原子接口参数
* *
@@ -223,7 +232,7 @@ declare namespace Api {
interface CreateProductWithTeamParams { interface CreateProductWithTeamParams {
product: SaveProductParams; product: SaveProductParams;
members: CreateProductMemberParams[]; members: CreateProductMemberParams[];
/** 关人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 product_watcher 角色 */ /** 关人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 product_watcher 角色 */
watcherUserIds?: string[]; watcherUserIds?: string[];
} }
@@ -239,6 +248,11 @@ declare namespace Api {
reason?: string | null; reason?: string | null;
} }
interface BatchInactiveProductMembersParams {
memberIds: string[];
reason?: string | null;
}
// ========== 产品需求相关类型定义 ========== // ========== 产品需求相关类型定义 ==========
/** 需求状态编码 */ /** 需求状态编码 */
type RequirementStatusCode = type RequirementStatusCode =

View File

@@ -519,15 +519,10 @@ declare namespace Api {
userNickname: string; userNickname: string;
/** 角色 ID */ /** 角色 ID */
roleId: string; roleId: string;
/** 角色名称(主角色) */ /** 角色名称 */
roleName: string; roleName: string;
/** 角色编码(主角色) */ /** 角色编码 */
roleCode: string; roleCode: string;
/**
* 非主角色的中文名列表(多角色合并展示用,按字典序升序)
* 单角色时为空数组 [];典型场景:创建者 + 负责人重合时,主行 managercreator 名进此列表
*/
additionalRoleNames: string[];
/** 是否项目负责人 */ /** 是否项目负责人 */
managerFlag: boolean; managerFlag: boolean;
/** 成员状态 */ /** 成员状态 */
@@ -625,6 +620,26 @@ declare namespace Api {
reason: string | null; reason: string | null;
} }
/**
* 批量新增项目成员参数
*
* 刻意不复用 CreateProjectMemberParams批量接口不承担"项目负责人交接"语义,
* 后端兜底拒绝 roleId 为项目负责人角色的项。
*/
interface BatchCreateProjectMembersParams {
members: Array<{
userId: string;
roleId: string;
remark?: string | null;
}>;
}
/** 批量移出项目成员参数 */
interface BatchInactiveProjectMembersParams {
memberIds: string[];
reason?: string | null;
}
/** /**
* 项目创建(含初始团队)原子接口参数 * 项目创建(含初始团队)原子接口参数
* *
@@ -633,7 +648,7 @@ declare namespace Api {
interface CreateProjectWithTeamParams { interface CreateProjectWithTeamParams {
project: SaveProjectParams; project: SaveProjectParams;
members: CreateProjectMemberParams[]; members: CreateProjectMemberParams[];
/** 关人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 project_watcher 角色 */ /** 关人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 project_watcher 角色 */
watcherUserIds?: string[]; watcherUserIds?: string[];
} }

View File

@@ -47,6 +47,8 @@ declare namespace Api {
type: RoleType; type: RoleType;
/** remark */ /** remark */
remark?: string | null; remark?: string | null;
/** 是否在前端选择面板可见0 不可见 / 1 可见,缺省视作可见 */
visible?: 0 | 1 | null;
/** create time */ /** create time */
createTime: number; createTime: number;
} }
@@ -226,7 +228,7 @@ declare namespace Api {
type PostList = PageResult<Post>; type PostList = PageResult<Post>;
type RoleSimple = Pick<Role, 'id' | 'name' | 'code' | 'status' | 'sort'>; type RoleSimple = Pick<Role, 'id' | 'name' | 'code' | 'status' | 'sort' | 'remark' | 'visible'>;
type RoleSimpleList = RoleSimple[]; type RoleSimpleList = RoleSimple[];

View File

@@ -100,6 +100,10 @@ declare module 'vue' {
IconCarbonStop: typeof import('~icons/carbon/stop')['default'] IconCarbonStop: typeof import('~icons/carbon/stop')['default']
'IconCharm:download': typeof import('~icons/charm/download')['default'] 'IconCharm:download': typeof import('~icons/charm/download')['default']
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default'] 'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
'IconEp:box': typeof import('~icons/ep/box')['default']
'IconEp:files': typeof import('~icons/ep/files')['default']
'IconEp:infoFilled': typeof import('~icons/ep/info-filled')['default']
'IconEp:plus': typeof import('~icons/ep/plus')['default']
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default'] IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default'] IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default'] 'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']

View File

@@ -1,14 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue'; import { computed, nextTick, reactive, watch } from 'vue';
import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business';
import { useForm, useFormRules } from '@/hooks/common/form'; import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
defineOptions({ name: 'ProductCreateTeamMemberDialog' }); defineOptions({ name: 'ProductCreateTeamMemberDialog' });
type OperateMode = 'create' | 'edit';
interface DraftMemberInput { interface DraftMemberInput {
userId: string; userId: string;
roleId: string; roleId: string;
@@ -16,22 +12,16 @@ interface DraftMemberInput {
} }
interface Props { interface Props {
mode: OperateMode;
initial: DraftMemberInput | null; initial: DraftMemberInput | null;
userOptions: Api.SystemManage.UserSimple[]; userOptions: Api.SystemManage.UserSimple[];
roleOptions: Api.SystemManage.RoleSimple[]; roleOptions: Api.SystemManage.RoleSimple[];
/** 已使用且不可选的 userId编辑模式应当排除当前行自身 */
disabledUserIds?: readonly string[];
} }
interface Emits { interface Emits {
(e: 'submit', payload: DraftMemberInput): void; (e: 'submit', payload: DraftMemberInput): void;
} }
const props = withDefaults(defineProps<Props>(), { const props = defineProps<Props>();
disabledUserIds: () => []
});
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false }); const visible = defineModel<boolean>('visible', { default: false });
@@ -39,31 +29,21 @@ const visible = defineModel<boolean>('visible', { default: false });
const { formRef, validate } = useForm(); const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules(); const { createRequiredRule } = useFormRules();
const model = reactive<DraftMemberInput>({ const model = reactive<DraftMemberInput>({ userId: '', roleId: '', remark: '' });
userId: '',
roleId: '',
remark: ''
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname]))); const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const selectableRoles = computed(() => props.roleOptions.filter(role => role.visible !== 0));
const rules = computed( const rules = computed(
() => () =>
({ ({
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
roleId: [createRequiredRule('请选择角色')] roleId: [createRequiredRule('请选择角色')]
}) satisfies Record<string, App.Global.FormRule[]> }) satisfies Record<string, App.Global.FormRule[]>
); );
function isManagerRole(role: Api.SystemManage.RoleSimple) {
return role.code === PRODUCT_MANAGER_ROLE_CODE;
}
async function handleConfirm() { async function handleConfirm() {
await validate(); await validate();
emit('submit', { emit('submit', {
userId: model.userId, userId: model.userId,
roleId: model.roleId, roleId: model.roleId,
@@ -72,34 +52,21 @@ async function handleConfirm() {
} }
watch(visible, async value => { watch(visible, async value => {
if (!value) { if (!value) return;
return;
}
model.userId = props.initial?.userId || ''; model.userId = props.initial?.userId || '';
model.roleId = props.initial?.roleId || ''; model.roleId = props.initial?.roleId || '';
model.remark = props.initial?.remark || ''; model.remark = props.initial?.remark || '';
await nextTick(); await nextTick();
formRef.value?.clearValidate(); formRef.value?.clearValidate();
}); });
</script> </script>
<template> <template>
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="sm" @confirm="handleConfirm"> <BusinessFormDialog v-model="visible" title="调整成员角色" preset="sm" @confirm="handleConfirm">
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false"> <ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElRow :gutter="16"> <ElRow :gutter="16">
<ElCol :span="24"> <ElCol :span="24">
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId"> <ElFormItem label="成员用户">
<BusinessUserSelect
v-model="model.userId"
:options="userOptions"
:disabled-user-ids="disabledUserIds"
disabled-label="已添加"
placeholder="请选择成员用户"
/>
</ElFormItem>
<ElFormItem v-else label="成员用户">
<ElInput <ElInput
:model-value="userLabelMap.get(String(model.userId)) || ''" :model-value="userLabelMap.get(String(model.userId)) || ''"
readonly readonly
@@ -111,17 +78,13 @@ watch(visible, async value => {
<ElCol :span="24"> <ElCol :span="24">
<ElFormItem label="目标角色" prop="roleId"> <ElFormItem label="目标角色" prop="roleId">
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色"> <ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
<ElOption <ElOption v-for="role in selectableRoles" :key="role.id" :label="role.name" :value="role.id">
v-for="role in roleOptions" <div class="product-create-team-member-dialog__role-option">
:key="role.id" <span class="product-create-team-member-dialog__role-option-name">{{ role.name }}</span>
:label="role.name" <ElTooltip v-if="role.remark" :content="role.remark" placement="right" :show-after="120">
:value="role.id" <icon-ep:info-filled class="product-create-team-member-dialog__role-option-info" @click.stop />
:disabled="isManagerRole(role)" </ElTooltip>
> </div>
<span>{{ role.name }}</span>
<span v-if="isManagerRole(role)" class="product-create-team-member-dialog__role-hint">
已由第 1 步指定
</span>
</ElOption> </ElOption>
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
@@ -144,12 +107,6 @@ watch(visible, async value => {
</template> </template>
<style scoped> <style scoped>
.product-create-team-member-dialog__role-hint {
margin-left: 8px;
color: rgb(148 163 184 / 96%);
font-size: 12px;
}
:deep(.product-create-team-member-dialog__readonly-input .el-input__wrapper) { :deep(.product-create-team-member-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249); background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset; box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
@@ -166,4 +123,31 @@ watch(visible, async value => {
-webkit-text-fill-color: rgb(51 65 85 / 96%); -webkit-text-fill-color: rgb(51 65 85 / 96%);
cursor: default; cursor: default;
} }
.product-create-team-member-dialog__role-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.product-create-team-member-dialog__role-option-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-create-team-member-dialog__role-option-info {
flex-shrink: 0;
font-size: 14px;
color: var(--el-text-color-placeholder);
cursor: help;
}
.product-create-team-member-dialog__role-option-info:hover {
color: var(--el-color-primary);
}
</style> </style>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business'; import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business';
import { fetchGetRoleSimpleList } from '@/service/api'; import ProductTeamBatchDialog, {
import { getProductTeamTableHeight } from '../../setting/shared'; type BatchMemberPayload
} from '@/views/product/shared/components/product-team-batch-dialog.vue';
import ProductCreateTeamMemberDialog from './product-create-team-member-dialog.vue'; import ProductCreateTeamMemberDialog from './product-create-team-member-dialog.vue';
import type { ProductCreateBaseForm } from './product-create-base-form.vue'; import type { ProductCreateBaseForm } from './product-create-base-form.vue';
@@ -21,57 +22,34 @@ interface DraftMember {
interface Props { interface Props {
baseInfo: ProductCreateBaseForm; baseInfo: ProductCreateBaseForm;
userOptions: Api.SystemManage.UserSimple[]; userOptions: Api.SystemManage.UserSimple[];
roleOptions: Api.SystemManage.RoleSimple[];
roleLoading: boolean;
managerRoleError: string;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:members', members: Api.Product.CreateProductMemberParams[]): void; (e: 'update:members', members: Api.Product.CreateProductMemberParams[]): void;
(e: 'update:watcherUserIds', watcherUserIds: string[]): void;
}>(); }>();
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
const roleLoading = ref(false);
const managerRoleError = ref('');
const members = ref<DraftMember[]>([]); const members = ref<DraftMember[]>([]);
const memberDialogVisible = ref(false); const memberDialogVisible = ref(false);
const memberDialogMode = ref<'create' | 'edit'>('create');
const editingKey = ref<string | null>(null); const editingKey = ref<string | null>(null);
const watcherUserIds = ref<string[]>([]); const batchDialogVisible = ref(false);
// 关心人候选用户:排除已在团队成员列表中的用户(包含产品经理本人) const batchDisabledUserIds = computed(() => members.value.map(item => item.userId).filter(Boolean));
const watcherUserOptions = computed(() => {
const memberUserIds = new Set(members.value.map(item => item.userId).filter(Boolean));
return props.userOptions.filter(user => !memberUserIds.has(user.id));
});
const teamTableHeight = getProductTeamTableHeight(4);
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname]))); const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const managerRole = computed(() => roleOptions.value.find(item => item.code === PRODUCT_MANAGER_ROLE_CODE) ?? null); const managerRole = computed(() => props.roleOptions.find(item => item.code === PRODUCT_MANAGER_ROLE_CODE) ?? null);
// 弹框传入的禁选用户列表:新增时排除所有已选;编辑时排除自身以外的已选
const dialogDisabledUserIds = computed(() => {
return members.value
.filter(item => !editingKey.value || item.key !== editingKey.value)
.map(item => item.userId)
.filter(Boolean);
});
const dialogInitial = computed(() => { const dialogInitial = computed(() => {
if (memberDialogMode.value === 'create' || !editingKey.value) { if (!editingKey.value) return null;
return null;
}
const target = members.value.find(item => item.key === editingKey.value); const target = members.value.find(item => item.key === editingKey.value);
if (!target) return null;
if (!target) {
return null;
}
return { userId: target.userId, roleId: target.roleId, remark: target.remark }; return { userId: target.userId, roleId: target.roleId, remark: target.remark };
}); });
@@ -80,31 +58,13 @@ function getUserNickname(userId: string) {
} }
function getRoleName(roleId: string) { function getRoleName(roleId: string) {
return roleOptions.value.find(item => item.id === roleId)?.name || '--'; return props.roleOptions.find(item => item.id === roleId)?.name || '--';
} }
function generateKey() { function generateKey() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
} }
async function loadRoles() {
roleLoading.value = true;
managerRoleError.value = '';
const { data } = await fetchGetRoleSimpleList({ scopeType: 'object', objectType: 'product' });
roleLoading.value = false;
roleOptions.value = data ?? [];
if (!managerRole.value) {
managerRoleError.value = '未找到产品经理角色,请联系管理员';
return;
}
refreshManagerRow();
}
function refreshManagerRow() { function refreshManagerRow() {
const managerUserId = props.baseInfo.managerUserId; const managerUserId = props.baseInfo.managerUserId;
@@ -132,14 +92,25 @@ function refreshManagerRow() {
emitMembers(); emitMembers();
} }
function openCreate() { function openBatch() {
memberDialogMode.value = 'create'; batchDialogVisible.value = true;
editingKey.value = null; }
memberDialogVisible.value = true;
function handleBatchSubmit(payloads: BatchMemberPayload[]) {
for (const p of payloads) {
members.value.push({
key: generateKey(),
userId: p.userId,
roleId: p.roleId,
remark: p.remark,
locked: false
});
}
batchDialogVisible.value = false;
emitMembers();
} }
function openEdit(row: DraftMember) { function openEdit(row: DraftMember) {
memberDialogMode.value = 'edit';
editingKey.value = row.key; editingKey.value = row.key;
memberDialogVisible.value = true; memberDialogVisible.value = true;
} }
@@ -149,27 +120,16 @@ function removeMember(key: string) {
emitMembers(); emitMembers();
} }
function handleMemberSubmit(payload: { userId: string; roleId: string; remark: string }) { function handleMemberEditSubmit(payload: { userId: string; roleId: string; remark: string }) {
if (memberDialogMode.value === 'create') { if (!editingKey.value) return;
members.value.push({ const idx = members.value.findIndex(item => item.key === editingKey.value);
key: generateKey(), if (idx >= 0) {
userId: payload.userId, members.value[idx] = {
...members.value[idx],
roleId: payload.roleId, roleId: payload.roleId,
remark: payload.remark, remark: payload.remark
locked: false };
});
} else if (editingKey.value) {
const idx = members.value.findIndex(item => item.key === editingKey.value);
if (idx >= 0) {
members.value[idx] = {
...members.value[idx],
roleId: payload.roleId,
remark: payload.remark
};
}
} }
memberDialogVisible.value = false; memberDialogVisible.value = false;
emitMembers(); emitMembers();
} }
@@ -188,8 +148,8 @@ function emitMembers() {
} }
async function runValidate(): Promise<boolean> { async function runValidate(): Promise<boolean> {
if (managerRoleError.value) { if (props.managerRoleError) {
window.$message?.error(managerRoleError.value); window.$message?.error(props.managerRoleError);
return false; return false;
} }
@@ -214,43 +174,32 @@ async function runValidate(): Promise<boolean> {
return true; return true;
} }
function handleWatcherChange(ids: string[]) {
watcherUserIds.value = ids;
emit('update:watcherUserIds', ids);
}
// 团队成员变化时,剔除已被加入团队的关心人,避免重叠
watch(
() => members.value.map(item => item.userId).join(','),
() => {
const memberUserIds = new Set(members.value.map(item => item.userId).filter(Boolean));
const filtered = watcherUserIds.value.filter(id => !memberUserIds.has(id));
if (filtered.length !== watcherUserIds.value.length) {
handleWatcherChange(filtered);
}
}
);
onMounted(loadRoles);
watch( watch(
() => props.baseInfo.managerUserId, () => props.baseInfo.managerUserId,
() => { () => {
if (!managerRoleError.value && managerRole.value) { if (!props.managerRoleError && managerRole.value) {
refreshManagerRow(); refreshManagerRow();
} }
} }
); );
// roleOptions 异步加载到位后,补一次 locked 行刷新
watch(managerRole, () => {
if (!props.managerRoleError && managerRole.value && props.baseInfo.managerUserId) {
refreshManagerRow();
}
});
defineExpose({ validate: runValidate }); defineExpose({ validate: runValidate });
</script> </script>
<template> <template>
<div v-loading="roleLoading" class="team-step"> <div v-loading="roleLoading" class="team-step">
<div class="team-step__toolbar"> <button type="button" class="team-step__add" :disabled="Boolean(managerRoleError)" @click="openBatch">
<ElButton type="primary" plain :disabled="Boolean(managerRoleError)" @click="openCreate">新增成员</ElButton> <icon-ep:plus class="team-step__add-icon" />
</div> <span>新增成员</span>
<span class="team-step__add-hint">从部门 / 管理链路 / 全公司 批量选人</span>
</button>
<ElAlert <ElAlert
v-if="managerRoleError" v-if="managerRoleError"
@@ -261,62 +210,49 @@ defineExpose({ validate: runValidate });
class="team-step__alert" class="team-step__alert"
/> />
<ElTable :data="members" :height="teamTableHeight" border row-key="key" empty-text="点击右上角新增成员添加"> <div class="team-step__table-wrap">
<ElTableColumn type="index" label="序号" width="64" align="center" /> <ElTable :data="members" height="100%" border row-key="key" empty-text="点击右上角新增成员添加">
<ElTableColumn label="成员姓名" min-width="120"> <ElTableColumn type="index" label="序号" width="64" align="center" />
<template #default="{ row }"> <ElTableColumn label="成员姓名" min-width="120">
{{ getUserNickname(row.userId) }} <template #default="{ row }">
</template> {{ getUserNickname(row.userId) }}
</ElTableColumn> </template>
<ElTableColumn label="当前角色" min-width="140"> </ElTableColumn>
<template #default="{ row }"> <ElTableColumn label="当前角色" min-width="140">
{{ getRoleName(row.roleId) }} <template #default="{ row }">
</template> {{ getRoleName(row.roleId) }}
</ElTableColumn> </template>
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip> </ElTableColumn>
<template #default="{ row }"> <ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
{{ row.remark || '--' }} <template #default="{ row }">
</template> {{ row.remark || '--' }}
</ElTableColumn> </template>
<ElTableColumn label="操作" width="150" fixed="right" align="center"> </ElTableColumn>
<template #default="{ row }"> <ElTableColumn label="操作" width="150" fixed="right" align="center">
<div class="team-step__actions"> <template #default="{ row }">
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton> <div class="team-step__actions">
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton> <ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
</div> <ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
</template> </div>
</ElTableColumn> </template>
</ElTable> </ElTableColumn>
</ElTable>
<div class="watcher-row">
<span class="watcher-row__label">
关心人
<span class="watcher-row__optional">选填</span>
</span>
<ElSelect
:model-value="watcherUserIds"
multiple
filterable
clearable
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="3"
placeholder="可在列表 / 概览看到此产品的关注人"
class="watcher-row__select"
@update:model-value="handleWatcherChange"
>
<ElOption v-for="item in watcherUserOptions" :key="item.id" :value="item.id" :label="item.nickname" />
</ElSelect>
</div> </div>
<ProductCreateTeamMemberDialog <ProductCreateTeamMemberDialog
v-model:visible="memberDialogVisible" v-model:visible="memberDialogVisible"
:mode="memberDialogMode"
:initial="dialogInitial" :initial="dialogInitial"
:user-options="userOptions" :user-options="userOptions"
:role-options="roleOptions" :role-options="roleOptions"
:disabled-user-ids="dialogDisabledUserIds" @submit="handleMemberEditSubmit"
@submit="handleMemberSubmit" />
<ProductTeamBatchDialog
v-model:visible="batchDialogVisible"
:user-options="userOptions"
:role-options="roleOptions"
:disabled-user-ids="batchDisabledUserIds"
@submit="handleBatchSubmit"
/> />
</div> </div>
</template> </template>
@@ -326,16 +262,65 @@ defineExpose({ validate: runValidate });
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
height: 100%;
min-height: 0; min-height: 0;
} }
.team-step__toolbar { .team-step__add {
display: flex; display: flex;
justify-content: flex-end; align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
height: 44px;
padding: 0 16px;
border: 1px dashed var(--el-border-color-darker);
border-radius: 6px;
background: transparent;
color: var(--el-text-color-regular);
font-size: 13px;
cursor: pointer;
transition:
border-color 0.2s ease,
color 0.2s ease,
background-color 0.2s ease;
flex-shrink: 0;
}
.team-step__add:hover:not(:disabled) {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.team-step__add:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.team-step__add-icon {
font-size: 16px;
}
.team-step__add-hint {
color: var(--el-text-color-placeholder);
font-size: 12px;
font-weight: 400;
}
.team-step__add:hover:not(:disabled) .team-step__add-hint {
color: var(--el-color-primary);
opacity: 0.7;
} }
.team-step__alert { .team-step__alert {
margin: 0; margin: 0;
flex-shrink: 0;
}
.team-step__table-wrap {
flex: 1 1 auto;
min-height: 0;
} }
.team-step__actions { .team-step__actions {
@@ -343,27 +328,4 @@ defineExpose({ validate: runValidate });
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.watcher-row {
display: flex;
align-items: center;
gap: 10px;
}
.watcher-row__label {
flex: 0 0 auto;
font-size: 13px;
font-weight: 500;
color: rgb(60 70 95 / 96%);
}
.watcher-row__optional {
color: rgb(140 150 170 / 96%);
font-weight: 400;
}
.watcher-row__select {
flex: 1 1 auto;
min-width: 0;
}
</style> </style>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict'; import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { fetchCreateProductWithTeam, fetchGetProduct, fetchUpdateProduct } from '@/service/api'; import { fetchCreateProductWithTeam, fetchGetProduct, fetchGetRoleSimpleList, fetchUpdateProduct } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form'; import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue'; import DictSelect from '@/components/custom/dict-select.vue';
@@ -112,9 +113,29 @@ const baseFormRef = ref<InstanceType<typeof ProductCreateBaseForm> | null>(null)
const teamStepRef = ref<InstanceType<typeof ProductCreateTeamStep> | null>(null); const teamStepRef = ref<InstanceType<typeof ProductCreateTeamStep> | null>(null);
const currentStep = ref<1 | 2>(1); const currentStep = ref<1 | 2>(1);
// === 新增模式:角色列表(父级加载,下发给 team-step 与批量弹层) ===
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
const roleLoading = ref(false);
const managerRoleError = ref('');
const managerRole = computed(() => roleOptions.value.find(item => item.code === PRODUCT_MANAGER_ROLE_CODE) ?? null);
async function loadRoles() {
roleLoading.value = true;
managerRoleError.value = '';
const { data } = await fetchGetRoleSimpleList({ scopeType: 'object', objectType: 'product' });
roleLoading.value = false;
roleOptions.value = data ?? [];
if (!managerRole.value) {
managerRoleError.value = '未找到产品经理角色,请联系管理员';
}
}
const createBaseModel = ref<ProductCreateBaseFormModel>(createBaseInfo()); const createBaseModel = ref<ProductCreateBaseFormModel>(createBaseInfo());
const draftMembers = ref<Api.Product.CreateProductMemberParams[]>([]); const draftMembers = ref<Api.Product.CreateProductMemberParams[]>([]);
const draftWatcherUserIds = ref<string[]>([]);
function createBaseInfo(): ProductCreateBaseFormModel { function createBaseInfo(): ProductCreateBaseFormModel {
return { code: '', name: '', directionCode: '', managerUserId: null, description: '' }; return { code: '', name: '', directionCode: '', managerUserId: null, description: '' };
@@ -158,8 +179,7 @@ async function handleCreateSubmit() {
managerUserId: createBaseModel.value.managerUserId as string, managerUserId: createBaseModel.value.managerUserId as string,
description: getNullableText(createBaseModel.value.description) description: getNullableText(createBaseModel.value.description)
}, },
members: draftMembers.value, members: draftMembers.value
watcherUserIds: draftWatcherUserIds.value.length > 0 ? draftWatcherUserIds.value : undefined
}; };
const { error, data } = await fetchCreateProductWithTeam(payload); const { error, data } = await fetchCreateProductWithTeam(payload);
@@ -188,8 +208,8 @@ watch(visible, async value => {
editModel.value = createEditModel(); editModel.value = createEditModel();
createBaseModel.value = createBaseInfo(); createBaseModel.value = createBaseInfo();
draftMembers.value = []; draftMembers.value = [];
draftWatcherUserIds.value = [];
await nextTick(); await nextTick();
await loadRoles();
editFormRef.value?.clearValidate(); editFormRef.value?.clearValidate();
return; return;
} }
@@ -295,7 +315,7 @@ watch(visible, async value => {
</ElForm> </ElForm>
</BusinessFormDialog> </BusinessFormDialog>
<!-- 新增模式两步向导复合内容特例自定义 ElDialog 880px --> <!-- 新增模式:两步向导(复合内容特例,1080px,左侧概念区 + 右侧步骤面板) -->
<ElDialog <ElDialog
v-else v-else
v-model="visible" v-model="visible"
@@ -304,43 +324,85 @@ watch(visible, async value => {
:close-on-click-modal="false" :close-on-click-modal="false"
destroy-on-close destroy-on-close
align-center align-center
width="760px" width="1080px"
> >
<div class="product-create-dialog__stepbar"> <div class="product-create-dialog__split">
<div class="product-create-dialog__step" :class="{ 'is-active': currentStep === 1, 'is-done': currentStep > 1 }"> <aside class="product-create-dialog__guide">
<span class="product-create-dialog__step-index">1</span> <div class="product-create-dialog__guide-hero">
<span class="product-create-dialog__step-text"> <div class="product-create-dialog__guide-hero-icon">
<strong>基础资料</strong> <icon-ep:box />
<small>定义产品身份和负责人</small> </div>
</span> <div class="product-create-dialog__guide-hero-text">
</div> <div class="product-create-dialog__guide-hero-title">产品</div>
<div class="product-create-dialog__step" :class="{ 'is-active': currentStep === 2 }"> <div class="product-create-dialog__guide-hero-sub">需求 · 项目 · 迭代 的承载单元</div>
<span class="product-create-dialog__step-index">2</span> </div>
<span class="product-create-dialog__step-text"> </div>
<strong>初始化团队</strong>
<small>配置对象域成员角色</small>
</span>
</div>
</div>
<div class="product-create-dialog__body"> <p class="product-create-dialog__guide-lead">
<div v-show="currentStep === 1" class="product-create-dialog__panel"> 产品与需求池管理,提供多维度的需求规划工具,打通客户业务团队和产研团队之间的协作
<ProductCreateBaseForm ref="baseFormRef" v-model="createBaseModel" :manager-user-options="managerUserOptions" /> </p>
</div>
<div v-show="currentStep === 2" class="product-create-dialog__panel"> <section class="product-create-dialog__guide-section">
<ProductCreateTeamStep <h4>包含</h4>
ref="teamStepRef" <p>需求变更迭代模块文档状态统计报表</p>
:base-info="createBaseModel" </section>
:user-options="managerUserOptions"
@update:members="draftMembers = $event" <section class="product-create-dialog__guide-section">
@update:watcher-user-ids="draftWatcherUserIds = $event" <h4>参与人</h4>
/> <p>产品经理(必填,创建后锁定) · 团队角色(业务专员 / 游客 / 关注人)</p>
</section>
<section class="product-create-dialog__guide-section">
<h4>命名建议</h4>
<p>建议使用业务团队约定俗成的简短名称,产品创建后不再轻易调整,会影响下游引用</p>
</section>
</aside>
<div class="product-create-dialog__main">
<div class="product-create-dialog__stepbar">
<div
class="product-create-dialog__step"
:class="{ 'is-active': currentStep === 1, 'is-done': currentStep > 1 }"
>
<span class="product-create-dialog__step-index">1</span>
<span class="product-create-dialog__step-text">
<strong>基础资料</strong>
</span>
</div>
<div class="product-create-dialog__step" :class="{ 'is-active': currentStep === 2 }">
<span class="product-create-dialog__step-index">2</span>
<span class="product-create-dialog__step-text">
<strong>初始化团队</strong>
</span>
</div>
</div>
<div class="product-create-dialog__body">
<div v-show="currentStep === 1" class="product-create-dialog__panel">
<ProductCreateBaseForm
ref="baseFormRef"
v-model="createBaseModel"
:manager-user-options="managerUserOptions"
/>
</div>
<div v-show="currentStep === 2" class="product-create-dialog__panel">
<ProductCreateTeamStep
ref="teamStepRef"
:base-info="createBaseModel"
:user-options="managerUserOptions"
:role-options="roleOptions"
:role-loading="roleLoading"
:manager-role-error="managerRoleError"
@update:members="draftMembers = $event"
/>
</div>
</div>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<div class="product-create-dialog__footer"> <div class="product-create-dialog__footer">
<span class="product-create-dialog__footer-meta"> {{ currentStep }} 2 </span> <span class="product-create-dialog__footer-meta"> {{ currentStep }} , 2 </span>
<ElSpace :size="10"> <ElSpace :size="10">
<ElButton @click="closeDialog">取消</ElButton> <ElButton @click="closeDialog">取消</ElButton>
<ElButton v-if="currentStep === 2" @click="goPrev">上一步</ElButton> <ElButton v-if="currentStep === 2" @click="goPrev">上一步</ElButton>
@@ -378,6 +440,92 @@ watch(visible, async value => {
padding: 0; padding: 0;
} }
.product-create-dialog__split {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 0;
}
.product-create-dialog__guide {
padding: 28px 24px;
background: linear-gradient(180deg, #f7f9fc 0%, #fafbfc 100%);
border-right: 1px solid rgb(229 233 242 / 96%);
overflow-y: auto;
max-height: min(720px, calc(100vh - 160px));
}
.product-create-dialog__guide-hero {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid rgb(229 233 242 / 96%);
}
.product-create-dialog__guide-hero-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 10px;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-size: 22px;
flex-shrink: 0;
}
.product-create-dialog__guide-hero-text {
min-width: 0;
}
.product-create-dialog__guide-hero-title {
font-size: 15px;
font-weight: 650;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.product-create-dialog__guide-hero-sub {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
.product-create-dialog__guide-lead {
margin: 0 0 24px;
font-size: 13.5px;
line-height: 1.8;
color: var(--el-text-color-regular);
}
.product-create-dialog__guide-section + .product-create-dialog__guide-section {
margin-top: 20px;
}
.product-create-dialog__guide-section h4 {
margin: 0 0 6px;
font-size: 12.5px;
font-weight: 700;
color: var(--el-text-color-primary);
letter-spacing: 0.4px;
}
.product-create-dialog__guide-section p {
margin: 0;
font-size: 13px;
line-height: 1.75;
color: var(--el-text-color-regular);
}
.product-create-dialog__main {
display: flex;
flex-direction: column;
min-width: 0;
}
.product-create-dialog__stepbar { .product-create-dialog__stepbar {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -433,13 +581,15 @@ watch(visible, async value => {
} }
.product-create-dialog__body { .product-create-dialog__body {
min-height: 0; height: min(520px, calc(100vh - 240px));
max-height: min(560px, calc(100vh - 240px)); overflow: hidden;
overflow: auto;
} }
.product-create-dialog__panel { .product-create-dialog__panel {
height: 100%;
padding: 24px; padding: 24px;
overflow: auto;
box-sizing: border-box;
} }
.product-create-dialog__footer { .product-create-dialog__footer {

View File

@@ -4,6 +4,8 @@ import { useMediaQuery } from '@vueuse/core';
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials'; import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
import { objectContextDomainConfigs } from '@/constants/object-context'; import { objectContextDomainConfigs } from '@/constants/object-context';
import { import {
fetchBatchCreateProductMembers,
fetchBatchInactiveProductMembers,
fetchChangeProductStatus, fetchChangeProductStatus,
fetchCreateProductMember, fetchCreateProductMember,
fetchDeleteProduct, fetchDeleteProduct,
@@ -19,8 +21,12 @@ import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context'; import { useObjectContextStore } from '@/store/modules/object-context';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import ProductTeamBatchDialog, {
type BatchMemberPayload
} from '@/views/product/shared/components/product-team-batch-dialog.vue';
import { useCurrentProduct } from '../shared/use-current-product'; import { useCurrentProduct } from '../shared/use-current-product';
import BaseInfoDialog from './modules/base-info-dialog.vue'; import BaseInfoDialog from './modules/base-info-dialog.vue';
import MemberBatchRemoveDialog from './modules/member-batch-remove-dialog.vue';
import MemberOperateDialog from './modules/member-operate-dialog.vue'; import MemberOperateDialog from './modules/member-operate-dialog.vue';
import MemberRemoveDialog from './modules/member-remove-dialog.vue'; import MemberRemoveDialog from './modules/member-remove-dialog.vue';
import ProductDeleteDialog from './modules/product-delete-dialog.vue'; import ProductDeleteDialog from './modules/product-delete-dialog.vue';
@@ -70,7 +76,11 @@ const pageLoading = ref(false);
const memberLoading = ref(false); const memberLoading = ref(false);
const baseInfoVisible = ref(false); const baseInfoVisible = ref(false);
const memberOperateVisible = ref(false); const memberOperateVisible = ref(false);
const memberBatchVisible = ref(false);
const memberRemoveVisible = ref(false); const memberRemoveVisible = ref(false);
const memberBatchRemoveVisible = ref(false);
const teamPanelRef = ref<InstanceType<typeof SettingTeamPanel> | null>(null);
const selectedBatchRemoveMembers = ref<Api.Product.ProductMember[]>([]);
const statusActionVisible = ref(false); const statusActionVisible = ref(false);
const deleteVisible = ref(false); const deleteVisible = ref(false);
const memberOperateMode = ref<'create' | 'edit'>('create'); const memberOperateMode = ref<'create' | 'edit'>('create');
@@ -217,9 +227,7 @@ function scrollToSection(key: string) {
} }
function openCreateMember() { function openCreateMember() {
memberOperateMode.value = 'create'; memberBatchVisible.value = true;
selectedMember.value = null;
memberOperateVisible.value = true;
} }
function openEditMember(member: Api.Product.ProductMember) { function openEditMember(member: Api.Product.ProductMember) {
@@ -233,6 +241,12 @@ function openRemoveMember(member: Api.Product.ProductMember) {
memberRemoveVisible.value = true; memberRemoveVisible.value = true;
} }
function openBatchRemoveMember(targetMembers: Api.Product.ProductMember[]) {
if (!targetMembers.length) return;
selectedBatchRemoveMembers.value = targetMembers;
memberBatchRemoveVisible.value = true;
}
function openLifecycleAction(action: Api.Product.ProductLifecycleAction) { function openLifecycleAction(action: Api.Product.ProductLifecycleAction) {
selectedAction.value = action; selectedAction.value = action;
statusActionVisible.value = true; statusActionVisible.value = true;
@@ -288,6 +302,29 @@ async function handleSubmitMemberOperate(event: {
} }
} }
async function handleSubmitMemberBatch(payloads: BatchMemberPayload[]) {
if (!currentObjectId.value || !payloads.length) {
return;
}
const { error } = await fetchBatchCreateProductMembers(currentObjectId.value, {
members: payloads.map(item => ({
userId: item.userId,
roleId: item.roleId,
remark: item.remark.trim() || null
}))
});
if (error) {
return;
}
window.$message?.success(`已新增 ${payloads.length} 名成员`);
memberBatchVisible.value = false;
await Promise.all([loadMembers(), loadSettings()]);
}
async function handleSubmitRemoveMember(payload: Api.Product.InactiveProductMemberParams) { async function handleSubmitRemoveMember(payload: Api.Product.InactiveProductMemberParams) {
if (!currentObjectId.value || !selectedMember.value?.id) { if (!currentObjectId.value || !selectedMember.value?.id) {
return; return;
@@ -305,6 +342,30 @@ async function handleSubmitRemoveMember(payload: Api.Product.InactiveProductMemb
await Promise.all([loadMembers(), loadSettings()]); await Promise.all([loadMembers(), loadSettings()]);
} }
async function handleSubmitBatchRemoveMember(payload: { reason: string | null }) {
if (!currentObjectId.value || !selectedBatchRemoveMembers.value.length) {
return;
}
const memberIds = selectedBatchRemoveMembers.value.map(item => item.id).filter((id): id is string => Boolean(id));
if (!memberIds.length) return;
const { error } = await fetchBatchInactiveProductMembers(currentObjectId.value, {
memberIds,
reason: payload.reason
});
if (error) return;
window.$message?.success(`已移出 ${memberIds.length} 名成员`);
memberBatchRemoveVisible.value = false;
selectedBatchRemoveMembers.value = [];
teamPanelRef.value?.clearSelection();
await Promise.all([loadMembers(), loadSettings()]);
}
async function handleSubmitLifecycleAction(payload: Api.Product.ChangeProductStatusParams) { async function handleSubmitLifecycleAction(payload: Api.Product.ChangeProductStatusParams) {
if (!currentObjectId.value || !selectedAction.value) { if (!currentObjectId.value || !selectedAction.value) {
return; return;
@@ -393,6 +454,7 @@ watch(
<section :id="sectionIdMap.team" class="product-setting-page__section"> <section :id="sectionIdMap.team" class="product-setting-page__section">
<SettingTeamPanel <SettingTeamPanel
ref="teamPanelRef"
:members="members" :members="members"
:role-options="roleOptions" :role-options="roleOptions"
:loading="memberLoading" :loading="memberLoading"
@@ -400,6 +462,7 @@ watch(
@create="openCreateMember" @create="openCreateMember"
@edit="openEditMember" @edit="openEditMember"
@remove="openRemoveMember" @remove="openRemoveMember"
@batch-remove="openBatchRemoveMember"
/> />
</section> </section>
@@ -427,11 +490,23 @@ watch(
:disabled-user-ids="members.filter(member => member.status === 0).map(member => member.userId)" :disabled-user-ids="members.filter(member => member.status === 0).map(member => member.userId)"
@submit="handleSubmitMemberOperate" @submit="handleSubmitMemberOperate"
/> />
<ProductTeamBatchDialog
v-model:visible="memberBatchVisible"
:user-options="userOptions"
:role-options="roleOptions"
:disabled-user-ids="members.filter(member => member.status === 0).map(member => member.userId)"
@submit="handleSubmitMemberBatch"
/>
<MemberRemoveDialog <MemberRemoveDialog
v-model:visible="memberRemoveVisible" v-model:visible="memberRemoveVisible"
:member="selectedMember" :member="selectedMember"
@submit="handleSubmitRemoveMember" @submit="handleSubmitRemoveMember"
/> />
<MemberBatchRemoveDialog
v-model:visible="memberBatchRemoveVisible"
:members="selectedBatchRemoveMembers"
@submit="handleSubmitBatchRemoveMember"
/>
<StatusActionDialog <StatusActionDialog
v-model:visible="statusActionVisible" v-model:visible="statusActionVisible"
:action="selectedAction" :action="selectedAction"

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'MemberBatchRemoveDialog' });
interface Props {
members: Api.Product.ProductMember[];
}
interface Emits {
(e: 'submit', payload: { reason: string | null }): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const model = reactive({
reason: ''
});
const previewNames = computed(() => {
const names = props.members.map(item => item.userNickname || item.userId || '').filter(Boolean);
if (names.length <= 5) {
return names.join('、');
}
return `${names.slice(0, 5).join('、')}${names.length}`;
});
function handleConfirm() {
emit('submit', {
reason: model.reason.trim() || null
});
}
watch(
() => visible.value,
value => {
if (!value) {
return;
}
model.reason = '';
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" title="批量移出成员" preset="sm" @confirm="handleConfirm">
<ElAlert
:title="`确认将选中的 ${props.members.length} 名成员(${previewNames})从当前产品团队中移出吗?`"
type="warning"
:closable="false"
class="mb-16px"
/>
<ElForm label-position="top">
<ElFormItem label="移出原因">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入移出原因(统一应用到所有选中成员)"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import type { TableInstance } from 'element-plus';
import { filterProductMembers, formatProductMemberDate, getProductTeamTableHeight } from '../shared'; import { filterProductMembers, formatProductMemberDate, getProductTeamTableHeight } from '../shared';
defineOptions({ name: 'SettingTeamPanel' }); defineOptions({ name: 'SettingTeamPanel' });
@@ -15,6 +16,7 @@ interface Emits {
(e: 'create'): void; (e: 'create'): void;
(e: 'edit', member: Api.Product.ProductMember): void; (e: 'edit', member: Api.Product.ProductMember): void;
(e: 'remove', member: Api.Product.ProductMember): void; (e: 'remove', member: Api.Product.ProductMember): void;
(e: 'batch-remove', members: Api.Product.ProductMember[]): void;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -26,19 +28,41 @@ const emit = defineEmits<Emits>();
const searchKeyword = ref(''); const searchKeyword = ref('');
const selectedRoleId = ref(''); const selectedRoleId = ref('');
const teamTableHeight = getProductTeamTableHeight(5); const teamTableHeight = getProductTeamTableHeight(5);
const tableRef = ref<TableInstance | null>(null);
const selectedRows = ref<Api.Product.ProductMember[]>([]);
const selectedCount = computed(() => selectedRows.value.length);
function isRowSelectable(row: Api.Product.ProductMember) {
return row.status === 0 && !row.managerFlag;
}
function handleSelectionChange(rows: Api.Product.ProductMember[]) {
selectedRows.value = rows;
}
function handleBatchRemove() {
if (!selectedRows.value.length) return;
emit('batch-remove', [...selectedRows.value]);
}
function clearSelection() {
tableRef.value?.clearSelection();
selectedRows.value = [];
}
defineExpose({ clearSelection });
const roleFilterOptions = computed(() => { const roleFilterOptions = computed(() => {
const roleMap = new Map<string, string>(); const seen = new Set<string>();
const result: Api.SystemManage.RoleSimple[] = [];
props.roleOptions.forEach(role => { props.roleOptions.forEach(role => {
if (!roleMap.has(role.id)) { if (role.visible === 0) return;
roleMap.set(role.id, role.name); if (seen.has(role.id)) return;
} seen.add(role.id);
result.push(role);
}); });
return [...roleMap.entries()].map(([value, label]) => ({ return result;
value,
label
}));
}); });
const filteredMembers = computed(() => const filteredMembers = computed(() =>
filterProductMembers(props.members, { filterProductMembers(props.members, {
@@ -49,7 +73,7 @@ const filteredMembers = computed(() =>
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value)); const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
watch(roleFilterOptions, options => { watch(roleFilterOptions, options => {
if (selectedRoleId.value && !options.some(item => item.value === selectedRoleId.value)) { if (selectedRoleId.value && !options.some(item => item.id === selectedRoleId.value)) {
selectedRoleId.value = ''; selectedRoleId.value = '';
} }
}); });
@@ -72,12 +96,14 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
</div> </div>
<div class="setting-team-panel__toolbar"> <div class="setting-team-panel__toolbar">
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter"> <ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
<ElOption <ElOption v-for="role in roleFilterOptions" :key="role.id" :label="role.name" :value="role.id">
v-for="option in roleFilterOptions" <div class="setting-team-panel__role-option">
:key="option.value" <span class="setting-team-panel__role-option-name">{{ role.name }}</span>
:label="option.label" <ElTooltip v-if="role.remark" :content="role.remark" placement="right" :show-after="120">
:value="option.value" <icon-ep:info-filled class="setting-team-panel__role-option-info" @click.stop />
/> </ElTooltip>
</div>
</ElOption>
</ElSelect> </ElSelect>
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" /> <ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
<ElButton <ElButton
@@ -89,35 +115,42 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
> >
新增成员 新增成员
</ElButton> </ElButton>
<ElButton
v-if="!props.readonly"
v-auth="{ code: 'project:product:update', source: 'object' }"
type="danger"
plain
:disabled="selectedCount === 0"
@click="handleBatchRemove"
>
批量移出{{ selectedCount > 0 ? `${selectedCount}` : '' }}
</ElButton>
</div> </div>
</div> </div>
</template> </template>
<ElTable <ElTable
ref="tableRef"
v-loading="props.loading" v-loading="props.loading"
:data="filteredMembers" :data="filteredMembers"
:height="teamTableHeight" :height="teamTableHeight"
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'" :empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
border border
row-key="id" row-key="id"
@selection-change="handleSelectionChange"
> >
<ElTableColumn
v-if="!props.readonly"
type="selection"
width="48"
align="center"
:selectable="(row: Api.Product.ProductMember) => isRowSelectable(row)"
/>
<ElTableColumn type="index" label="序号" width="64" align="center" /> <ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" /> <ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
<ElTableColumn label="当前角色" min-width="180"> <ElTableColumn label="当前角色" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<div class="setting-team-panel__role-cell"> {{ row.roleName || '--' }}
<span class="setting-team-panel__role-main">{{ row.roleName || '--' }}</span>
<ElTag
v-for="extra in row.additionalRoleNames"
:key="extra"
size="small"
type="info"
effect="plain"
class="setting-team-panel__role-extra"
>
{{ extra }}
</ElTag>
</div>
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn label="成员状态" width="110" align="center"> <ElTableColumn label="成员状态" width="110" align="center">
@@ -196,15 +229,31 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
gap: 12px; gap: 12px;
} }
.setting-team-panel__role-cell { .setting-team-panel__role-option {
display: inline-flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; justify-content: space-between;
gap: 6px; gap: 8px;
width: 100%;
} }
.setting-team-panel__role-extra { .setting-team-panel__role-option-name {
font-weight: 400; flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.setting-team-panel__role-option-info {
flex-shrink: 0;
font-size: 14px;
color: var(--el-text-color-placeholder);
cursor: help;
}
.setting-team-panel__role-option-info:hover {
color: var(--el-color-primary);
} }
@media (width <= 768px) { @media (width <= 768px) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue'; import { computed, nextTick, reactive, watch } from 'vue';
import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
import { useForm, useFormRules } from '@/hooks/common/form'; import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
defineOptions({ name: 'ProjectCreateTeamMemberDialog' }); defineOptions({ name: 'ProjectCreateTeamMemberDialog' });
type OperateMode = 'create' | 'edit';
interface DraftMemberInput { interface DraftMemberInput {
userId: string; userId: string;
roleId: string; roleId: string;
@@ -16,22 +12,16 @@ interface DraftMemberInput {
} }
interface Props { interface Props {
mode: OperateMode;
initial: DraftMemberInput | null; initial: DraftMemberInput | null;
userOptions: Api.SystemManage.UserSimple[]; userOptions: Api.SystemManage.UserSimple[];
roleOptions: Api.SystemManage.RoleSimple[]; roleOptions: Api.SystemManage.RoleSimple[];
/** 已使用且不可选的 userId编辑模式应当排除当前行自身 */
disabledUserIds?: readonly string[];
} }
interface Emits { interface Emits {
(e: 'submit', payload: DraftMemberInput): void; (e: 'submit', payload: DraftMemberInput): void;
} }
const props = withDefaults(defineProps<Props>(), { const props = defineProps<Props>();
disabledUserIds: () => []
});
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false }); const visible = defineModel<boolean>('visible', { default: false });
@@ -39,31 +29,19 @@ const visible = defineModel<boolean>('visible', { default: false });
const { formRef, validate } = useForm(); const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules(); const { createRequiredRule } = useFormRules();
const model = reactive<DraftMemberInput>({ const model = reactive<DraftMemberInput>({ userId: '', roleId: '', remark: '' });
userId: '',
roleId: '',
remark: ''
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname]))); const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const rules = computed( const rules = computed(
() => () =>
({ ({
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
roleId: [createRequiredRule('请选择角色')] roleId: [createRequiredRule('请选择角色')]
}) satisfies Record<string, App.Global.FormRule[]> }) satisfies Record<string, App.Global.FormRule[]>
); );
function isManagerRole(role: Api.SystemManage.RoleSimple) {
return role.code === PROJECT_MANAGER_ROLE_CODE;
}
async function handleConfirm() { async function handleConfirm() {
await validate(); await validate();
emit('submit', { emit('submit', {
userId: model.userId, userId: model.userId,
roleId: model.roleId, roleId: model.roleId,
@@ -72,34 +50,21 @@ async function handleConfirm() {
} }
watch(visible, async value => { watch(visible, async value => {
if (!value) { if (!value) return;
return;
}
model.userId = props.initial?.userId || ''; model.userId = props.initial?.userId || '';
model.roleId = props.initial?.roleId || ''; model.roleId = props.initial?.roleId || '';
model.remark = props.initial?.remark || ''; model.remark = props.initial?.remark || '';
await nextTick(); await nextTick();
formRef.value?.clearValidate(); formRef.value?.clearValidate();
}); });
</script> </script>
<template> <template>
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="sm" @confirm="handleConfirm"> <BusinessFormDialog v-model="visible" title="调整成员角色" preset="sm" @confirm="handleConfirm">
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false"> <ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElRow :gutter="16"> <ElRow :gutter="16">
<ElCol :span="24"> <ElCol :span="24">
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId"> <ElFormItem label="成员用户">
<BusinessUserSelect
v-model="model.userId"
:options="userOptions"
:disabled-user-ids="disabledUserIds"
disabled-label="已添加"
placeholder="请选择成员用户"
/>
</ElFormItem>
<ElFormItem v-else label="成员用户">
<ElInput <ElInput
:model-value="userLabelMap.get(String(model.userId)) || ''" :model-value="userLabelMap.get(String(model.userId)) || ''"
readonly readonly
@@ -111,18 +76,7 @@ watch(visible, async value => {
<ElCol :span="24"> <ElCol :span="24">
<ElFormItem label="目标角色" prop="roleId"> <ElFormItem label="目标角色" prop="roleId">
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色"> <ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
<ElOption <ElOption v-for="role in roleOptions" :key="role.id" :label="role.name" :value="role.id" />
v-for="role in roleOptions"
:key="role.id"
:label="role.name"
:value="role.id"
:disabled="isManagerRole(role)"
>
<span>{{ role.name }}</span>
<span v-if="isManagerRole(role)" class="project-create-team-member-dialog__role-hint">
已由第 1 步指定
</span>
</ElOption>
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
@@ -144,12 +98,6 @@ watch(visible, async value => {
</template> </template>
<style scoped> <style scoped>
.project-create-team-member-dialog__role-hint {
margin-left: 8px;
color: rgb(148 163 184 / 96%);
font-size: 12px;
}
:deep(.project-create-team-member-dialog__readonly-input .el-input__wrapper) { :deep(.project-create-team-member-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249); background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset; box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business'; import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
import { fetchGetRoleSimpleList } from '@/service/api'; import ProjectTeamBatchDialog, {
import { getProjectTeamTableHeight } from '@/views/project/project/setting/shared'; type BatchMemberPayload
} from '@/views/project/shared/components/project-team-batch-dialog.vue';
import ProjectCreateTeamMemberDialog from './project-create-team-member-dialog.vue'; import ProjectCreateTeamMemberDialog from './project-create-team-member-dialog.vue';
import type { ProjectCreateBaseForm } from './project-create-base-form.vue'; import type { ProjectCreateBaseForm } from './project-create-base-form.vue';
@@ -21,57 +22,34 @@ interface DraftMember {
interface Props { interface Props {
baseInfo: ProjectCreateBaseForm; baseInfo: ProjectCreateBaseForm;
userOptions: Api.SystemManage.UserSimple[]; userOptions: Api.SystemManage.UserSimple[];
roleOptions: Api.SystemManage.RoleSimple[];
roleLoading: boolean;
managerRoleError: string;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:members', members: Api.Project.CreateProjectMemberParams[]): void; (e: 'update:members', members: Api.Project.CreateProjectMemberParams[]): void;
(e: 'update:watcherUserIds', watcherUserIds: string[]): void;
}>(); }>();
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
const roleLoading = ref(false);
const managerRoleError = ref('');
const members = ref<DraftMember[]>([]); const members = ref<DraftMember[]>([]);
const memberDialogVisible = ref(false); const memberDialogVisible = ref(false);
const memberDialogMode = ref<'create' | 'edit'>('create');
const editingKey = ref<string | null>(null); const editingKey = ref<string | null>(null);
const watcherUserIds = ref<string[]>([]); const batchDialogVisible = ref(false);
// 关心人候选用户:排除已在团队成员列表中的用户(包含项目负责人本人) const batchDisabledUserIds = computed(() => members.value.map(item => item.userId).filter(Boolean));
const watcherUserOptions = computed(() => {
const memberUserIds = new Set(members.value.map(item => item.userId).filter(Boolean));
return props.userOptions.filter(user => !memberUserIds.has(user.id));
});
const teamTableHeight = getProjectTeamTableHeight(4);
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname]))); const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const managerRole = computed(() => roleOptions.value.find(item => item.code === PROJECT_MANAGER_ROLE_CODE) ?? null); const managerRole = computed(() => props.roleOptions.find(item => item.code === PROJECT_MANAGER_ROLE_CODE) ?? null);
// 弹框传入的禁选用户列表:新增时排除所有已选;编辑时排除自身以外的已选
const dialogDisabledUserIds = computed(() => {
return members.value
.filter(item => !editingKey.value || item.key !== editingKey.value)
.map(item => item.userId)
.filter(Boolean);
});
const dialogInitial = computed(() => { const dialogInitial = computed(() => {
if (memberDialogMode.value === 'create' || !editingKey.value) { if (!editingKey.value) return null;
return null;
}
const target = members.value.find(item => item.key === editingKey.value); const target = members.value.find(item => item.key === editingKey.value);
if (!target) return null;
if (!target) {
return null;
}
return { userId: target.userId, roleId: target.roleId, remark: target.remark }; return { userId: target.userId, roleId: target.roleId, remark: target.remark };
}); });
@@ -80,31 +58,13 @@ function getUserNickname(userId: string) {
} }
function getRoleName(roleId: string) { function getRoleName(roleId: string) {
return roleOptions.value.find(item => item.id === roleId)?.name || '--'; return props.roleOptions.find(item => item.id === roleId)?.name || '--';
} }
function generateKey() { function generateKey() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
} }
async function loadRoles() {
roleLoading.value = true;
managerRoleError.value = '';
const { data } = await fetchGetRoleSimpleList({ scopeType: 'object', objectType: 'project' });
roleLoading.value = false;
roleOptions.value = data ?? [];
if (!managerRole.value) {
managerRoleError.value = '未找到项目经理角色,请联系管理员';
return;
}
refreshManagerRow();
}
function refreshManagerRow() { function refreshManagerRow() {
const managerUserId = props.baseInfo.managerUserId; const managerUserId = props.baseInfo.managerUserId;
@@ -132,14 +92,25 @@ function refreshManagerRow() {
emitMembers(); emitMembers();
} }
function openCreate() { function openBatch() {
memberDialogMode.value = 'create'; batchDialogVisible.value = true;
editingKey.value = null; }
memberDialogVisible.value = true;
function handleBatchSubmit(payloads: BatchMemberPayload[]) {
for (const p of payloads) {
members.value.push({
key: generateKey(),
userId: p.userId,
roleId: p.roleId,
remark: p.remark,
locked: false
});
}
batchDialogVisible.value = false;
emitMembers();
} }
function openEdit(row: DraftMember) { function openEdit(row: DraftMember) {
memberDialogMode.value = 'edit';
editingKey.value = row.key; editingKey.value = row.key;
memberDialogVisible.value = true; memberDialogVisible.value = true;
} }
@@ -149,27 +120,16 @@ function removeMember(key: string) {
emitMembers(); emitMembers();
} }
function handleMemberSubmit(payload: { userId: string; roleId: string; remark: string }) { function handleMemberEditSubmit(payload: { userId: string; roleId: string; remark: string }) {
if (memberDialogMode.value === 'create') { if (!editingKey.value) return;
members.value.push({ const idx = members.value.findIndex(item => item.key === editingKey.value);
key: generateKey(), if (idx >= 0) {
userId: payload.userId, members.value[idx] = {
...members.value[idx],
roleId: payload.roleId, roleId: payload.roleId,
remark: payload.remark, remark: payload.remark
locked: false };
});
} else if (editingKey.value) {
const idx = members.value.findIndex(item => item.key === editingKey.value);
if (idx >= 0) {
members.value[idx] = {
...members.value[idx],
roleId: payload.roleId,
remark: payload.remark
};
}
} }
memberDialogVisible.value = false; memberDialogVisible.value = false;
emitMembers(); emitMembers();
} }
@@ -188,8 +148,8 @@ function emitMembers() {
} }
async function runValidate(): Promise<boolean> { async function runValidate(): Promise<boolean> {
if (managerRoleError.value) { if (props.managerRoleError) {
window.$message?.error(managerRoleError.value); window.$message?.error(props.managerRoleError);
return false; return false;
} }
@@ -214,43 +174,32 @@ async function runValidate(): Promise<boolean> {
return true; return true;
} }
function handleWatcherChange(ids: string[]) {
watcherUserIds.value = ids;
emit('update:watcherUserIds', ids);
}
// 团队成员变化时,剔除已被加入团队的关心人,避免重叠
watch(
() => members.value.map(item => item.userId).join(','),
() => {
const memberUserIds = new Set(members.value.map(item => item.userId).filter(Boolean));
const filtered = watcherUserIds.value.filter(id => !memberUserIds.has(id));
if (filtered.length !== watcherUserIds.value.length) {
handleWatcherChange(filtered);
}
}
);
onMounted(loadRoles);
watch( watch(
() => props.baseInfo.managerUserId, () => props.baseInfo.managerUserId,
() => { () => {
if (!managerRoleError.value && managerRole.value) { if (!props.managerRoleError && managerRole.value) {
refreshManagerRow(); refreshManagerRow();
} }
} }
); );
// roleOptions 异步加载到位后,补一次 locked 行刷新
watch(managerRole, () => {
if (!props.managerRoleError && managerRole.value && props.baseInfo.managerUserId) {
refreshManagerRow();
}
});
defineExpose({ validate: runValidate }); defineExpose({ validate: runValidate });
</script> </script>
<template> <template>
<div v-loading="roleLoading" class="team-step"> <div v-loading="roleLoading" class="team-step">
<div class="team-step__toolbar"> <button type="button" class="team-step__add" :disabled="Boolean(managerRoleError)" @click="openBatch">
<ElButton type="primary" plain :disabled="Boolean(managerRoleError)" @click="openCreate">新增成员</ElButton> <icon-ep:plus class="team-step__add-icon" />
</div> <span>新增成员</span>
<span class="team-step__add-hint">从部门 / 管理链路 / 全公司 批量选人</span>
</button>
<ElAlert <ElAlert
v-if="managerRoleError" v-if="managerRoleError"
@@ -261,62 +210,49 @@ defineExpose({ validate: runValidate });
class="team-step__alert" class="team-step__alert"
/> />
<ElTable :data="members" :height="teamTableHeight" border row-key="key" empty-text="点击右上角新增成员添加"> <div class="team-step__table-wrap">
<ElTableColumn type="index" label="序号" width="64" align="center" /> <ElTable :data="members" height="100%" border row-key="key" empty-text="点击右上角新增成员添加">
<ElTableColumn label="成员姓名" min-width="120"> <ElTableColumn type="index" label="序号" width="64" align="center" />
<template #default="{ row }"> <ElTableColumn label="成员姓名" min-width="120">
{{ getUserNickname(row.userId) }} <template #default="{ row }">
</template> {{ getUserNickname(row.userId) }}
</ElTableColumn> </template>
<ElTableColumn label="当前角色" min-width="140"> </ElTableColumn>
<template #default="{ row }"> <ElTableColumn label="当前角色" min-width="140">
{{ getRoleName(row.roleId) }} <template #default="{ row }">
</template> {{ getRoleName(row.roleId) }}
</ElTableColumn> </template>
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip> </ElTableColumn>
<template #default="{ row }"> <ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
{{ row.remark || '--' }} <template #default="{ row }">
</template> {{ row.remark || '--' }}
</ElTableColumn> </template>
<ElTableColumn label="操作" width="150" fixed="right" align="center"> </ElTableColumn>
<template #default="{ row }"> <ElTableColumn label="操作" width="150" fixed="right" align="center">
<div class="team-step__actions"> <template #default="{ row }">
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton> <div class="team-step__actions">
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton> <ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
</div> <ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
</template> </div>
</ElTableColumn> </template>
</ElTable> </ElTableColumn>
</ElTable>
<div class="watcher-row">
<span class="watcher-row__label">
关心人
<span class="watcher-row__optional">选填</span>
</span>
<ElSelect
:model-value="watcherUserIds"
multiple
filterable
clearable
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="3"
placeholder="可在列表 / 概览看到此项目的关注人"
class="watcher-row__select"
@update:model-value="handleWatcherChange"
>
<ElOption v-for="item in watcherUserOptions" :key="item.id" :value="item.id" :label="item.nickname" />
</ElSelect>
</div> </div>
<ProjectCreateTeamMemberDialog <ProjectCreateTeamMemberDialog
v-model:visible="memberDialogVisible" v-model:visible="memberDialogVisible"
:mode="memberDialogMode"
:initial="dialogInitial" :initial="dialogInitial"
:user-options="userOptions" :user-options="userOptions"
:role-options="roleOptions" :role-options="roleOptions"
:disabled-user-ids="dialogDisabledUserIds" @submit="handleMemberEditSubmit"
@submit="handleMemberSubmit" />
<ProjectTeamBatchDialog
v-model:visible="batchDialogVisible"
:user-options="userOptions"
:role-options="roleOptions"
:disabled-user-ids="batchDisabledUserIds"
@submit="handleBatchSubmit"
/> />
</div> </div>
</template> </template>
@@ -326,16 +262,65 @@ defineExpose({ validate: runValidate });
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
height: 100%;
min-height: 0; min-height: 0;
} }
.team-step__toolbar { .team-step__add {
display: flex; display: flex;
justify-content: flex-end; align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
height: 44px;
padding: 0 16px;
border: 1px dashed var(--el-border-color-darker);
border-radius: 6px;
background: transparent;
color: var(--el-text-color-regular);
font-size: 13px;
cursor: pointer;
transition:
border-color 0.2s ease,
color 0.2s ease,
background-color 0.2s ease;
flex-shrink: 0;
}
.team-step__add:hover:not(:disabled) {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.team-step__add:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.team-step__add-icon {
font-size: 16px;
}
.team-step__add-hint {
color: var(--el-text-color-placeholder);
font-size: 12px;
font-weight: 400;
}
.team-step__add:hover:not(:disabled) .team-step__add-hint {
color: var(--el-color-primary);
opacity: 0.7;
} }
.team-step__alert { .team-step__alert {
margin: 0; margin: 0;
flex-shrink: 0;
}
.team-step__table-wrap {
flex: 1 1 auto;
min-height: 0;
} }
.team-step__actions { .team-step__actions {
@@ -343,27 +328,4 @@ defineExpose({ validate: runValidate });
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.watcher-row {
display: flex;
align-items: center;
gap: 10px;
}
.watcher-row__label {
flex: 0 0 auto;
font-size: 13px;
font-weight: 500;
color: rgb(60 70 95 / 96%);
}
.watcher-row__optional {
color: rgb(140 150 170 / 96%);
font-weight: 400;
}
.watcher-row__select {
flex: 1 1 auto;
min-width: 0;
}
</style> </style>

View File

@@ -2,8 +2,14 @@
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElRow } from 'element-plus'; import { ElCol, ElDatePicker, ElFormItem, ElInput, ElRow } from 'element-plus';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict'; import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchCreateProjectWithTeam, fetchGetProductPage, fetchUpdateProject } from '@/service/api'; import {
fetchCreateProjectWithTeam,
fetchGetProductPage,
fetchGetRoleSimpleList,
fetchUpdateProject
} from '@/service/api';
import { useDict } from '@/hooks/business/dict'; import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form'; import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
@@ -265,9 +271,29 @@ const baseFormRef = ref<InstanceType<typeof ProjectCreateBaseForm> | null>(null)
const teamStepRef = ref<InstanceType<typeof ProjectCreateTeamStep> | null>(null); const teamStepRef = ref<InstanceType<typeof ProjectCreateTeamStep> | null>(null);
const currentStep = ref<1 | 2>(1); const currentStep = ref<1 | 2>(1);
// === 新增模式:角色列表(父级加载,下发给 team-step 与批量弹层) ===
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
const roleLoading = ref(false);
const managerRoleError = ref('');
const managerRole = computed(() => roleOptions.value.find(item => item.code === PROJECT_MANAGER_ROLE_CODE) ?? null);
async function loadRoles() {
roleLoading.value = true;
managerRoleError.value = '';
const { data } = await fetchGetRoleSimpleList({ scopeType: 'object', objectType: 'project' });
roleLoading.value = false;
roleOptions.value = data ?? [];
if (!managerRole.value) {
managerRoleError.value = '未找到项目经理角色,请联系管理员';
}
}
const createBaseModel = ref<ProjectCreateBaseFormModel>(createBaseInfo()); const createBaseModel = ref<ProjectCreateBaseFormModel>(createBaseInfo());
const draftMembers = ref<Api.Project.CreateProjectMemberParams[]>([]); const draftMembers = ref<Api.Project.CreateProjectMemberParams[]>([]);
const draftWatcherUserIds = ref<string[]>([]);
function createBaseInfo(): ProjectCreateBaseFormModel { function createBaseInfo(): ProjectCreateBaseFormModel {
return { return {
@@ -325,8 +351,7 @@ async function handleCreateSubmit() {
plannedEndDate: createBaseModel.value.plannedEndDate, plannedEndDate: createBaseModel.value.plannedEndDate,
projectDesc: getNullableText(createBaseModel.value.projectDesc) projectDesc: getNullableText(createBaseModel.value.projectDesc)
}, },
members: draftMembers.value, members: draftMembers.value
watcherUserIds: draftWatcherUserIds.value.length > 0 ? draftWatcherUserIds.value : undefined
}; };
const { error, data } = await fetchCreateProjectWithTeam(payload); const { error, data } = await fetchCreateProjectWithTeam(payload);
@@ -355,8 +380,8 @@ watch(visible, async value => {
editModel.value = createEditModel(); editModel.value = createEditModel();
createBaseModel.value = createBaseInfo(); createBaseModel.value = createBaseInfo();
draftMembers.value = []; draftMembers.value = [];
draftWatcherUserIds.value = [];
await nextTick(); await nextTick();
await loadRoles();
editFormRef.value?.clearValidate(); editFormRef.value?.clearValidate();
return; return;
} }
@@ -521,7 +546,7 @@ watch(visible, async value => {
</ElForm> </ElForm>
</BusinessFormDialog> </BusinessFormDialog>
<!-- 新增模式两步向导复合内容特例,自定义 ElDialog 880px --> <!-- 新增模式:两步向导(复合内容特例,1080px,左侧概念区 + 右侧步骤面板) -->
<ElDialog <ElDialog
v-else v-else
v-model="visible" v-model="visible"
@@ -530,37 +555,79 @@ watch(visible, async value => {
:close-on-click-modal="false" :close-on-click-modal="false"
destroy-on-close destroy-on-close
align-center align-center
width="760px" width="1080px"
> >
<div class="project-create-dialog__stepbar"> <div class="project-create-dialog__split">
<div class="project-create-dialog__step" :class="{ 'is-active': currentStep === 1, 'is-done': currentStep > 1 }"> <aside class="project-create-dialog__guide">
<span class="project-create-dialog__step-index">1</span> <div class="project-create-dialog__guide-hero">
<span class="project-create-dialog__step-text"> <div class="project-create-dialog__guide-hero-icon">
<strong>基础资料</strong> <icon-ep:files />
<small>定义项目身份和负责人</small> </div>
</span> <div class="project-create-dialog__guide-hero-text">
</div> <div class="project-create-dialog__guide-hero-title">项目</div>
<div class="project-create-dialog__step" :class="{ 'is-active': currentStep === 2 }"> <div class="project-create-dialog__guide-hero-sub">需求 · 任务 · 交付 的协同载体</div>
<span class="project-create-dialog__step-index">2</span> </div>
<span class="project-create-dialog__step-text"> </div>
<strong>初始化团队</strong>
<small>配置对象域成员角色</small>
</span>
</div>
</div>
<div class="project-create-dialog__body"> <p class="project-create-dialog__guide-lead">
<div v-show="currentStep === 1" class="project-create-dialog__panel"> 项目帮助组织持续规划和交付业务价值,围绕计划周期、负责人和团队推进研发工作,串起需求、任务、缺陷和成果。
<ProjectCreateBaseForm ref="baseFormRef" v-model="createBaseModel" :manager-user-options="managerUserOptions" /> </p>
</div>
<div v-show="currentStep === 2" class="project-create-dialog__panel"> <section class="project-create-dialog__guide-section">
<ProjectCreateTeamStep <h4>包含</h4>
ref="teamStepRef" <p>需求、执行、任务、工作日志、缺陷、文档、统计报表。</p>
:base-info="createBaseModel" </section>
:user-options="managerUserOptions"
@update:members="draftMembers = $event" <section class="project-create-dialog__guide-section">
@update:watcher-user-ids="draftWatcherUserIds = $event" <h4>参与人</h4>
/> <p>项目经理(必填,创建后锁定) · 团队角色(开发 / 测试 / 业务 / 关注人)。</p>
</section>
<section class="project-create-dialog__guide-section">
<h4>命名建议</h4>
<p>建议使用业务团队约定俗成的简短名称,关联所属产品后会继承产品方向,影响下游归类。</p>
</section>
</aside>
<div class="project-create-dialog__main">
<div class="project-create-dialog__stepbar">
<div
class="project-create-dialog__step"
:class="{ 'is-active': currentStep === 1, 'is-done': currentStep > 1 }"
>
<span class="project-create-dialog__step-index">1</span>
<span class="project-create-dialog__step-text">
<strong>基础资料</strong>
</span>
</div>
<div class="project-create-dialog__step" :class="{ 'is-active': currentStep === 2 }">
<span class="project-create-dialog__step-index">2</span>
<span class="project-create-dialog__step-text">
<strong>初始化团队</strong>
</span>
</div>
</div>
<div class="project-create-dialog__body">
<div v-show="currentStep === 1" class="project-create-dialog__panel">
<ProjectCreateBaseForm
ref="baseFormRef"
v-model="createBaseModel"
:manager-user-options="managerUserOptions"
/>
</div>
<div v-show="currentStep === 2" class="project-create-dialog__panel">
<ProjectCreateTeamStep
ref="teamStepRef"
:base-info="createBaseModel"
:user-options="managerUserOptions"
:role-options="roleOptions"
:role-loading="roleLoading"
:manager-role-error="managerRoleError"
@update:members="draftMembers = $event"
/>
</div>
</div>
</div> </div>
</div> </div>
@@ -608,6 +675,92 @@ watch(visible, async value => {
padding: 0; padding: 0;
} }
.project-create-dialog__split {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 0;
}
.project-create-dialog__guide {
padding: 28px 24px;
background: linear-gradient(180deg, #f7f9fc 0%, #fafbfc 100%);
border-right: 1px solid rgb(229 233 242 / 96%);
overflow-y: auto;
max-height: min(720px, calc(100vh - 160px));
}
.project-create-dialog__guide-hero {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid rgb(229 233 242 / 96%);
}
.project-create-dialog__guide-hero-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 10px;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-size: 22px;
flex-shrink: 0;
}
.project-create-dialog__guide-hero-text {
min-width: 0;
}
.project-create-dialog__guide-hero-title {
font-size: 15px;
font-weight: 650;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.project-create-dialog__guide-hero-sub {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
.project-create-dialog__guide-lead {
margin: 0 0 24px;
font-size: 13.5px;
line-height: 1.8;
color: var(--el-text-color-regular);
}
.project-create-dialog__guide-section + .project-create-dialog__guide-section {
margin-top: 20px;
}
.project-create-dialog__guide-section h4 {
margin: 0 0 6px;
font-size: 12.5px;
font-weight: 700;
color: var(--el-text-color-primary);
letter-spacing: 0.4px;
}
.project-create-dialog__guide-section p {
margin: 0;
font-size: 13px;
line-height: 1.75;
color: var(--el-text-color-regular);
}
.project-create-dialog__main {
display: flex;
flex-direction: column;
min-width: 0;
}
.project-create-dialog__stepbar { .project-create-dialog__stepbar {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -663,13 +816,15 @@ watch(visible, async value => {
} }
.project-create-dialog__body { .project-create-dialog__body {
min-height: 0; height: min(520px, calc(100vh - 240px));
max-height: min(560px, calc(100vh - 240px)); overflow: hidden;
overflow: auto;
} }
.project-create-dialog__panel { .project-create-dialog__panel {
height: 100%;
padding: 24px; padding: 24px;
overflow: auto;
box-sizing: border-box;
} }
.project-create-dialog__footer { .project-create-dialog__footer {

View File

@@ -4,6 +4,8 @@ import { useMediaQuery } from '@vueuse/core';
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials'; import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
import { objectContextDomainConfigs } from '@/constants/object-context'; import { objectContextDomainConfigs } from '@/constants/object-context';
import { import {
fetchBatchCreateProjectMembers,
fetchBatchInactiveProjectMembers,
fetchChangeProjectStatus, fetchChangeProjectStatus,
fetchCreateProjectMember, fetchCreateProjectMember,
fetchDeleteProject, fetchDeleteProject,
@@ -18,8 +20,12 @@ import {
import { useObjectContextStore } from '@/store/modules/object-context'; import { useObjectContextStore } from '@/store/modules/object-context';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import ProjectTeamBatchDialog, {
type BatchMemberPayload
} from '@/views/project/shared/components/project-team-batch-dialog.vue';
import { useCurrentProject } from '../../shared/use-current-project'; import { useCurrentProject } from '../../shared/use-current-project';
import BaseInfoDialog from './modules/base-info-dialog.vue'; import BaseInfoDialog from './modules/base-info-dialog.vue';
import MemberBatchRemoveDialog from './modules/member-batch-remove-dialog.vue';
import MemberOperateDialog from './modules/member-operate-dialog.vue'; import MemberOperateDialog from './modules/member-operate-dialog.vue';
import MemberRemoveDialog from './modules/member-remove-dialog.vue'; import MemberRemoveDialog from './modules/member-remove-dialog.vue';
import ProjectDeleteDialog from './modules/project-delete-dialog.vue'; import ProjectDeleteDialog from './modules/project-delete-dialog.vue';
@@ -68,7 +74,11 @@ const pageLoading = ref(false);
const memberLoading = ref(false); const memberLoading = ref(false);
const baseInfoVisible = ref(false); const baseInfoVisible = ref(false);
const memberOperateVisible = ref(false); const memberOperateVisible = ref(false);
const memberBatchVisible = ref(false);
const memberRemoveVisible = ref(false); const memberRemoveVisible = ref(false);
const memberBatchRemoveVisible = ref(false);
const teamPanelRef = ref<InstanceType<typeof SettingTeamPanel> | null>(null);
const selectedBatchRemoveMembers = ref<Api.Project.ProjectMember[]>([]);
const statusActionVisible = ref(false); const statusActionVisible = ref(false);
const deleteVisible = ref(false); const deleteVisible = ref(false);
const memberOperateMode = ref<'create' | 'edit'>('create'); const memberOperateMode = ref<'create' | 'edit'>('create');
@@ -211,9 +221,7 @@ function scrollToSection(key: string) {
} }
function openCreateMember() { function openCreateMember() {
memberOperateMode.value = 'create'; memberBatchVisible.value = true;
selectedMember.value = null;
memberOperateVisible.value = true;
} }
function openEditMember(member: Api.Project.ProjectMember) { function openEditMember(member: Api.Project.ProjectMember) {
@@ -227,6 +235,12 @@ function openRemoveMember(member: Api.Project.ProjectMember) {
memberRemoveVisible.value = true; memberRemoveVisible.value = true;
} }
function openBatchRemoveMember(targetMembers: Api.Project.ProjectMember[]) {
if (!targetMembers.length) return;
selectedBatchRemoveMembers.value = targetMembers;
memberBatchRemoveVisible.value = true;
}
function openLifecycleAction(action: Api.Project.ProjectLifecycleAction) { function openLifecycleAction(action: Api.Project.ProjectLifecycleAction) {
selectedAction.value = action; selectedAction.value = action;
statusActionVisible.value = true; statusActionVisible.value = true;
@@ -299,6 +313,53 @@ async function handleSubmitRemoveMember(payload: Api.Project.InactiveProjectMemb
await Promise.all([loadMembers(), loadSettings()]); await Promise.all([loadMembers(), loadSettings()]);
} }
async function handleSubmitMemberBatch(payloads: BatchMemberPayload[]) {
if (!currentObjectId.value || !payloads.length) {
return;
}
const { error } = await fetchBatchCreateProjectMembers(currentObjectId.value, {
members: payloads.map(item => ({
userId: item.userId,
roleId: item.roleId,
remark: item.remark.trim() || null
}))
});
if (error) {
return;
}
window.$message?.success(`已新增 ${payloads.length} 名成员`);
memberBatchVisible.value = false;
await Promise.all([loadMembers(), loadSettings()]);
}
async function handleSubmitBatchRemoveMember(payload: { reason: string | null }) {
if (!currentObjectId.value || !selectedBatchRemoveMembers.value.length) {
return;
}
const memberIds = selectedBatchRemoveMembers.value.map(item => item.id).filter((id): id is string => Boolean(id));
if (!memberIds.length) return;
const { error } = await fetchBatchInactiveProjectMembers(currentObjectId.value, {
memberIds,
reason: payload.reason
});
if (error) return;
window.$message?.success(`已移出 ${memberIds.length} 名成员`);
memberBatchRemoveVisible.value = false;
selectedBatchRemoveMembers.value = [];
teamPanelRef.value?.clearSelection();
await Promise.all([loadMembers(), loadSettings()]);
}
async function handleSubmitLifecycleAction(payload: Api.Project.ChangeProjectStatusParams) { async function handleSubmitLifecycleAction(payload: Api.Project.ChangeProjectStatusParams) {
if (!currentObjectId.value || !selectedAction.value) { if (!currentObjectId.value || !selectedAction.value) {
return; return;
@@ -387,6 +448,7 @@ watch(
<section :id="sectionIdMap.team" class="project-setting-page__section"> <section :id="sectionIdMap.team" class="project-setting-page__section">
<SettingTeamPanel <SettingTeamPanel
ref="teamPanelRef"
:members="members" :members="members"
:role-options="roleOptions" :role-options="roleOptions"
:loading="memberLoading" :loading="memberLoading"
@@ -394,6 +456,7 @@ watch(
@create="openCreateMember" @create="openCreateMember"
@edit="openEditMember" @edit="openEditMember"
@remove="openRemoveMember" @remove="openRemoveMember"
@batch-remove="openBatchRemoveMember"
/> />
</section> </section>
@@ -432,6 +495,18 @@ watch(
:member="selectedMember" :member="selectedMember"
@submit="handleSubmitRemoveMember" @submit="handleSubmitRemoveMember"
/> />
<ProjectTeamBatchDialog
v-model:visible="memberBatchVisible"
:user-options="userOptions"
:role-options="roleOptions"
:disabled-user-ids="members.filter(member => member.status === 0).map(member => member.userId)"
@submit="handleSubmitMemberBatch"
/>
<MemberBatchRemoveDialog
v-model:visible="memberBatchRemoveVisible"
:members="selectedBatchRemoveMembers"
@submit="handleSubmitBatchRemoveMember"
/>
<StatusActionDialog <StatusActionDialog
v-model:visible="statusActionVisible" v-model:visible="statusActionVisible"
:action="selectedAction" :action="selectedAction"

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'ProjectMemberBatchRemoveDialog' });
interface Props {
members: Api.Project.ProjectMember[];
}
interface Emits {
(e: 'submit', payload: { reason: string | null }): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const model = reactive({
reason: ''
});
const previewNames = computed(() => {
const names = props.members.map(item => item.userNickname || item.userId || '').filter(Boolean);
if (names.length <= 5) {
return names.join('、');
}
return `${names.slice(0, 5).join('、')}${names.length}`;
});
function handleConfirm() {
emit('submit', {
reason: model.reason.trim() || null
});
}
watch(
() => visible.value,
value => {
if (!value) {
return;
}
model.reason = '';
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" title="批量移出成员" preset="sm" @confirm="handleConfirm">
<ElAlert
:title="`确认将选中的 ${props.members.length} 名成员(${previewNames})从当前项目团队中移出吗?`"
type="warning"
:closable="false"
class="mb-12px"
/>
<ElAlert
title="若任一成员仍担任未关闭执行的负责人,后端会整体拒绝;请先完成执行负责人交接后再批量移出。"
type="info"
:closable="false"
class="mb-16px"
/>
<ElForm label-position="top">
<ElFormItem label="移出原因">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入移出原因(统一应用到所有选中成员)"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import type { TableInstance } from 'element-plus';
import { filterProjectMembers, formatProjectMemberDate, getProjectTeamTableHeight } from '../shared'; import { filterProjectMembers, formatProjectMemberDate, getProjectTeamTableHeight } from '../shared';
defineOptions({ name: 'ProjectSettingTeamPanel' }); defineOptions({ name: 'ProjectSettingTeamPanel' });
@@ -15,6 +16,7 @@ interface Emits {
(e: 'create'): void; (e: 'create'): void;
(e: 'edit', member: Api.Project.ProjectMember): void; (e: 'edit', member: Api.Project.ProjectMember): void;
(e: 'remove', member: Api.Project.ProjectMember): void; (e: 'remove', member: Api.Project.ProjectMember): void;
(e: 'batch-remove', members: Api.Project.ProjectMember[]): void;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -26,19 +28,41 @@ const emit = defineEmits<Emits>();
const searchKeyword = ref(''); const searchKeyword = ref('');
const selectedRoleId = ref(''); const selectedRoleId = ref('');
const teamTableHeight = getProjectTeamTableHeight(5); const teamTableHeight = getProjectTeamTableHeight(5);
const tableRef = ref<TableInstance | null>(null);
const selectedRows = ref<Api.Project.ProjectMember[]>([]);
const selectedCount = computed(() => selectedRows.value.length);
function isRowSelectable(row: Api.Project.ProjectMember) {
return row.status === 0 && !row.managerFlag;
}
function handleSelectionChange(rows: Api.Project.ProjectMember[]) {
selectedRows.value = rows;
}
function handleBatchRemove() {
if (!selectedRows.value.length) return;
emit('batch-remove', [...selectedRows.value]);
}
function clearSelection() {
tableRef.value?.clearSelection();
selectedRows.value = [];
}
defineExpose({ clearSelection });
const roleFilterOptions = computed(() => { const roleFilterOptions = computed(() => {
const roleMap = new Map<string, string>(); const seen = new Set<string>();
const result: Api.SystemManage.RoleSimple[] = [];
props.roleOptions.forEach(role => { props.roleOptions.forEach(role => {
if (!roleMap.has(role.id)) { if (role.visible === 0) return;
roleMap.set(role.id, role.name); if (seen.has(role.id)) return;
} seen.add(role.id);
result.push(role);
}); });
return [...roleMap.entries()].map(([value, label]) => ({ return result;
value,
label
}));
}); });
const filteredMembers = computed(() => const filteredMembers = computed(() =>
filterProjectMembers(props.members, { filterProjectMembers(props.members, {
@@ -49,7 +73,7 @@ const filteredMembers = computed(() =>
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value)); const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
watch(roleFilterOptions, options => { watch(roleFilterOptions, options => {
if (selectedRoleId.value && !options.some(item => item.value === selectedRoleId.value)) { if (selectedRoleId.value && !options.some(item => item.id === selectedRoleId.value)) {
selectedRoleId.value = ''; selectedRoleId.value = '';
} }
}); });
@@ -72,44 +96,46 @@ function getMemberStatusTagType(status: Api.Project.ProjectMemberStatus) {
</div> </div>
<div class="setting-team-panel__toolbar"> <div class="setting-team-panel__toolbar">
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter"> <ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
<ElOption <ElOption v-for="role in roleFilterOptions" :key="role.id" :label="role.name" :value="role.id">
v-for="option in roleFilterOptions" <div class="setting-team-panel__role-option">
:key="option.value" <span class="setting-team-panel__role-option-name">{{ role.name }}</span>
:label="option.label" <ElTooltip v-if="role.remark" :content="role.remark" placement="right" :show-after="120">
:value="option.value" <icon-ep:info-filled class="setting-team-panel__role-option-info" @click.stop />
/> </ElTooltip>
</div>
</ElOption>
</ElSelect> </ElSelect>
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" /> <ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
<ElButton type="primary" plain :disabled="props.readonly" @click="emit('create')">新增成员</ElButton> <ElButton type="primary" plain :disabled="props.readonly" @click="emit('create')">新增成员</ElButton>
<ElButton type="danger" plain :disabled="props.readonly || selectedCount === 0" @click="handleBatchRemove">
批量移出{{ selectedCount > 0 ? `${selectedCount}` : '' }}
</ElButton>
</div> </div>
</div> </div>
</template> </template>
<ElTable <ElTable
ref="tableRef"
v-loading="props.loading" v-loading="props.loading"
:data="filteredMembers" :data="filteredMembers"
:height="teamTableHeight" :height="teamTableHeight"
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'" :empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
border border
row-key="id" row-key="id"
@selection-change="handleSelectionChange"
> >
<ElTableColumn
v-if="!props.readonly"
type="selection"
width="48"
align="center"
:selectable="(row: Api.Project.ProjectMember) => isRowSelectable(row)"
/>
<ElTableColumn type="index" label="序号" width="64" align="center" /> <ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" /> <ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
<ElTableColumn label="当前角色" min-width="180"> <ElTableColumn label="当前角色" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<div class="setting-team-panel__role-cell"> {{ row.roleName || '--' }}
<span class="setting-team-panel__role-main">{{ row.roleName || '--' }}</span>
<ElTag
v-for="extra in row.additionalRoleNames"
:key="extra"
size="small"
type="info"
effect="plain"
class="setting-team-panel__role-extra"
>
{{ extra }}
</ElTag>
</div>
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn label="成员状态" width="110" align="center"> <ElTableColumn label="成员状态" width="110" align="center">
@@ -186,15 +212,31 @@ function getMemberStatusTagType(status: Api.Project.ProjectMemberStatus) {
gap: 12px; gap: 12px;
} }
.setting-team-panel__role-cell { .setting-team-panel__role-option {
display: inline-flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; justify-content: space-between;
gap: 6px; gap: 8px;
width: 100%;
} }
.setting-team-panel__role-extra { .setting-team-panel__role-option-name {
font-weight: 400; flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.setting-team-panel__role-option-info {
flex-shrink: 0;
font-size: 14px;
color: var(--el-text-color-placeholder);
cursor: help;
}
.setting-team-panel__role-option-info:hover {
color: var(--el-color-primary);
} }
@media (width <= 768px) { @media (width <= 768px) {

File diff suppressed because it is too large Load Diff