diff --git a/e2e/rdm-duties.playwright.ts b/e2e/rdm-duties.playwright.ts index 388c882..f34e6bd 100644 --- a/e2e/rdm-duties.playwright.ts +++ b/e2e/rdm-duties.playwright.ts @@ -1,4 +1,4 @@ -// Real React/Picker interaction with deterministic HTTP responses; no live account or database. +// 文档职责三视图:确定性 HTTP,不连接真实账号或数据库。 import { test, expect, type Page } from '@playwright/test' test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server') @@ -11,17 +11,10 @@ function deferred() { async function dutiesPage(page: Page) { const writes: { command: string; params: Record }[] = [] - const calls: { command: string; params: Record }[] = [] const holds = new Map; release: ReturnType }>() - const active = new Set() - const submissions: Record = { 'doc-A': null, 'doc-B': null } - const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove', - 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', - 'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final', 'rdm.review.doc.status'] - const docs = [ - { docId: 'doc-A', title: '文档A', typeKey: 'design', sections: 1, assigned: true, author: 'alice', reviewers: ['carol'], approver: 'dave' }, - { docId: 'doc-B', title: '文档B', typeKey: 'design', sections: 1, assigned: true, author: 'bob', reviewers: ['carol'], approver: 'dave' }, - ] + let generation = 0 + const commands = ['rdm.project.member.list', 'rdm.duty.matrix', 'rdm.duty.preview', + 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.scan', 'rdm.duty.legacy.apply'] await page.route('**/api/**', async route => { const request = route.request() const path = new URL(request.url()).pathname @@ -36,125 +29,71 @@ async function dutiesPage(page: Page) { const command = decodeURIComponent(path.substring('/api/commands/'.length)) const params = request.postDataJSON().params as Record expect(params.projectId).toBe('domain-7') - calls.push({ command, params }) - const hold = holds.get(`${command}:${params.docId ?? ''}`) + const hold = holds.get(command) if (hold) { hold.started.resolve(); await hold.release.promise } - let payload: unknown = {} - if (command === 'rdm.duty.catalog') payload = { docs } - else if (command === 'rdm.project.member.list') payload = { members: ['alice', 'bob', 'carol', 'dave'].map(owner => ({ owner, role: 'member', addedBy: '', addedAt: '' })) } - else if (command === 'rdm.duty.get') { - const doc = docs.find(d => d.docId === params.docId)! - payload = { docId: doc.docId, title: doc.title, assigned: doc.assigned, - roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [], - active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice' } : null } - } else if (command === 'rdm.review.doc.status') payload = { submission: submissions[String(params.docId)] } - else { writes.push({ command, params }) } - body = { payload } + const author = generation === 0 ? 'alice' : 'bob' + if (command === 'rdm.duty.matrix') { + body = { payload: { + canAssign: true, + docs: [{ docId: 'doc-A', code: 'A', title: '文档A', typeKey: 'design', stage: 'BD', dutyVersion: 1, authors: 1, reviewers: 1, approvers: 1, conflicts: 0, legacySectionOverrides: 0 }], + members: ['alice', 'bob', 'carol'].map(owner => ({ owner, role: 'member', valid: true, canAuthor: true, canReview: true, canApprove: true })), + cells: [ + { docId: 'doc-A', owner: 'alice', duty: author === 'alice' ? 'author' : 'none' }, + { docId: 'doc-A', owner: 'bob', duty: author === 'bob' ? 'author' : 'reviewer' }, + { docId: 'doc-A', owner: 'carol', duty: 'approver' }, + ], + } } + } else if (command === 'rdm.duty.preview') { + body = { payload: { ok: true, docs: [{ docId: 'doc-A', title: '文档A', replacedAuthor: 'alice' }] } } + } else if (command === 'rdm.duty.legacy.scan') { + body = { payload: { findings: [{ docId: 'doc-A', title: '文档A', sectionTitle: '范围', kind: 'ambiguous' }] } } + } else { + writes.push({ command, params }) + generation += 1 + body = { payload: { applied: 1 } } + } } else if (path.endsWith('/cards')) body = { cards: [] } await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }) }) await page.goto('/') await page.getByRole('button', { name: '项目设置', exact: true }).click() - await page.getByRole('tab', { name: /文档职责/ }).click() - await expect(page.getByRole('button', { name: '文档A', exact: true })).toBeVisible() - return { writes, calls, active, submissions, - hold(command: string, docId: string) { - const pending = { started: deferred(), release: deferred() } - holds.set(`${command}:${docId}`, pending) - return pending - } } + await page.getByRole('tab', { name: '文档职责', exact: true }).click() + await expect(page.getByText('本页只分配已有文档的职责,不创建或生成文档。')).toBeVisible() + return { writes, hold(command: string) { + const pending = { started: deferred(), release: deferred() } + holds.set(command, pending) + return pending + } } } -async function chooseMember(page: Page, label: string, owner: string) { - await page.locator(`.picker[aria-label="${label}"]`).getByRole('combobox').fill(owner) - await page.getByRole('option', { name: new RegExp(`^${owner}`) }).click() -} - -async function settleResponse(page: Page, command: string, release: () => void) { - const response = page.waitForResponse(r => r.url().endsWith(`/api/commands/${command}`)) - release() - await response - await page.evaluate(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => done())))) -} - -test('a newly assigned document can submit its first whole-document review', async ({ page }) => { +test('three views share one draft and do not offer review actions', async ({ page }) => { const fixture = await dutiesPage(page) - await page.getByRole('button', { name: '文档A', exact: true }).click() - await expect(page.getByText('该文档尚无整文提交记录(当前职责仍可分配)。', { exact: true })).toBeVisible() - const submit = page.getByRole('button', { name: '整文提交(编制人)', exact: true }) - await expect(submit).toBeEnabled() - await submit.click() - await expect(page.getByText('整文已提交审核(全文锁定)', { exact: true })).toBeVisible() - expect(fixture.writes).toEqual([{ command: 'rdm.review.doc.submit', params: { projectId: 'domain-7', docId: 'doc-A' } }]) + await page.getByLabel('选择成员').selectOption('bob') + await page.getByLabel('bob 在 文档A 的职责').selectOption({ label: '编制(将替换alice)' }) + await page.getByRole('tab', { name: '按文档' }).click() + await page.getByLabel('选择文档').selectOption('doc-A') + await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author') + await page.getByRole('tab', { name: '矩阵' }).click() + await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author') + await expect(page.getByRole('button', { name: /提交|审核通过|驳回|终批|生成|新建文档/ })).toHaveCount(0) + await page.getByRole('button', { name: '保存职责' }).click() + await expect.poll(() => fixture.writes.map(item => item.command)).toEqual(['rdm.duty.preview', 'rdm.review.assign.doc.batch']) }) -test('viewing active duties is passive; only a dirty save asks for confirmation', async ({ page }) => { +test('a stale matrix response does not replace the current draft', async ({ page }) => { const fixture = await dutiesPage(page) - fixture.active.add('doc-A') - const dialogs: string[] = [] - let accept = false - page.on('dialog', async dialog => { dialogs.push(dialog.message()); accept ? await dialog.accept() : await dialog.dismiss() }) - await page.getByRole('button', { name: '文档A', exact: true }).click() - await expect(page.getByRole('heading', { name: /文档A · 当前职责/ })).toBeVisible() - const save = page.getByRole('button', { name: '保存当前职责', exact: true }) - await expect(save).toBeDisabled() - expect(dialogs).toEqual([]) - await chooseMember(page, '编制人', 'bob') - await expect(save).toBeEnabled() - expect(dialogs).toEqual([]) - await save.click() - expect(dialogs).toHaveLength(1) - expect(fixture.writes).toEqual([]) - accept = true - await save.click() - await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible() - expect(dialogs).toHaveLength(2) - expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: { - projectId: 'domain-7', docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave', - } }]) + await page.getByLabel('选择成员').selectOption('alice') + const held = fixture.hold('rdm.duty.matrix') + await page.getByLabel('筛选文档名称或编号').fill('文档A') + await held.started.promise + await page.getByLabel('alice 在 文档A 的职责').selectOption('审核') + held.release.resolve() + await expect(page.getByLabel('alice 在 文档A 的职责')).toHaveValue('reviewer') }) -test('a late document detail cannot overwrite another selection or its saved roles', async ({ page }) => { - const fixture = await dutiesPage(page) - const old = fixture.hold('rdm.duty.get', 'doc-A') - await page.getByRole('button', { name: '文档A', exact: true }).click() - await old.started.promise - await page.getByRole('button', { name: '文档B', exact: true }).click() - await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible() - await settleResponse(page, 'rdm.duty.get', old.release.resolve) - await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible() - expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([]) - await chooseMember(page, '批准人', 'alice') - await page.getByRole('button', { name: '保存当前职责', exact: true }).click() - await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible() - expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: { - projectId: 'domain-7', docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'alice', - } }]) -}) - -test('a late review status cannot replace the selected document frozen review', async ({ page }) => { - const fixture = await dutiesPage(page) - const submission = (round: number, author: string) => ({ round, state: 'submitted', digest: '', createdBy: author, - createdAt: '', duties: { author, reviewers: ['carol'], approver: 'dave' }, reviews: {}, sections: {} }) - fixture.submissions['doc-A'] = submission(1, 'alice') - fixture.submissions['doc-B'] = submission(2, 'bob') - const old = fixture.hold('rdm.review.doc.status', 'doc-A') - await page.getByRole('button', { name: '文档A', exact: true }).click() - await old.started.promise - await page.getByRole('button', { name: '文档B', exact: true }).click() - await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible() - await settleResponse(page, 'rdm.review.doc.status', old.release.resolve) - await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible() - await expect(page.getByRole('heading', { name: /整文审签 · 第 1 轮/ })).toHaveCount(0) -}) - -test('closing the project panel invalidates a pending detail before its follow-up status read', async ({ page }) => { - const fixture = await dutiesPage(page) - const old = fixture.hold('rdm.duty.get', 'doc-A') - await page.getByRole('button', { name: '文档A', exact: true }).click() - await old.started.promise - await page.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click() - await expect(page.getByRole('tab', { name: /文档职责/ })).toHaveCount(0) - await settleResponse(page, 'rdm.duty.get', old.release.resolve) - expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([]) +test('a narrow viewport points away from the full matrix', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await dutiesPage(page) + await page.getByRole('tab', { name: '矩阵' }).click() + await expect(page.getByText('当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。')).toBeVisible() }) diff --git a/src/components/ProjectSettingsModal.tsx b/src/components/ProjectSettingsModal.tsx index 8420ef6..0675e78 100644 --- a/src/components/ProjectSettingsModal.tsx +++ b/src/components/ProjectSettingsModal.tsx @@ -72,7 +72,7 @@ export function ProjectSettingsModal() { { key: 'project', label: '本项目', icon: 'folder' }, // U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。 ...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []), - ...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责 · 整文审签', icon: 'shield' as const }] : []), + ...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责', icon: 'shield' as const }] : []), ...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []), ]} footer={saved ? ✓ 已保存 : '项目设置覆盖用户设置(new-run 类对新任务生效)'} diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index b8abad1..6c42619 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -1,5 +1,5 @@ // U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 / -// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力 +// 文档职责」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力 // 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal // 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用 // 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。 @@ -13,8 +13,7 @@ import { ROLE_ACTIONS, roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload, memberAddPayload, memberRemovePayload, memberSetRolePayload, - assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload, - docFinalPayload, docStatusPayload, permCheckPayload, describeSource, + permCheckPayload, describeSource, rolesListPlaceholder, describeRoleListError, } from '../rdmManagement' @@ -260,271 +259,7 @@ export function RdmMembersSection({ project }: { project: Project }) { ) } -// ---- 项目设置:文档职责(P7-B:文档清单+选中职责详情;方案 §4)---- - -interface DocSubmissionView { - round: number - state: string - digest: string - createdBy: string - createdAt: string - duties: { author: string; reviewers: string[]; approver: string } - reviews: Record - 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({ project }: { project: Project }) { - const projectId = project.id - const writeError = projectWorkError(project) - 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 cmd = (id: string, params: Record) => api.rdmProjectCommand(project, id, params) - const memberPage = useMemberPickerPage(members) - const requests = useRef({ project, epoch: 0, selected: '', selection: 0, catalog: 0, status: 0, action: 0 }) - requests.current.project = project - const currentProject = (epoch: number) => requests.current.project === project && requests.current.epoch === epoch - const currentDocument = (epoch: number, selection: number, id: string) => - currentProject(epoch) && requests.current.selection === selection && requests.current.selected === id - - const refreshCatalog = async (epoch = requests.current.epoch) => { - const request = ++requests.current.catalog - try { - const out = await cmd<{ docs: DutyCatalogDoc[] }>('rdm.duty.catalog', { projectId }) - if (!currentProject(epoch) || request !== requests.current.catalog) return - setDocs(out.docs ?? []) - } catch (e) { - if (!currentProject(epoch) || request !== requests.current.catalog) return - setErr(String(e)); setDocs([]) - } - } - const refreshStatus = async (id: string, epoch: number, selection: number) => { - if (!id || !currentDocument(epoch, selection, id)) return - const request = ++requests.current.status - try { - const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id)) - if (!currentDocument(epoch, selection, id) || request !== requests.current.status) return - setSub(out.submission) - } catch (e) { - if (!currentDocument(epoch, selection, id) || request !== requests.current.status) return - setSub(undefined); setErr(`整文状态读取失败:${e}`) - } - } - /** 选中文档:读当前职责(duty.get)并回填选择器(方案:当前职责与冻结名单分列)。 */ - const selectDoc = async (id: string, keepNotice = false) => { - const epoch = requests.current.epoch - const selection = ++requests.current.selection - requests.current.selected = id - requests.current.status++ - setErr(''); if (!keepNotice) setNotice('') - setDocId(id); setDetail(undefined); setSub(undefined); setBatchSel(new Set()) - setAuthor([]); setReviewers([]); setApprover([]) - if (!id) return - try { - const out = await cmd('rdm.duty.get', { projectId, docId: id }) - if (!currentDocument(epoch, selection, id)) return - if (out.docId !== id) throw new Error('文档职责与当前选择不一致,请重新选择文档') - 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) { - if (!currentDocument(epoch, selection, id)) return - setDetail(null); setErr(String(e)) - } - await refreshStatus(id, epoch, selection) - } - useEffect(() => { - const epoch = ++requests.current.epoch - requests.current.selected = ''; requests.current.selection++; requests.current.status++ - requests.current.action++ - setDocId(''); setDetail(undefined); setSub(undefined); setBatchSel(new Set()) - setDocs(null); setMembers(null); setAuthor([]); setReviewers([]); setApprover([]) - setBatchAuthor([]); setBatchReviewers([]); setBatchApprover([]) - setErr(''); setNotice(''); setBusy(false) - void refreshCatalog(epoch) - cmd<{ members: { owner: string; role: string }[] }>('rdm.project.member.list', { projectId }) - .then(out => { if (currentProject(epoch)) setMembers(out.members ?? []) }) - .catch(() => { if (currentProject(epoch)) setMembers([]) }) - return () => { - requests.current.epoch++; requests.current.selection++; requests.current.status++ - requests.current.catalog++; requests.current.action++; requests.current.selected = '' - } - }, [project]) - - const run = async (label: string, fn: () => Promise) => { - const epoch = requests.current.epoch - const selection = requests.current.selection - const selected = requests.current.selected - const action = ++requests.current.action - const currentAction = () => currentProject(epoch) && action === requests.current.action - setErr(''); setNotice(''); setBusy(true) - try { - await fn() - if (!currentAction() || !currentDocument(epoch, selection, selected)) return - setNotice(label) - await refreshCatalog(epoch) - if (currentAction() && currentDocument(epoch, selection, selected) && selected) await selectDoc(selected, true) - } catch (e) { - if (currentAction() && currentDocument(epoch, selection, selected)) setErr(String(e)) - } finally { if (currentAction()) 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 || !!writeError || !docId || detail == null || !author[0] || !approver[0] || reviewers.length === 0 - - const save = () => { - if (saveDisabled || !dirty || requests.current.selected !== docId || detail?.docId !== docId) return - if (detail.active && !window.confirm( - `文档「${detail.title}」在途第 ${detail.active.round} 轮审签将因职责变更失效,需重新提交。确认变更?`)) return - return 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 ( -
-

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

- - - - {(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} 章节单独指派(覆盖文档默认)。

- )} -
- )} - - {detail && docId && ( -
- -
- )} - {sub && ( -
-

整文审签 · 第 {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 === null && docId &&

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

} - - {batchSel.size > 0 && ( -
-

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

-
-
-
-
-
-
-
- 批量分配 {batchSel.size} 份文档 - -
-
- )} - - {notice &&
{notice}
} - {err &&
{err}
} -
- ) -} +export { RdmDutiesSection } from './duties/RdmDutiesSection' // ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖; // 与实际判定同源——UI 不重算,全部读服务端解释) ---- diff --git a/src/components/duties/RdmDutiesSection.tsx b/src/components/duties/RdmDutiesSection.tsx new file mode 100644 index 0000000..e90087f --- /dev/null +++ b/src/components/duties/RdmDutiesSection.tsx @@ -0,0 +1,241 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '../../api' +import type { Project } from '../../contracts' +import { projectWorkError } from '../../projectmeta' +import { Btn, Collapsible, EmptyState, Icon } from '@ui' +import { dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload } from '../../rdmManagement' +import { + absorbServerDocs, dirtyDocs, editCell, savePayload, + type CellDuty, type DutyDocDraft, type DutyMember, +} from '../../duties/draft' +import { DutyByDocumentView, DutyByMemberView, DutyChangePreview, DutyMatrixView } from '../../duties/views' + +type ViewName = 'member' | 'document' | 'matrix' + +interface MatrixDoc { + docId: string + title: string + code?: string + typeKey: string + stage?: string + dutyVersion: number + activeRound?: number + legacySectionOverrides?: number +} +interface MatrixOut { + canAssign: boolean + docs: MatrixDoc[] + members: DutyMember[] + cells: { docId: string; owner: string; duty: string }[] + nextDocCursor?: string + nextMemberCursor?: string +} +interface PreviewOut { + ok: boolean + docs: { + docId: string + title?: string + replacedAuthor?: string + replacedApprover?: string + needsConfirm?: boolean + reasons?: string[] + }[] +} +interface LegacyOut { + findings: { docId: string; title: string; sectionTitle: string; kind: string }[] +} + +export function RdmDutiesSection({ project }: { project: Project }) { + const projectId = project.id + const writeError = projectWorkError(project) + const [view, setView] = useState('member') + const [docs, setDocs] = useState([]) + const [members, setMembers] = useState([]) + const [member, setMember] = useState('') + const [docId, setDocId] = useState('') + const [query, setQuery] = useState('') + const [stage, setStage] = useState('') + const [typeKey, setTypeKey] = useState('') + const [assignment, setAssignment] = useState('') + const [docCursor, setDocCursor] = useState('') + const [nextDoc, setNextDoc] = useState('') + const [canAssign, setCanAssign] = useState(false) + const [narrow, setNarrow] = useState(false) + const [err, setErr] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(false) + const [preview, setPreview] = useState([]) + const [confirm, setConfirm] = useState(false) + const [legacy, setLegacy] = useState([]) + const [legacyOpen, setLegacyOpen] = useState(false) + const seq = useRef(0) + const docsRef = useRef(docs) + docsRef.current = docs + + const load = async (cursor: string, request = ++seq.current) => { + const seenMembers: DutyMember[] = [] + const seenCells: MatrixOut['cells'] = [] + let pageDocs: MatrixDoc[] = [] + let memberCursor = '' + let nextDocument = '' + let assignable = false + try { + do { + const page = await api.rdmProjectCommand(project, 'rdm.duty.matrix', dutyMatrixPayload(projectId, { + view: 'matrix', query, stage, typeKey, assignment, docCursor: cursor, memberCursor, docLimit: 10, memberLimit: 20, + })) + if (request !== seq.current) return + if (pageDocs.length === 0) pageDocs = page.docs ?? [] + seenMembers.push(...(page.members ?? [])) + seenCells.push(...(page.cells ?? [])) + assignable = page.canAssign + nextDocument = page.nextDocCursor ?? '' + memberCursor = page.nextMemberCursor ?? '' + } while (memberCursor) + if (request !== seq.current) return + setMembers(seenMembers) + setCanAssign(assignable) + setNextDoc(nextDocument) + setDocs(current => absorbServerDocs(current, pageDocs, seenCells)) + } catch (error) { + if (request !== seq.current) return + setErr(String(error)) + } + } + + useEffect(() => { + const media = window.matchMedia('(max-width: 720px)') + const apply = () => setNarrow(media.matches) + apply() + media.addEventListener('change', apply) + return () => media.removeEventListener('change', apply) + }, []) + + useEffect(() => { + void load(docCursor) + return () => { seq.current += 1 } + }, [project, query, stage, typeKey, assignment, docCursor]) + + const onCell = (id: string, owner: string, duty: CellDuty) => { + setPreview([]) + setDocs(current => editCell(current, id, owner, duty)) + } + + const visibleDocs = docs.filter(doc => { + const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase() + if (query && !text.includes(query.trim().toLowerCase())) return false + if (stage && doc.stage !== stage) return false + if (typeKey && doc.typeKey !== typeKey) return false + return true + }) + + const save = async () => { + const payload = savePayload(docsRef.current, confirm) + if (payload.issues.length > 0) { + setErr(payload.issues.join(';')) + return + } + const request = ++seq.current + setBusy(true); setErr(''); setNotice('') + try { + const looked = await api.rdmProjectCommand(project, 'rdm.duty.preview', dutyPreviewPayload(projectId, payload.docs)) + if (request !== seq.current) return + setPreview(looked.docs ?? []) + if (!looked.ok) { + setErr('预览未通过,未写入') + return + } + if ((looked.docs ?? []).some(item => item.needsConfirm) && !confirm) { + setErr('在途审签将失效。确认后再保存。') + return + } + await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm).docs)) + if (request !== seq.current) return + setNotice('已保存文档职责') + setPreview([]) + setDocs(current => current.map(doc => ({ ...doc, dirty: false }))) + await load(docCursor, request) + } catch (error) { + if (request !== seq.current) return + setErr(`保存结果未确认:${error}`) + await load(docCursor, request) + } finally { + if (request === seq.current) setBusy(false) + } + } + + const scanLegacy = async (request = ++seq.current) => { + try { + const out = await api.rdmProjectCommand(project, 'rdm.duty.legacy.scan', dutyLegacyPayload(projectId)) + if (request !== seq.current) return + setLegacy(out.findings ?? []) + } catch (error) { + if (request !== seq.current) return + setErr(String(error)) + } + } + + const applyIdentical = async () => { + const request = ++seq.current + setBusy(true) + try { + await api.rdmProjectCommand(project, 'rdm.duty.legacy.apply', dutyLegacyPayload(projectId)) + if (request !== seq.current) return + setNotice('已归并无歧义的章节职责') + await scanLegacy(request) + await load(docCursor, request) + } catch (error) { + if (request !== seq.current) return + setErr(String(error)) + } finally { + if (request === seq.current) setBusy(false) + } + } + + const disabled = busy || !!writeError || !canAssign + const ambiguous = legacy.filter(item => item.kind !== 'identical') + + return ( +
+

本页只分配已有文档的职责,不创建或生成文档。

+ {writeError &&

{writeError}

} +
+ + + +
+
+ { setDocCursor(''); setQuery(event.target.value) }} /> + { setDocCursor(''); setTypeKey(event.target.value) }} /> + { setDocCursor(''); setStage(event.target.value) }} /> + +
+ {view === 'member' && } + {view === 'document' && } + {view === 'matrix' && } +
+ {docCursor && } + {nextDoc && } + { void save() }}>保存职责 +
+ + + + + {legacy.length === 0 ?

尚未扫描,或没有章节级覆盖。

: ( +
    + {legacy.map(item =>
  • {item.title} / {item.sectionTitle}:{item.kind === 'identical' ? '与文档职责一致' : '有歧义,需逐文档确认'}
  • )} +
+ )} + {ambiguous.length > 0 &&

有歧义的文档不会自动覆盖。请在上面的视图里逐份确认后再保存。

} +
+ {notice &&
{notice}
} + {err &&
{err}
} + {docs.length === 0 && !err && 暂无文档} +
+ ) +} diff --git a/src/duties/draft.ts b/src/duties/draft.ts new file mode 100644 index 0000000..54c74eb --- /dev/null +++ b/src/duties/draft.ts @@ -0,0 +1,203 @@ +// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。 +export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver' + +export interface DutyRoles { + author: string + reviewers: string[] + approver: string +} + +export interface DutyDocDraft extends DutyRoles { + docId: string + title: string + code: string + typeKey: string + stage: string + dutyVersion: number + activeRound: number + legacySectionOverrides: number + dirty: boolean +} + +export interface DutyMember { + owner: string + role: string + valid: boolean + canAuthor: boolean + canReview: boolean + canApprove: boolean +} + +export interface DutySaveItem { + docId: string + author: string + reviewers: string[] + approver: string + expectedDutyVersion: number + confirmInvalidation: boolean +} + +export function cellDuty(roles: DutyRoles, owner: string): CellDuty { + if (roles.author === owner) return 'author' + if (roles.approver === owner) return 'approver' + if (roles.reviewers.includes(owner)) return 'reviewer' + return 'none' +} + +export function applyCell(roles: DutyRoles, owner: string, duty: CellDuty): { + roles: DutyRoles + replacedAuthor: string + replacedApprover: string +} { + const replacedAuthor = duty === 'author' && roles.author !== '' && roles.author !== owner ? roles.author : '' + const replacedApprover = duty === 'approver' && roles.approver !== '' && roles.approver !== owner ? roles.approver : '' + let author = roles.author === owner ? '' : roles.author + let approver = roles.approver === owner ? '' : roles.approver + let reviewers = roles.reviewers.filter(person => person !== owner) + switch (duty) { + case 'author': + author = owner + break + case 'approver': + approver = owner + break + case 'reviewer': + reviewers = [...reviewers, owner] + break + case 'none': + break + default: { + const exhaustive: never = duty + throw new Error(exhaustive) + } + } + return { roles: { author, reviewers, approver }, replacedAuthor, replacedApprover } +} + +export function replacementNote(roles: DutyRoles, owner: string, duty: CellDuty): string { + if (duty === 'author' && roles.author !== '' && roles.author !== owner) return `将替换${roles.author}` + if (duty === 'approver' && roles.approver !== '' && roles.approver !== owner) return `将替换${roles.approver}` + return '' +} + +export function dutyConflicts(roles: DutyRoles): number { + const seen = new Map() + const mark = (owner: string) => { + if (!owner) return + seen.set(owner, (seen.get(owner) ?? 0) + 1) + } + mark(roles.author) + mark(roles.approver) + for (const reviewer of roles.reviewers) mark(reviewer) + let conflicts = 0 + for (const count of seen.values()) if (count > 1) conflicts += 1 + return conflicts +} + +export function completeness(roles: DutyRoles): { authors: number; reviewers: number; approvers: number; conflicts: number } { + return { + authors: roles.author ? 1 : 0, + reviewers: roles.reviewers.length, + approvers: roles.approver ? 1 : 0, + conflicts: dutyConflicts(roles), + } +} + +export function saveIssues(roles: DutyRoles): string[] { + const issues: string[] = [] + if (!roles.author) issues.push('需指定编制人') + if (roles.reviewers.length === 0) issues.push('审核至少一人') + if (!roles.approver) issues.push('需指定批准人') + if (dutyConflicts(roles) > 0) issues.push('一人只能担一个职责') + return issues +} + +export function sameRoles(a: DutyRoles, b: DutyRoles): boolean { + return a.author === b.author && a.approver === b.approver && a.reviewers.join('\0') === b.reviewers.join('\0') +} + +export function rolesFromCells(docId: string, cells: { docId: string; owner: string; duty: string }[]): DutyRoles { + const roles: DutyRoles = { author: '', reviewers: [], approver: '' } + for (const cell of cells) { + if (cell.docId !== docId) continue + if (cell.duty !== 'none' && cell.duty !== 'author' && cell.duty !== 'reviewer' && cell.duty !== 'approver') continue + const next = applyCell(roles, cell.owner, cell.duty) + roles.author = next.roles.author + roles.reviewers = next.roles.reviewers + roles.approver = next.roles.approver + } + return roles +} + +export function absorbServerDocs( + current: DutyDocDraft[], + docs: { + docId: string; title: string; code?: string; typeKey: string; stage?: string + dutyVersion: number; activeRound?: number; legacySectionOverrides?: number + }[], + cells: { docId: string; owner: string; duty: string }[], +): DutyDocDraft[] { + const byId = new Map(current.map(doc => [doc.docId, doc])) + const pageIds = new Set(docs.map(doc => doc.docId)) + const next: DutyDocDraft[] = [] + for (const doc of docs) { + const previous = byId.get(doc.docId) + const serverRoles = rolesFromCells(doc.docId, cells) + const keepDraft = previous?.dirty === true + next.push({ + docId: doc.docId, + title: doc.title, + code: doc.code ?? doc.docId, + typeKey: doc.typeKey, + stage: doc.stage ?? '', + dutyVersion: keepDraft ? previous.dutyVersion : doc.dutyVersion, + activeRound: doc.activeRound ?? 0, + legacySectionOverrides: doc.legacySectionOverrides ?? 0, + dirty: keepDraft, + author: keepDraft ? previous.author : serverRoles.author, + reviewers: keepDraft ? previous.reviewers : serverRoles.reviewers, + approver: keepDraft ? previous.approver : serverRoles.approver, + }) + } + for (const doc of current) { + if (doc.dirty && !pageIds.has(doc.docId)) next.push(doc) + } + return next +} + +export function editCell(docs: DutyDocDraft[], docId: string, owner: string, duty: CellDuty): DutyDocDraft[] { + return docs.map(doc => { + if (doc.docId !== docId) return doc + const next = applyCell(doc, owner, duty) + return { ...doc, ...next.roles, dirty: true } + }) +} + +export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] { + return docs.filter(doc => doc.dirty) +} + +export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): { docs: DutySaveItem[]; issues: string[] } { + const dirty = dirtyDocs(docs) + const issues: string[] = [] + if (dirty.length === 0) issues.push('没有未保存的职责') + if (dirty.length > 20) issues.push('一次保存最多 20 份文档') + for (const doc of dirty) { + for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`) + } + return { + docs: dirty.slice(0, 20).map(doc => ({ + docId: doc.docId, + author: doc.author, + reviewers: doc.reviewers, + approver: doc.approver, + expectedDutyVersion: doc.dutyVersion, + confirmInvalidation: confirmInvalidation && doc.activeRound > 0, + })), + issues, + } +} + +export function needsInvalidationConfirm(docs: DutyDocDraft[]): boolean { + return dirtyDocs(docs).some(doc => doc.activeRound > 0) +} diff --git a/src/duties/views.tsx b/src/duties/views.tsx new file mode 100644 index 0000000..cde818c --- /dev/null +++ b/src/duties/views.tsx @@ -0,0 +1,203 @@ +import type { CellDuty, DutyDocDraft, DutyMember } from './draft' +import { cellDuty, completeness, replacementNote, saveIssues } from './draft' + +const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [ + { value: 'none', label: '无职责' }, + { value: 'author', label: '编制' }, + { value: 'reviewer', label: '审核' }, + { value: 'approver', label: '批准' }, +] + +function DutySelect({ + doc, owner, disabled, onChange, +}: { + doc: DutyDocDraft + owner: string + disabled: boolean + onChange: (duty: CellDuty) => void +}) { + const current = cellDuty(doc, owner) + return ( + + ) +} + +export function DutyByMemberView({ + member, members, docs, disabled, onMember, onCell, +}: { + member: string + members: DutyMember[] + docs: DutyDocDraft[] + disabled: boolean + onMember: (owner: string) => void + onCell: (docId: string, owner: string, duty: CellDuty) => void +}) { + return ( +
+
+ + +
+ {!member ?

先选择成员,再为每份文档指定无职责、编制、审核或批准。

: ( + + + + {docs.map(doc => { + const score = completeness(doc) + return ( + + + + + + + ) + })} + {docs.length === 0 && } + +
文档类型 / 阶段职责完整性
{doc.title} · {doc.code}{doc.typeKey}{doc.stage ? ` / ${doc.stage}` : ''} onCell(doc.docId, member, duty)} />编 {score.authors}/1 · 审 {score.reviewers} · 批 {score.approvers}/1
没有符合筛选的文档
+ )} +
+ ) +} + +export function DutyByDocumentView({ + docId, docs, members, disabled, onDoc, onCell, +}: { + docId: string + docs: DutyDocDraft[] + members: DutyMember[] + disabled: boolean + onDoc: (id: string) => void + onCell: (docId: string, owner: string, duty: CellDuty) => void +}) { + const doc = docs.find(item => item.docId === docId) + const score = doc ? completeness(doc) : null + const issues = doc ? saveIssues(doc) : [] + return ( +
+
+ + +
+ {score &&

编制 {score.authors}/1、审核 {score.reviewers} 人、批准 {score.approvers}/1、冲突 {score.conflicts}{issues.length > 0 ? `。${issues.join(',')}` : ''}

} + {!doc ?

先选择整份文档。这里不能选择章节,也没有生成入口。

: ( + + + + {members.map(person => ( + + + + + + ))} + +
成员项目身份职责
{person.owner}{person.valid ? '' : '(无效)'}{person.role === 'owner' ? '项目负责人' : '项目成员'} onCell(doc.docId, person.owner, duty)} />
+ )} +
+ ) +} + +export function DutyMatrixView({ + docs, members, disabled, narrow, onCell, +}: { + docs: DutyDocDraft[] + members: DutyMember[] + disabled: boolean + narrow: boolean + onCell: (docId: string, owner: string, duty: CellDuty) => void +}) { + if (narrow) { + return

当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。

+ } + return ( +
+ + + + + {docs.map(doc => { + const score = completeness(doc) + const ready = score.authors === 1 && score.reviewers >= 1 && score.approvers === 1 && score.conflicts === 0 + return + })} + + + + {members.map(person => ( + + + {docs.map(doc => ( + + ))} + + ))} + +
成员{doc.title}{ready ? '三权齐' : '未齐'}
{person.owner} + onCell(doc.docId, person.owner, duty)} /> +
+
+ ) +} + +export function DutyChangePreview({ + impacts, confirm, onConfirm, +}: { + impacts: { + docId: string + title?: string + replacedAuthor?: string + replacedApprover?: string + needsConfirm?: boolean + reasons?: string[] + }[] + confirm: boolean + onConfirm: (value: boolean) => void +}) { + if (impacts.length === 0) return null + const needs = impacts.some(item => item.needsConfirm) + return ( +
+

保存前预览

+
    + {impacts.map(item => ( +
  • + {item.title || item.docId} + {item.replacedAuthor ? `:编制将替换${item.replacedAuthor}` : ''} + {item.replacedApprover ? `:批准将替换${item.replacedApprover}` : ''} + {item.needsConfirm ? ';在途审签将失效,历史名单保持不变' : ''} + {item.reasons && item.reasons.length > 0 ? `;${item.reasons.join(',')}` : ''} +
  • + ))} +
+ {needs && ( + + )} +
+ ) +} diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts index cb7a2cd..b5c4576 100644 --- a/src/rdmManagement.ts +++ b/src/rdmManagement.ts @@ -26,6 +26,7 @@ export function rdmProjectReadError(project: Project | undefined): string | null const PROJECT_READ_COMMANDS = new Set([ 'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix', 'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get', + 'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan', ]) export function rdmProjectCommandError(project: Project, command: string): string | null { @@ -54,16 +55,17 @@ export const RDM_PERM_MATRIX_COMMANDS = [ 'rdm.project.member.list', ] as const -/** 项目设置「文档职责 · 整文审签」页消费的命令面。 */ -export const RDM_DOC_REVIEW_COMMANDS = [ +/** 项目设置「文档职责」页消费的命令面。审签命令留在文档工作区,不作为本页门控。 */ +export const RDM_DUTY_COMMANDS = [ + 'rdm.duty.matrix', + 'rdm.duty.preview', 'rdm.review.assign.doc', - 'rdm.review.doc.submit', - 'rdm.review.doc.approve', - 'rdm.review.doc.reject', - 'rdm.review.doc.final', - 'rdm.review.doc.status', + 'rdm.review.assign.doc.batch', ] as const +/** @deprecated 设置页已改用 RDM_DUTY_COMMANDS。保留导出名,避免旧调用点仍指向审签命令。 */ +export const RDM_DOC_REVIEW_COMMANDS = RDM_DUTY_COMMANDS + export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined function ready(cmds: CommandListItem[] | null | undefined, need: readonly string[]): boolean { @@ -79,9 +81,12 @@ export const rdmRolesReady = (cmds: CommandListItem[] | null | undefined) => export const rdmMembersReady = (cmds: CommandListItem[] | null | undefined) => ready(cmds, RDM_MEMBER_COMMANDS) -/** 文档职责/整文审签页是否就绪。 */ -export const rdmDocReviewReady = (cmds: CommandListItem[] | null | undefined) => - ready(cmds, RDM_DOC_REVIEW_COMMANDS) +/** 文档职责页是否就绪。命令未齐则 fail-closed。 */ +export const rdmDutyReady = (cmds: CommandListItem[] | null | undefined) => + ready(cmds, RDM_DUTY_COMMANDS) + +/** @deprecated 使用 rdmDutyReady。 */ +export const rdmDocReviewReady = rdmDutyReady /** 项目权限矩阵页是否就绪。 */ export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) => @@ -109,7 +114,37 @@ export const memberSetRolePayload = (projectId: string, member: string, role: 'o export const assignDocDutiesPayload = ( projectId: string, docId: string, author: string, reviewers: string[], approver: string, -) => ({ projectId, docId, author, reviewers, approver }) + extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean }, +) => ({ + projectId, docId, author, reviewers, approver, + ...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}), + ...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}), +}) + +export const dutyMatrixPayload = ( + projectId: string, + query: { + view: 'member' | 'document' | 'matrix' + memberOwner?: string + docId?: string + query?: string + stage?: string + typeKey?: string + assignment?: string + memberQuery?: string + memberCursor?: string + docCursor?: string + memberLimit?: number + docLimit?: number + }, +) => ({ projectId, ...query }) + +export const dutyPreviewPayload = ( + projectId: string, + docs: { docId: string; author: string; reviewers: string[]; approver: string; expectedDutyVersion: number; confirmInvalidation: boolean }[], +) => ({ projectId, docs }) + +export const dutyLegacyPayload = (projectId: string) => ({ projectId }) export const docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId }) export const docApprovePayload = (projectId: string, docId: string, note?: string) => diff --git a/src/store.ts b/src/store.ts index ebb0f2e..7a8763e 100644 --- a/src/store.ts +++ b/src/store.ts @@ -12,7 +12,7 @@ import { flashElement } from './scrollsync' import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme' import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey' import { rdmAuditReady, rdmExportReady } from './workspaces' -import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement' +import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement' type FontSizeChoice = 13 | 14 | 16 | 18 const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18] @@ -1174,7 +1174,7 @@ export const store = { rdmExportAvailable: rdmExportReady(cmds), rdmRolesAvailable: rdmRolesReady(cmds), rdmMembersAvailable: rdmMembersReady(cmds), - rdmDocReviewAvailable: rdmDocReviewReady(cmds), + rdmDocReviewAvailable: rdmDutyReady(cmds), rdmPermMatrixAvailable: rdmPermMatrixReady(cmds), }) } catch { diff --git a/src/styles.css b/src/styles.css index 54016a1..b52e525 100644 --- a/src/styles.css +++ b/src/styles.css @@ -644,3 +644,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .rdm-audit-err { display: flex; flex-direction: column; gap: 8px; } .rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); } .rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; } +.duty-matrix-wrap { overflow: auto; } +.duty-matrix .duty-sticky { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; } +.duty-matrix thead th { position: sticky; top: 0; background: var(--bg-panel); z-index: 2; } +.duty-cell select { font-size: var(--fs-ui-sm); color: var(--text); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); } diff --git a/test/duty-draft.test.ts b/test/duty-draft.test.ts new file mode 100644 index 0000000..19629b7 --- /dev/null +++ b/test/duty-draft.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { + absorbServerDocs, applyCell, cellDuty, completeness, editCell, replacementNote, saveIssues, savePayload, + type DutyDocDraft, +} from '../src/duties/draft' + +function doc(over: Partial = {}): DutyDocDraft { + return { + docId: 'd1', title: '需求', code: 'UR-1', typeKey: 'design', stage: 'BD', + dutyVersion: 3, activeRound: 0, legacySectionOverrides: 0, dirty: false, + author: 'alice', reviewers: ['bob'], approver: 'carol', + ...over, + } +} + +describe('文档职责草稿', () => { + it('单元格由三权反推,三人视图读到同一份角色', () => { + const roles = { author: 'alice', reviewers: ['bob', 'dave'], approver: 'carol' } + expect(cellDuty(roles, 'alice')).toBe('author') + expect(cellDuty(roles, 'bob')).toBe('reviewer') + expect(cellDuty(roles, 'dave')).toBe('reviewer') + expect(cellDuty(roles, 'carol')).toBe('approver') + expect(cellDuty(roles, 'erin')).toBe('none') + expect(completeness(roles)).toEqual({ authors: 1, reviewers: 2, approvers: 1, conflicts: 0 }) + }) + + it('替换编制前先记住原编制,并保证一人一责', () => { + const next = applyCell({ author: 'alice', reviewers: ['bob'], approver: 'carol' }, 'bob', 'author') + expect(next.replacedAuthor).toBe('alice') + expect(next.roles).toEqual({ author: 'bob', reviewers: [], approver: 'carol' }) + expect(replacementNote({ author: 'alice', reviewers: ['bob'], approver: 'carol' }, 'bob', 'author')).toBe('将替换alice') + }) + + it('清掉唯一编制后允许留在草稿,但禁止保存', () => { + const cleared = editCell([doc()], 'd1', 'alice', 'none') + expect(cleared[0].author).toBe('') + expect(cleared[0].dirty).toBe(true) + expect(saveIssues(cleared[0])).toContain('需指定编制人') + expect(savePayload(cleared, false).issues.join(' ')).toContain('需指定编制人') + }) + + it('过期服务端页不覆盖未保存草稿', () => { + const dirty = doc({ author: 'erin', dirty: true, dutyVersion: 3 }) + const absorbed = absorbServerDocs([dirty], [{ + docId: 'd1', title: '需求', typeKey: 'design', dutyVersion: 4, + }], [{ docId: 'd1', owner: 'alice', duty: 'author' }, { docId: 'd1', owner: 'bob', duty: 'reviewer' }, { docId: 'd1', owner: 'carol', duty: 'approver' }]) + expect(absorbed[0].author).toBe('erin') + expect(absorbed[0].dutyVersion).toBe(3) + }) + + it('保存载荷是每份脏文档的完整目标,超过 20 份被拒绝', () => { + const many = Array.from({ length: 21 }, (_, index) => doc({ docId: `d${index}`, dirty: true })) + const blocked = savePayload(many, true) + expect(blocked.issues).toContain('一次保存最多 20 份文档') + const one = savePayload([doc({ dirty: true, activeRound: 2 })], true) + expect(one.docs[0]).toMatchObject({ + docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'carol', + expectedDutyVersion: 3, confirmInvalidation: true, + }) + }) +}) diff --git a/test/project-access.test.ts b/test/project-access.test.ts index 91c6cf0..c64dfea 100644 --- a/test/project-access.test.ts +++ b/test/project-access.test.ts @@ -56,7 +56,8 @@ describe('project command identity', () => { it.each(['archived', 'closed'])('preserves canonical member, permission and duty reads for a %s project', async state => { const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state }) const commands = ['rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix', - 'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get'] + 'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get', + 'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan'] for (const command of commands) await api.rdmProjectCommand(historical, command, { docId: 'doc-2' }) expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([ { path: '/api/commands/rdm.project.member.list', params: { projectId: 'domain-7', docId: 'doc-2' } }, @@ -65,13 +66,17 @@ describe('project command identity', () => { { path: '/api/commands/rdm.review.doc.status', params: { projectId: 'domain-7', docId: 'doc-2' } }, { path: '/api/commands/rdm.duty.catalog', params: { projectId: 'domain-7', docId: 'doc-2' } }, { path: '/api/commands/rdm.duty.get', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.duty.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.duty.preview', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.duty.legacy.scan', params: { projectId: 'domain-7', docId: 'doc-2' } }, ]) }) it.each(['archived', 'closed'])('still blocks project and document mutations for a %s project', async state => { const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state }) for (const command of ['rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.setRole', - 'rdm.review.assign.doc', 'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final']) { + 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.apply', + 'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final']) { await expect(api.rdmProjectCommand(historical, command)).rejects.toThrow() } expect(requests).toEqual([]) diff --git a/test/rdm-management.test.ts b/test/rdm-management.test.ts index 2710a12..14cebfc 100644 --- a/test/rdm-management.test.ts +++ b/test/rdm-management.test.ts @@ -3,8 +3,8 @@ //(未登录/请求失败/null)一律视为未就绪(fail-closed),普通基座不出现 RDM 页面。 import { describe, expect, it } from 'vitest' import { - RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DOC_REVIEW_COMMANDS, ROLE_ACTIONS, - rdmRolesReady, rdmMembersReady, rdmDocReviewReady, + RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DUTY_COMMANDS, ROLE_ACTIONS, + rdmRolesReady, rdmMembersReady, rdmDutyReady, dutyMatrixPayload, dutyPreviewPayload, roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload, memberAddPayload, memberRemovePayload, assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload, docFinalPayload, docStatusPayload, @@ -19,19 +19,19 @@ describe('RDM 页面能力门控', () => { it('命令齐备且 enabled 才就绪', () => { expect(rdmRolesReady(all(RDM_ROLES_COMMANDS))).toBe(true) expect(rdmMembersReady(all(RDM_MEMBER_COMMANDS))).toBe(true) - expect(rdmDocReviewReady(all(RDM_DOC_REVIEW_COMMANDS))).toBe(true) + expect(rdmDutyReady(all(RDM_DUTY_COMMANDS))).toBe(true) }) it('缺一命令即未就绪', () => { const drop = (ids: readonly string[], skip: string) => ids.filter(i => i !== skip).map(id => cmd(id)) expect(rdmRolesReady(drop(RDM_ROLES_COMMANDS, 'rdm.role.list'))).toBe(false) expect(rdmMembersReady(drop(RDM_MEMBER_COMMANDS, 'rdm.project.member.add'))).toBe(false) - expect(rdmDocReviewReady(drop(RDM_DOC_REVIEW_COMMANDS, 'rdm.review.doc.final'))).toBe(false) + expect(rdmDutyReady(drop(RDM_DUTY_COMMANDS, 'rdm.duty.matrix'))).toBe(false) }) it('disabled / null / 非数组 / 请求失败均未就绪(fail-closed)', () => { expect(rdmRolesReady(RDM_ROLES_COMMANDS.map(id => cmd(id, false)))).toBe(false) expect(rdmRolesReady(null)).toBe(false) expect(rdmMembersReady(undefined)).toBe(false) - expect(rdmDocReviewReady('not-array' as unknown as never)).toBe(false) + expect(rdmDutyReady('not-array' as unknown as never)).toBe(false) }) }) @@ -68,6 +68,9 @@ describe('命令载荷构造(身份只经认证会话——载荷不含任何 expect(docFinalPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' }) expect(docFinalPayload('p1', 'd1', 3)).toEqual({ projectId: 'p1', docId: 'd1', round: 3 }) expect(docStatusPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' }) + expect(dutyMatrixPayload('p1', { view: 'matrix', docLimit: 10 })).toEqual({ projectId: 'p1', view: 'matrix', docLimit: 10 }) + expect(dutyPreviewPayload('p1', [{ docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2, confirmInvalidation: true }])) + .toEqual({ projectId: 'p1', docs: [{ docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2, confirmInvalidation: true }] }) }) it('审阅人 CSV 解析:去空白、去空项、保序去重', () => { expect(parseReviewers('bob, carol,,bob')).toEqual(['bob', 'carol'])