refactor(projects): 优化产品项目新增逻辑

This commit is contained in:
2026-05-14 14:11:16 +08:00
parent ddd05f8c02
commit 59b73f3dae
13 changed files with 2133 additions and 10 deletions

View File

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

View File

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

View File

@@ -99,10 +99,15 @@ declare namespace Api {
userNickname: string;
/** 角色 ID */
roleId: string;
/** 角色名称 */
/** 角色名称(主角色) */
roleName: string;
/** 角色编码 */
/** 角色编码(主角色) */
roleCode: string;
/**
* 非主角色的中文名列表(多角色合并展示用,按字典序升序)
* 单角色时为空数组 [];典型场景:创建者 + 经理重合时,主行 managercreator 名进此列表
*/
additionalRoleNames: string[];
/** 是否当前产品经理 */
managerFlag: boolean;
/** 成员状态 */
@@ -218,6 +223,8 @@ declare namespace Api {
interface CreateProductWithTeamParams {
product: SaveProductParams;
members: CreateProductMemberParams[];
/** 关心人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 product_watcher 角色 */
watcherUserIds?: string[];
}
interface UpdateProductMemberParams {

View File

@@ -519,10 +519,15 @@ declare namespace Api {
userNickname: string;
/** 角色 ID */
roleId: string;
/** 角色名称 */
/** 角色名称(主角色) */
roleName: string;
/** 角色编码 */
/** 角色编码(主角色) */
roleCode: string;
/**
* 非主角色的中文名列表(多角色合并展示用,按字典序升序)
* 单角色时为空数组 [];典型场景:创建者 + 负责人重合时,主行 managercreator 名进此列表
*/
additionalRoleNames: string[];
/** 是否项目负责人 */
managerFlag: boolean;
/** 成员状态 */
@@ -628,6 +633,8 @@ declare namespace Api {
interface CreateProjectWithTeamParams {
project: SaveProjectParams;
members: CreateProjectMemberParams[];
/** 关心人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 project_watcher 角色 */
watcherUserIds?: string[];
}
// ========== 项目需求相关类型定义 ==========

View File

@@ -104,6 +104,7 @@ declare module 'vue' {
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
'IconF7:flagCircleFill': typeof import('~icons/f7/flag-circle-fill')['default']
'IconFe:eye': typeof import('~icons/fe/eye')['default']
'IconFe:question': typeof import('~icons/fe/question')['default']
'IconFileIcons:microsoftExcel': typeof import('~icons/file-icons/microsoft-excel')['default']
'IconGg:ratio': typeof import('~icons/gg/ratio')['default']

View File

@@ -27,6 +27,7 @@ const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:members', members: Api.Product.CreateProductMemberParams[]): void;
(e: 'update:watcherUserIds', watcherUserIds: string[]): void;
}>();
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
@@ -38,7 +39,15 @@ const memberDialogVisible = ref(false);
const memberDialogMode = ref<'create' | 'edit'>('create');
const editingKey = ref<string | null>(null);
const teamTableHeight = getProductTeamTableHeight(5);
const watcherUserIds = ref<string[]>([]);
// 关心人候选用户:排除已在团队成员列表中的用户(包含产品经理本人)
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])));
@@ -205,6 +214,24 @@ async function runValidate(): Promise<boolean> {
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(
@@ -261,6 +288,27 @@ defineExpose({ validate: runValidate });
</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>
<ProductCreateTeamMemberDialog
v-model:visible="memberDialogVisible"
:mode="memberDialogMode"
@@ -295,4 +343,27 @@ defineExpose({ validate: runValidate });
align-items: center;
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>

View File

@@ -114,6 +114,7 @@ const currentStep = ref<1 | 2>(1);
const createBaseModel = ref<ProductCreateBaseFormModel>(createBaseInfo());
const draftMembers = ref<Api.Product.CreateProductMemberParams[]>([]);
const draftWatcherUserIds = ref<string[]>([]);
function createBaseInfo(): ProductCreateBaseFormModel {
return { code: '', name: '', directionCode: '', managerUserId: null, description: '' };
@@ -157,7 +158,8 @@ async function handleCreateSubmit() {
managerUserId: createBaseModel.value.managerUserId as string,
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);
@@ -186,6 +188,7 @@ watch(visible, async value => {
editModel.value = createEditModel();
createBaseModel.value = createBaseInfo();
draftMembers.value = [];
draftWatcherUserIds.value = [];
await nextTick();
editFormRef.value?.clearValidate();
return;
@@ -330,6 +333,7 @@ watch(visible, async value => {
:base-info="createBaseModel"
:user-options="managerUserOptions"
@update:members="draftMembers = $event"
@update:watcher-user-ids="draftWatcherUserIds = $event"
/>
</div>
</div>

View File

@@ -103,7 +103,23 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
>
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
<ElTableColumn prop="roleName" label="当前角色" min-width="140" />
<ElTableColumn label="当前角色" min-width="180">
<template #default="{ row }">
<div class="setting-team-panel__role-cell">
<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>
</ElTableColumn>
<ElTableColumn label="成员状态" width="110" align="center">
<template #default="{ row }">
<ElTag :type="getMemberStatusTagType(row.status)">{{ getMemberStatusLabel(row.status) }}</ElTag>
@@ -180,6 +196,17 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
gap: 12px;
}
.setting-team-panel__role-cell {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.setting-team-panel__role-extra {
font-weight: 400;
}
@media (width <= 768px) {
.setting-team-panel__header {
align-items: flex-start;

View File

@@ -27,6 +27,7 @@ const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:members', members: Api.Project.CreateProjectMemberParams[]): void;
(e: 'update:watcherUserIds', watcherUserIds: string[]): void;
}>();
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
@@ -38,7 +39,15 @@ const memberDialogVisible = ref(false);
const memberDialogMode = ref<'create' | 'edit'>('create');
const editingKey = ref<string | null>(null);
const teamTableHeight = getProjectTeamTableHeight(5);
const watcherUserIds = ref<string[]>([]);
// 关心人候选用户:排除已在团队成员列表中的用户(包含项目负责人本人)
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])));
@@ -205,6 +214,24 @@ async function runValidate(): Promise<boolean> {
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(
@@ -261,6 +288,27 @@ defineExpose({ validate: runValidate });
</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>
<ProjectCreateTeamMemberDialog
v-model:visible="memberDialogVisible"
:mode="memberDialogMode"
@@ -295,4 +343,27 @@ defineExpose({ validate: runValidate });
align-items: center;
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>

View File

@@ -267,6 +267,7 @@ const currentStep = ref<1 | 2>(1);
const createBaseModel = ref<ProjectCreateBaseFormModel>(createBaseInfo());
const draftMembers = ref<Api.Project.CreateProjectMemberParams[]>([]);
const draftWatcherUserIds = ref<string[]>([]);
function createBaseInfo(): ProjectCreateBaseFormModel {
return {
@@ -324,7 +325,8 @@ async function handleCreateSubmit() {
plannedEndDate: createBaseModel.value.plannedEndDate,
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);
@@ -353,6 +355,7 @@ watch(visible, async value => {
editModel.value = createEditModel();
createBaseModel.value = createBaseInfo();
draftMembers.value = [];
draftWatcherUserIds.value = [];
await nextTick();
editFormRef.value?.clearValidate();
return;
@@ -556,6 +559,7 @@ watch(visible, async value => {
:base-info="createBaseModel"
:user-options="managerUserOptions"
@update:members="draftMembers = $event"
@update:watcher-user-ids="draftWatcherUserIds = $event"
/>
</div>
</div>

View File

@@ -95,7 +95,23 @@ function getMemberStatusTagType(status: Api.Project.ProjectMemberStatus) {
>
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
<ElTableColumn prop="roleName" label="当前角色" min-width="140" />
<ElTableColumn label="当前角色" min-width="180">
<template #default="{ row }">
<div class="setting-team-panel__role-cell">
<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>
</ElTableColumn>
<ElTableColumn label="成员状态" width="110" align="center">
<template #default="{ row }">
<ElTag :type="getMemberStatusTagType(row.status)">{{ getMemberStatusLabel(row.status) }}</ElTag>
@@ -170,6 +186,17 @@ function getMemberStatusTagType(status: Api.Project.ProjectMemberStatus) {
gap: 12px;
}
.setting-team-panel__role-cell {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.setting-team-panel__role-extra {
font-weight: 400;
}
@media (width <= 768px) {
.setting-team-panel__header {
align-items: flex-start;