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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,754 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>成员列表接口变更 — 前端对接说明</title>
<style>
:root {
--fg: #1f2328;
--fg-muted: #57606a;
--bg: #ffffff;
--bg-soft: #f6f8fa;
--border: #d0d7de;
--border-soft: #e7ecf2;
--accent: #0969da;
--accent-soft: #ddf4ff;
--warn: #9a6700;
--warn-soft: #fff8c5;
--danger: #cf222e;
--danger-soft: #ffebe9;
--ok: #1a7f37;
--ok-soft: #dafbe1;
--code-bg: #f6f8fa;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Hiragino Sans GB',
'Source Han Sans CN', 'Noto Sans CJK SC', Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.75;
}
.wrap {
max-width: 1000px;
margin: 0 auto;
padding: 48px 32px 96px;
}
header.doc-header {
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
margin-bottom: 24px;
}
header.doc-header h1 {
margin: 0 0 8px;
font-size: 28px;
}
header.doc-header .meta {
color: var(--fg-muted);
font-size: 13px;
margin: 0;
}
h2 {
margin: 40px 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-soft);
font-size: 22px;
}
h3 {
margin: 28px 0 8px;
font-size: 17px;
}
h4 {
margin: 20px 0 6px;
font-size: 15px;
}
p {
margin: 8px 0;
}
ul,
ol {
margin: 8px 0;
padding-left: 24px;
}
ul li,
ol li {
margin: 3px 0;
}
code,
pre {
font-family: 'JetBrains Mono', Menlo, Consolas, 'Courier New', monospace;
font-size: 13px;
}
code {
background: var(--code-bg);
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--border-soft);
}
pre {
background: var(--code-bg);
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 14px 16px;
overflow-x: auto;
line-height: 1.55;
}
pre code {
background: transparent;
border: 0;
padding: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
font-size: 13px;
}
th,
td {
border: 1px solid var(--border);
padding: 7px 10px;
text-align: left;
vertical-align: top;
}
th {
background: var(--bg-soft);
font-weight: 600;
}
tr:nth-child(2n) td {
background: #fafbfc;
}
.callout {
border-left: 4px solid var(--accent);
background: var(--accent-soft);
padding: 10px 14px;
border-radius: 4px;
margin: 12px 0;
}
.callout.warn {
border-left-color: var(--warn);
background: var(--warn-soft);
}
.callout.danger {
border-left-color: var(--danger);
background: var(--danger-soft);
}
.callout.ok {
border-left-color: var(--ok);
background: var(--ok-soft);
}
.callout .title {
font-weight: 600;
margin-bottom: 4px;
}
.endpoint {
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
margin: 14px 0;
background: var(--bg);
}
.method {
display: inline-block;
padding: 3px 12px;
border-radius: 4px;
font-family: 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 12px;
font-weight: 700;
margin-right: 10px;
}
.method.get {
background: #0969da;
color: white;
}
.badge {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
}
.badge.new {
background: var(--ok-soft);
color: var(--ok);
border-color: #aceeb6;
}
.badge.keep {
background: var(--bg-soft);
color: var(--fg-muted);
}
.compare {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin: 12px 0;
}
.compare > div {
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 14px;
}
.compare .before {
border-top: 3px solid var(--warn);
}
.compare .after {
border-top: 3px solid var(--ok);
}
.compare h4 {
margin-top: 0;
}
hr {
border: 0;
border-top: 1px solid var(--border-soft);
margin: 32px 0;
}
.toc {
background: var(--bg-soft);
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 14px 20px;
margin: 16px 0 24px;
}
.toc ol {
margin: 4px 0;
}
.toc a {
color: var(--accent);
text-decoration: none;
}
.toc a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="wrap">
<header class="doc-header">
<h1>成员列表接口变更 — 前端对接说明</h1>
<p class="meta">变更日期 2026-05-14 · 后端已就绪 · 前端需要配合调整渲染逻辑</p>
</header>
<div class="callout">
<div class="title">一句话总结</div>
<strong>产品 / 项目成员列表接口</strong>
现在按"
<strong>一人一行</strong>
"返回,同一用户的多个角色合并到主角色行,其他角色名通过新增的
<code>additionalRoleNames: string[]</code>
字段返回。前端只需要把这个数组拼到角色名旁边显示即可。
</div>
<div class="toc">
<strong>目录</strong>
<ol>
<li><a href="#sec-1">背景:为什么改</a></li>
<li><a href="#sec-2">受影响的接口(仅 2 个)</a></li>
<li><a href="#sec-3">改动前后对比</a></li>
<li><a href="#sec-4">新增字段 additionalRoleNames</a></li>
<li><a href="#sec-5">前端渲染建议</a></li>
<li><a href="#sec-6">边界场景</a></li>
<li><a href="#sec-7">不受影响的接口</a></li>
<li><a href="#sec-8">前端落地 checklist</a></li>
</ol>
</div>
<!-- ====== 1 ====== -->
<h2 id="sec-1">1. 背景:为什么改</h2>
<p>
多角色改造后,
<strong>
产品/项目的"创建者"角色会自动落一条
<code>rdms_user_object_role</code>
</strong>
。当
<strong>创建者本人就是负责人</strong>
时,同一用户在同一对象内会出现两条 ACTIVE 记录:
</p>
<ul>
<li>
一条
<code>product_manager</code>
/
<code>project_manager</code>
</li>
<li>
一条
<code>product_creator</code>
/
<code>project_creator</code>
</li>
</ul>
<p>
如果后端原样返回,前端列表会出现"同一个人重复两行"。讨论后约定:后端在
<strong>列表接口</strong>
层做合并展示,
<strong>不影响</strong>
底层数据(每个角色行仍独立存在,便于后续可能的"按角色单独操作")。
</p>
<div class="callout warn">
<div class="title">业务边界(重要)</div>
当前业务上
<strong>
只有
<code>creator + manager</code>
这一种组合
</strong>
会让同人多角色出现。其他角色(产品专员、开发等)仍是一人一角色。所以前端可以放心按"主角色 + 附加角色名"
的方式渲染,附加列表通常是 0 或 1 个元素。
</div>
<!-- ====== 2 ====== -->
<h2 id="sec-2">2. 受影响的接口(仅 2 个)</h2>
<div class="endpoint">
<span class="method get">GET</span>
<code>/admin-api/project/product/{productId}/members</code>
<p style="color: var(--fg-muted); font-size: 13px; margin: 6px 0 0">产品团队成员列表</p>
</div>
<div class="endpoint">
<span class="method get">GET</span>
<code>/admin-api/project/project/{projectId}/members</code>
<p style="color: var(--fg-muted); font-size: 13px; margin: 6px 0 0">项目团队成员列表</p>
</div>
<p>
响应结构两个接口对称,本文档下文统一用
<code>RespVO</code>
表示,字段路径一致。
</p>
<!-- ====== 3 ====== -->
<h2 id="sec-3">3. 改动前后对比</h2>
<p>
假设:产品 P 里
<code>用户A</code>
是产品经理(同时创建了该产品,所以也有
<code>product_creator</code>
角色),
<code>用户B</code>
是产品专员,
<code>用户C</code>
是已退场的历史成员。
</p>
<div class="compare">
<div class="before">
<h4>改之前数据原样返回4 行)</h4>
<pre><code>[
{ id: "100", userId: "A", roleCode: "product_manager",
roleName: "产品经理", status: 0, ... },
{ id: "101", userId: "A", roleCode: "product_creator",
roleName: "产品创建者", status: 0, ... },
{ id: "102", userId: "B", roleCode: "product_specialist",
roleName: "产品专员", status: 0, ... },
{ id: "103", userId: "C", roleCode: "product_specialist",
roleName: "产品专员", status: 1, leftTime: "..." }
]</code></pre>
<p style="color: var(--fg-muted); font-size: 12px">用户 A 出现 2 次 — 前端要么重复显示,要么自己去重。</p>
</div>
<div class="after">
<h4>改之后合并后3 行)</h4>
<pre><code>[
{ id: "100", userId: "A", roleCode: "product_manager",
roleName: "产品经理",
additionalRoleNames: ["产品创建者"], // ← 新增字段
status: 0, ... },
{ id: "102", userId: "B", roleCode: "product_specialist",
roleName: "产品专员",
additionalRoleNames: [],
status: 0, ... },
{ id: "103", userId: "C", roleCode: "product_specialist",
roleName: "产品专员",
additionalRoleNames: [],
status: 1, leftTime: "..." }
]</code></pre>
<p style="color: var(--fg-muted); font-size: 12px">
用户 A 1 行 — 主行 managercreator 名字进
<code>additionalRoleNames</code>
</p>
</div>
</div>
<h3>合并规则</h3>
<table>
<tr>
<th>规则</th>
<th>说明</th>
</tr>
<tr>
<td>仅合并 ACTIVE 行</td>
<td>
<code>status = 0</code>
的多角色行才聚合;
<code>status = 1</code>
的历史 INACTIVE 行
<strong>保持每条独立成行</strong>
(历史角色留痕,便于审计)
</td>
</tr>
<tr>
<td>主角色选择</td>
<td>
同人多角色时,
<code>product_manager</code>
/
<code>project_manager</code>
角色行优先做主;不在则按
<code>roleId</code>
升序选第一条(理论不该走到这条兜底)
</td>
</tr>
<tr>
<td>主行字段</td>
<td>
<code>id</code>
/
<code>roleId</code>
/
<code>roleCode</code>
/
<code>roleName</code>
/
<code>joinedTime</code>
/
<code>leftTime</code>
/
<code>remark</code>
等都按主角色行的值返回
</td>
</tr>
<tr>
<td>非主角色名顺序</td>
<td>
<code>additionalRoleNames</code>
按角色中文名字典序升序,前端可以直接顺序渲染
</td>
</tr>
</table>
<!-- ====== 4 ====== -->
<h2 id="sec-4">
4. 新增字段
<code>additionalRoleNames</code>
</h2>
<table>
<tr>
<th>字段名</th>
<th>类型</th>
<th>状态</th>
<th>说明</th>
</tr>
<tr>
<td><code>additionalRoleNames</code></td>
<td><code>string[]</code></td>
<td><span class="badge new">本次新增</span></td>
<td>
非主角色的中文名列表,多角色场景使用;
<strong>
单角色时为空数组
<code>[]</code>
</strong>
,前端可以放心
<code>length</code>
判空
</td>
</tr>
</table>
<p>
所有
<strong>原有字段保持不变</strong>
<code>id</code>
<code>userId</code>
<code>userNickname</code>
<code>roleId</code>
<code>roleName</code>
<code>roleCode</code>
<code>managerFlag</code>
<code>status</code>
<code>joinedTime</code>
<code>leftTime</code>
<code>remark</code>
),前端原有代码不会因为字段消失而报错。
</p>
<!-- ====== 5 ====== -->
<h2 id="sec-5">5. 前端渲染建议</h2>
<h3>5.1 最简单的展示方式 — 拼成一个字符串</h3>
<pre><code>const displayRoleName = additionalRoleNames?.length
? `${roleName} + ${additionalRoleNames.join(', ')}`
: roleName;
//
// 例roleName="产品经理", additionalRoleNames=["产品创建者"]
// → "产品经理 + 产品创建者"
//
// 例roleName="产品专员", additionalRoleNames=[]
// → "产品专员"</code></pre>
<h3>5.2 更友好的展示 — 主角色 + 浅色 chip 标签</h3>
<pre><code>&lt;span class="role-main"&gt;{{ roleName }}&lt;/span&gt;
&lt;span v-for="extra in additionalRoleNames" :key="extra" class="role-tag"&gt;
{{ extra }}
&lt;/span&gt;
//
// CSSrole-tag 设计成浅色 background + 小圆角,跟主角色拉开视觉层级</code></pre>
<h3>5.3 表格单元格示意</h3>
<pre><code>| 用户 | 角色 | 状态 |
|-----------|---------------------------|------|
| 灿能管理 | 产品经理 [产品创建者] | 有效 |
| 洪圣文 | 产品专员 | 有效 |
| 李凡 | 游客 | 历史 |</code></pre>
<!-- ====== 6 ====== -->
<h2 id="sec-6">6. 边界场景</h2>
<table>
<tr>
<th>场景</th>
<th>预期返回</th>
<th>前端展示</th>
</tr>
<tr>
<td>用户 A 仅是产品经理(不是创建者)</td>
<td>
1 行,
<code>additionalRoleNames=[]</code>
</td>
<td>"产品经理"</td>
</tr>
<tr>
<td>用户 A 同时是产品经理 + 创建者</td>
<td>
1 行(主行 manager
<code>additionalRoleNames=["产品创建者"]</code>
</td>
<td>"产品经理 + 产品创建者"</td>
</tr>
<tr>
<td>用户 A 仅是创建者(罕见,理论上创建后立即把 manager 转给别人才会出现)</td>
<td>
1 行,主行就是 creator
<code>additionalRoleNames=[]</code>
</td>
<td>"产品创建者"</td>
</tr>
<tr>
<td>用户 A 是产品专员(单角色非 manager</td>
<td>
1 行,
<code>additionalRoleNames=[]</code>
</td>
<td>"产品专员"</td>
</tr>
<tr>
<td>用户 A 退场status=1 INACTIVE 历史行)</td>
<td>
每条 INACTIVE 行独立 1 行,
<code>additionalRoleNames=[]</code>
</td>
<td>"产品专员(已退场)"等灰显</td>
</tr>
<tr>
<td>
用户 A 同时有
<strong>历史失效</strong>
的角色行 +
<strong>当前生效</strong>
的角色行
</td>
<td>ACTIVE 行合并 1 行 + 每条 INACTIVE 行各占 1 行;同用户在列表里会出现多次(不同 status</td>
<td>分别用"有效 / 历史"区分;不要再二次合并</td>
</tr>
</table>
<!-- ====== 7 ====== -->
<h2 id="sec-7">7. 不受影响的接口</h2>
<p>
以下接口
<strong>不变</strong>
,前端原有调用方式保持:
</p>
<table>
<tr>
<th>接口</th>
<th>说明</th>
</tr>
<tr>
<td>
<code>POST /admin-api/project/product/{productId}/members</code>
<br />
<code>POST /admin-api/project/project/{projectId}/members</code>
</td>
<td>
新增成员 — 仍按"一个角色一条记录"操作,传
<code>userId + roleId</code>
</td>
</tr>
<tr>
<td><code>PUT /admin-api/project/.../members/{memberId}</code></td>
<td>
更新成员 — 按
<code>memberId</code>
(即
<code>rdms_user_object_role.id</code>
)定位具体角色行操作
</td>
</tr>
<tr>
<td><code>PUT /admin-api/project/.../members/{memberId}/inactive</code></td>
<td>
失效成员 — 同上,按
<code>memberId</code>
操作
</td>
</tr>
<tr>
<td>
<code>GET /admin-api/project/product/{productId}/context</code>
<br />
<code>GET /admin-api/project/project/{projectId}/context</code>
</td>
<td>
对象上下文 —
<code>currentRole.additionalRoleNames</code>
字段早已存在,
<strong>不在本次变更范围</strong>
</td>
</tr>
</table>
<div class="callout warn">
<div class="title">特别提醒 — 编辑 / 失效操作</div>
当用户 A 同时是 manager + creator合并展示成 1 行)时,前端如果允许"编辑这一行的角色"或"踢出",传的
<code>memberId</code>
<strong>
主角色行的
<code>id</code>
</strong>
(也就是 manager 那条)。
<strong>不会影响</strong>
同人的 creator 角色行 — creator 角色仍保留。
<br />
<br />
这是符合设计的行为creator 是"留痕"角色,原则上不应该被踢出。如果业务上要踢出整个人,需要前端先调一次 list
接口拿到该用户的所有 active 角色行(注意 — 合并后的 list 里看不到 creator 那条的
<code>id</code>
),后续
<strong>是否要单独提供"列出某 user 所有角色行"接口</strong>
取决于业务实际诉求,目前没有这个接口。
</div>
<!-- ====== 8 ====== -->
<h2 id="sec-8">8. 前端落地 checklist</h2>
<div class="callout ok">
<ul style="margin: 6px 0">
<li>
✅ 把
<code>additionalRoleNames</code>
数组加到 TypeScript 类型定义ProductMemberRespVO / ProjectMemberRespVO 两处)
</li>
<li>
✅ 列表渲染逻辑:把
<code>additionalRoleNames</code>
拼到
<code>roleName</code>
旁边显示(字符串或 chip 标签均可)
</li>
<li>
✅ 验证:找一个"创建者 = 经理"的产品/项目,确认列表里这个人只出现
<strong>1 行</strong>
,且能看到"产品经理 + 产品创建者"字样
</li>
<li>
✅ 验证:找一个普通成员(产品专员等),
<code>additionalRoleNames</code>
应该是
<code>[]</code>
,不影响展示
</li>
<li>
✅ 验证:历史退场成员(
<code>status=1</code>
),仍按原方式各占一行
</li>
<li>
⏸ 编辑/失效操作 — 当前不动,仍按
<code>memberId</code>
操作主角色行;若业务需要"踢人整体",后端再补接口
</li>
</ul>
</div>
<hr />
<h3>变更影响最小范围说明</h3>
<p style="color: var(--fg-muted); font-size: 13px">
本次后端改动仅在
<code>ProductMemberServiceImpl.getProductMemberList</code>
<code>ProjectMemberServiceImpl.getProjectMemberList</code>
两个方法中实现,
<strong>不修改底层数据</strong>
<code>rdms_user_object_role</code>
仍按一行一角色存储)。即使前端暂时不读
<code>additionalRoleNames</code>
字段,也只是看不到"+ 产品创建者"字样,不会出现数据错误或重复行问题。
</p>
</div>
</body>
</html>