From 880eff74f8d78b9c698e442f68b5ce665193788c Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 09:56:32 +0000 Subject: [PATCH] Rebuild duties and permission pages on catalog, matrix and tri-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: P7-B/C——文档职责页升级为「文档清单+选中职责详情」(duty.catalog/get:名称/类型/阶段/在途轮次,当前职责经三个项目成员 Picker 回填并与冻结名单分列;在途轮次失效确认;批量勾选+统一三权走 assign.doc.batch,整批拒绝列原因);权限页升级为「单成员详情+多人矩阵」双视图(中文标签、系统范围动作折叠为技术详情、矩阵单元格悬停解释、行展开三态覆盖——沿用默认/项目内允许/项目内禁止,恢复默认走 perm.unset 实际撤销)。 Evidence: tsc、design-guard 6/6、npm run build 通过;服务端语义由 RDM #346(标签/矩阵)与 #349(unset)测试覆盖;折叠使用 @ui Collapsible 原语。 Impact: RdmDutiesSection/RdmPermMatrixSection 重构;无跨页变更。 Refs: 提案 r1 §4/§5/P7;RDM #346/#349 --- src/components/adminRdmSections.tsx | 396 ++++++++++++++++++++++------ 1 file changed, 318 insertions(+), 78 deletions(-) diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index 16d0fa2..c72852d 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -6,9 +6,9 @@ import { useEffect, useState, useRef, useCallback } from 'react' import { api } from '../api' import { useStore } from '../store' -import { Btn, EmptyState, Icon, Picker} from '@ui' +import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui' import { - ROLE_ACTIONS, parseReviewers, + ROLE_ACTIONS, roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload, memberAddPayload, memberRemovePayload, memberSetRolePayload, assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload, @@ -254,7 +254,7 @@ export function RdmMembersSection({ projectId }: { projectId: string }) { ) } -// ---- 项目设置:文档职责 · 整文审签 ---- +// ---- 项目设置:文档职责(P7-B:文档清单+选中职责详情;方案 §4)---- interface DocSubmissionView { round: number @@ -267,87 +267,194 @@ interface DocSubmissionView { sections: Record } +interface DutyCatalogDoc { + docId: string; typeKey: string; title: string; stage?: string; sections: number + assigned: boolean; author?: string; reviewers?: string[]; approver?: string; activeRound?: number +} + +interface DutyGetOut { + docId: string; title: string; assigned: boolean + roles: { author: string; reviewers: string[]; approver: string } + active?: { round: number; state: string; createdBy: string } | null + sections: { sectionId: string; title: string; overridden: boolean }[] +} + +/** 职责候选 = 当前项目成员(方案 §4:候选只能来自项目成员)。 + * member.list 为小全量列表:本地 query 过滤 + 游标分页。 */ +function useMemberPickerPage(members: { owner: string; role: string }[] | null) { + return useCallback(async (query: string, cursor: string, limit: number) => { + const q = query.trim().toLowerCase() + const all = (members ?? []) + .filter(m => q === '' || m.owner.toLowerCase().includes(q)) + .map(m => ({ id: m.owner, title: m.owner, subtitle: m.role === 'owner' ? '项目负责人' : '项目成员' })) + const start = cursor === '' ? 0 : all.findIndex(x => x.id === cursor) + 1 + const page = all.slice(start, start + limit) + return { + items: page, + nextCursor: start + limit < all.length ? page[page.length - 1]?.id : undefined, + } + }, [members]) +} + export function RdmDutiesSection({ projectId }: { projectId: string }) { + const [docs, setDocs] = useState(null) + const [members, setMembers] = useState<{ owner: string; role: string }[] | null>(null) const [docId, setDocId] = useState('') + const [detail, setDetail] = useState(undefined) + const [author, setAuthor] = useState([]) + const [reviewers, setReviewers] = useState([]) + const [approver, setApprover] = useState([]) const [sub, setSub] = useState(undefined) + const [batchSel, setBatchSel] = useState>(new Set()) + const [batchAuthor, setBatchAuthor] = useState([]) + const [batchReviewers, setBatchReviewers] = useState([]) + const [batchApprover, setBatchApprover] = useState([]) const [err, setErr] = useState('') const [notice, setNotice] = useState('') const [busy, setBusy] = useState(false) - const [author, setAuthor] = useState('') - const [reviewers, setReviewers] = useState('') - const [approver, setApprover] = useState('') const cmd = (id: string, params: Record) => api.rdmCommand(id, params) + const memberPage = useMemberPickerPage(members) + const refreshCatalog = async () => { + try { + const out = await cmd<{ docs: DutyCatalogDoc[] }>('rdm.duty.catalog', { projectId }) + setDocs(out.docs ?? []) + } catch (e) { setErr(String(e)); setDocs([]) } + } const refreshStatus = async (id: string) => { - if (!id) return + if (!id) { setSub(undefined); return } try { const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id)) setSub(out.submission) - } catch (e) { setErr(String(e)); setSub(undefined) } + } catch (e) { setSub(undefined) } } + /** 选中文档:读当前职责(duty.get)并回填选择器(方案:当前职责与冻结名单分列)。 */ + const selectDoc = async (id: string) => { + setErr(''); setNotice(''); setDocId(id); setDetail(undefined); setBatchSel(new Set()) + if (!id) return + try { + const out = await cmd('rdm.duty.get', { projectId, docId: id }) + setDetail(out) + setAuthor(out.assigned && out.roles.author ? [out.roles.author] : []) + setReviewers(out.assigned ? out.roles.reviewers ?? [] : []) + setApprover(out.assigned && out.roles.approver ? [out.roles.approver] : []) + } catch (e) { setErr(String(e)) } + await refreshStatus(id) + } + useEffect(() => { + setDocId(''); setDetail(undefined); setSub(undefined); setBatchSel(new Set()) + refreshCatalog() + api.rdmCommand<{ members: { owner: string; role: string }[] }>('rdm.project.member.list', { projectId }) + .then(out => setMembers(out.members ?? [])).catch(() => setMembers([])) + }, [projectId]) const run = async (label: string, fn: () => Promise) => { setErr(''); setNotice(''); setBusy(true) - try { await fn(); setNotice(label); await refreshStatus(docId) } + try { await fn(); setNotice(label); await refreshCatalog(); if (docId) await selectDoc(docId) } catch (e) { setErr(String(e)) } finally { setBusy(false) } } + const dirty = detail != null && ( + (author[0] ?? '') !== (detail.assigned ? detail.roles.author : '') + || JSON.stringify(reviewers) !== JSON.stringify(detail.assigned ? detail.roles.reviewers ?? [] : []) + || (approver[0] ?? '') !== (detail.assigned ? detail.roles.approver : '')) + + const saveDisabled = busy || !docId || !author[0] || !approver[0] || reviewers.length === 0 + || (detail !== null && detail !== undefined && detail.active != null && !window.confirm( + `文档「${detail.title}」在途第 ${detail.active.round} 轮审签将因职责变更失效,需重新提交。确认变更?`)) + + const save = () => run('已保存当前职责', () => + cmd('rdm.review.assign.doc', assignDocDutiesPayload(projectId, docId, author[0], reviewers, approver[0]))) + + const batchApply = () => run(`已批量分配 ${batchSel.size} 份文档`, () => + cmd('rdm.review.assign.doc.batch', { projectId, docs: [...batchSel].map(d => ({ + docId: d, author: batchAuthor[0], reviewers: batchReviewers, approver: batchApprover[0] })) })) + return (
-
-
- setDocId(e.target.value)} /> - refreshStatus(docId)}>查看整文状态 +

文档清单(职责分配对象为整份文档;搜索/浏览同一目录)

+ + + + {(docs ?? []).map(d => ( + + + + + + + + + ))} + {docs === null && } + {docs !== null && docs.length === 0 && } + +
文档类型 / 阶段当前职责状态
{ const n = new Set(batchSel); e.target.checked ? n.add(d.docId) : n.delete(d.docId); setBatchSel(n) }} /> + · {d.docId}{d.typeKey}{d.stage ? ` / ${d.stage}` : ''}{d.assigned + ? `编 ${d.author} · 审 ${(d.reviewers ?? []).join('、')} · 批 ${d.approver}` : '未分配'}{d.activeRound ? 第 {d.activeRound} 轮在途 : d.assigned ? — : 未分配}{d.sections} 章节
加载中…
暂无文档
+ + {detail === null && docId && 读取职责失败} + {detail && ( +
+

{detail.title} · 当前职责{detail.active ? 在途第 {detail.active.round} 轮(变更将失效需重提交) : null}

+
+
+
+
+ {reviewers.length > 0 && 已选:{reviewers.join('、')}}
+
+
+
+ 保存当前职责 + {detail.active && 保存将使在途第 {detail.active.round} 轮失效(提交人 {detail.active.createdBy})} +
+ {detail.sections.length > 0 && ( +

章节覆盖:{detail.sections.filter(x => x.overridden).length}/{detail.sections.length} 章节单独指派(覆盖文档默认)。

+ )}
-
+ )} - {sub === null && 该文档尚无整文提交记录} {sub && ( -
-

第 {sub.round} 轮 · {sub.state === 'submitted' ? 审签在途(全文锁定) : sub.state === 'final' ? 已终批 : 已驳回}

-
编制 {sub.duties.author} · 审核 {(sub.duties.reviewers ?? []).join('、')} · 批准 {sub.duties.approver}
+
+

整文审签 · 第 {sub.round} 轮 · {sub.state === 'submitted' ? 在途(全文锁定) : sub.state === 'final' ? 已终批 : 已驳回}

+
编 {sub.duties.author} · 审 {(sub.duties.reviewers ?? []).join('、')} · 批 {sub.duties.approver}
{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}
-
{(sub.digest ?? '').slice(0, 16)}…
- + +
)} + {sub === null && docId &&

该文档尚无整文提交记录(当前职责仍可分配)。

} - {sub === undefined && docId &&

输入文档 ID 后查看整文状态。

} - -

分配文档三权(整文)

-
- setDocId(e.target.value)} /> - setAuthor(e.target.value)} /> - setReviewers(e.target.value)} /> - setApprover(e.target.value)} /> -
-
- - -
-

三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。

+ {batchSel.size > 0 && ( +
+

批量分配(已选 {batchSel.size} 份;任一校验失败整批拒绝并列原因)

+
+
+
+
+
+
+
+ 批量分配 {batchSel.size} 份文档 + +
+
+ )} {notice &&
{notice}
} {err &&
{err}
} @@ -355,33 +462,102 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) { ) } +// ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖; +// 与实际判定同源——UI 不重算,全部读服务端解释) ---- -// ---- 项目设置:项目权限(矩阵解释读面,与实际判定同源——UI 不重算) ---- - -interface MatrixAction { action: string; allowed: boolean; source: string; reason?: string } +interface MatrixAction { + action: string; label?: string; scope?: string + allowed: boolean; source: string; reason?: string +} interface MatrixOut { projectId: string; member: string; role: string profRole: { roleId: string; name: string } | null actions: MatrixAction[] } +interface MatrixRowOut { + member: string; role: string + profRole: { roleId: string; name: string } | null + actions: MatrixAction[] +} +interface MatrixBatchOut { projectId: string; rows: MatrixRowOut[] } + +function actionTitle(a: MatrixAction) { return a.label || a.action } + +/** 动作行:中文标签为主,内部标识进 title 技术详情(方案 §5)。 */ +function ActionDetailTable({ actions, onOverride, canManage }: { + actions: MatrixAction[] + onOverride?: (action: string, mode: 'allow' | 'deny' | 'default') => void + canManage?: boolean +}) { + const project = actions.filter(a => a.scope !== 'system') + const system = actions.filter(a => a.scope === 'system') + const overrideOf = (a: MatrixAction): 'allow' | 'deny' | 'default' => + a.source.includes('override') || a.source === 'project' ? (a.allowed ? 'allow' : 'deny') : 'default' + return ( + <> + + {canManage ? : null} + + {project.map(a => ( + + + + + + {canManage ? ( + + ) : null} + + ))} + +
动作生效来源原因(拒绝时)项目覆盖
{actionTitle(a)}{a.allowed ? 允许 : 拒绝}{describeSource(a.source)}{a.reason || '—'} + +
+ {system.length > 0 && ( + + + + + {system.map(a => ( + + + + + + + ))} + +
动作生效来源原因
{actionTitle(a)}{a.allowed ? 允许 : 拒绝}{describeSource(a.source)}{a.reason || '—'}
+
+ )} + + ) +} export function RdmPermMatrixSection({ projectId }: { projectId: string }) { const me = useStore(s => s.me) + const [view, setView] = useState<'member' | 'matrix'>('member') const [member, setMember] = useState('') const [members, setMembers] = useState(null) const [matrix, setMatrix] = useState(null) + const [batch, setBatch] = useState(null) + const [expand, setExpand] = useState('') const [err, setErr] = useState('') + const [notice, setNotice] = useState('') const [busy, setBusy] = useState(false) - useEffect(() => { + const loadMembers = () => api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId }) .then(out => { setMembers(out.members ?? []) - // 默认自省:普通成员只看自己的矩阵;负责人/管理员可切换他人。 if (me?.owner) setMember(prev => prev || me.owner) - }) - .catch(e => setErr(String(e))) - }, [projectId, me?.owner]) + }).catch(e => setErr(String(e))) + useEffect(() => { loadMembers() }, [projectId, me?.owner]) // eslint-disable-line react-hooks/exhaustive-deps const query = async (m: string) => { if (!m) return @@ -390,43 +566,107 @@ export function RdmPermMatrixSection({ projectId }: { projectId: string }) { setMatrix(await api.rdmCommand('rdm.perm.check', permCheckPayload(projectId, m))) } catch (e) { setErr(String(e)) } finally { setBusy(false) } } + const queryBatch = async () => { + setErr(''); setBusy(true); setBatch(null) + try { + setBatch(await api.rdmCommand('rdm.perm.matrix', { projectId })) + } catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + useEffect(() => { if (view === 'matrix') queryBatch() }, [view]) // eslint-disable-line react-hooks/exhaustive-deps + + /** 三态覆盖写回:default=实际撤销(perm.unset);allow/deny=perm.set。 */ + const override = async (m: string, action: string, mode: 'allow' | 'deny' | 'default') => { + setErr(''); setNotice(''); setBusy(true) + try { + if (mode === 'default') { + await api.rdmCommand('rdm.perm.unset', { projectId, member: m, action }) + setNotice(`已恢复默认(撤销 ${action} 的项目覆盖)`) + } else { + await api.rdmCommand('rdm.perm.set', { projectId, member: m, action, allow: mode === 'allow' }) + setNotice(mode === 'allow' ? `已在项目内允许 ${action}` : `已在项目内禁止 ${action}`) + } + await query(m) + } catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + + const canManage = !!matrix && matrix.role !== undefined return (
- setView(e.target.value as 'member' | 'matrix')}> + + - query(member)}>查看矩阵 + {view === 'member' && ( + <> + + query(member)}>查看 + + )}
-

普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。

+

普通成员可查看自己的有效权限;查看他人与矩阵须项目管理权。解释与实际判定同源(服务端,非前端推算);系统范围动作折叠为技术详情。

- {matrix && ( + + {view === 'member' && matrix && ( <>
- 成员 {matrix.member} · 项目关系 {matrix.role} - {matrix.profRole && <> · 专业角色 {matrix.profRole.name}({matrix.profRole.roleId})} + 成员 {matrix.member} · 项目身份 {matrix.role === 'owner' ? '项目负责人' : matrix.role === 'member' ? '项目成员' : matrix.role} + {matrix.profRole && <> · 专业角色 {matrix.profRole.name}}
- - - - {(matrix.actions ?? []).map(a => ( - - - - - - - ))} - -
动作生效来源原因(拒绝时)
{a.action}{a.allowed ? 允许 : 拒绝}{describeSource(a.source)}{a.reason || '—'}
+ override(matrix.member, action, mode)} /> )} - {!matrix && !err && members !== null &&

选择成员后展示 12 项动作的有效权限与第一拒绝原因。

} + {view === 'member' && !matrix && !err && members !== null &&

选择成员后展示各动作的有效权限、来源与第一拒绝原因。

} + + {view === 'matrix' && batch && ( + + + + {(batch.rows[0]?.actions ?? []).filter(a => a.scope !== 'system').map(a => ( + + ))} + + + {batch.rows.map(row => ( + + + {row.actions.filter(a => a.scope !== 'system').map(a => ( + + ))} + + ))} + +
成员{actionTitle(a)}
+ + · {row.role === 'owner' ? '负责人' : '成员'} + + {a.allowed ? ✓ : ✗} +
+ )} + {view === 'matrix' && batch && expand && ( +
+

{expand} · 单成员详情与三态覆盖

+ {(() => { + const row = batch.rows.find(r => r.member === expand) + if (!row) return null + return override(expand, action, mode)} /> + })()} +
+ )} + + {notice &&
{notice}
} {err &&
{err}
}
) -} +} \ No newline at end of file