Compare commits

..
Author SHA1 Message Date
weichen 781cde6667 Merge remote-tracking branch 'origin/main' into feature/layered-python-env 2026-10-15 14:01:27 +00:00
weichenandCursor e33d17e1a6 Show the layered Python environment in the admin zone.
Why: Approving an extra package, rebuilding the base layer, reclaiming unused layers, and uploading a wheel need an admin surface that uses the existing admin controls.
Evidence: SELF tsc of the new panel reported no errors in frontend sources. UI package type errors come from missing node_modules in this worktree, not from this panel.
Impact: Administrators get a Python 环境 section. Other admin sections are unchanged.
Refs: .task/feature-layered-python-env.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 13:52:55 +00:00
weichen 5c0e9f2711 Merge pull request 'Show document duties in three shared views' (#90) from feature/rdm-duty-matrix into main 2026-10-15 09:15:09 +00:00
weichen 04269eed25 Merge remote-tracking branch 'origin/main' into feature/rdm-duty-matrix 2026-10-15 09:14:32 +00:00
weichenandCursor 985fc0f931 Adopt the server duty draft when a save result is unknown
Why: a lost write response was keeping the old duty version, so the next save replayed it, and a busy save could stay locked if another request advanced the sequence.
Evidence: SELF vitest duty-draft 6 passed, tsc passed, knip 43, Playwright duty flows 3 passed.
Impact: an unknown save reloads server roles, confirmation resets, and busy clears even if a later read is discarded.
Refs: docs/proposals/2026-10-15-rdm-duty-matrix-rework.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 09:06:24 +00:00
weichen 4835c45b8d Merge pull request 'Stabilize the nonblocking choice browser baseline' (#91) from fix/workbench-choice-browser-wait into main 2026-10-15 08:38:20 +00:00
weichenandCursor 6093beb27e Merge origin/main into the duty matrix branch
Why: project settings must keep the shared duty draft and must not regain template document creation from main.
Evidence: SELF vitest 292 passed, tsc --noEmit passed, knip unused exports 43.
Impact: the duties tab stays assignment-only while conversation ordering from main is included.
Refs: docs/proposals/2026-10-15-rdm-duty-matrix-rework.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 08:37:53 +00:00
weichenandCursor 06249dd83f Keep the duty draft exports inside the knip baseline
Why: the settings page only needs the draft operations the views call. Extra aliases and unused helpers pushed the unused-export count over the baseline.
Evidence: SELF node scripts/check-knip.mjs reported 43 <= 43, and duty-draft vitest passed.
Impact: chapter-review command aliases are gone from the settings gate. The draft behavior is unchanged.
Refs: modules/frontend/knip-baseline.json
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 07:34:49 +00:00
weichenandCursor 44205e1262 Cover the shared duty draft in the browser
Why: the three views, the blocked empty-reviewer save, and the narrow-screen matrix need a browser check that does not depend on a live account.
Evidence: SELF playwright e2e/rdm-duties.playwright.ts, 3 passed against the local Vite server.
Impact: the duties end-to-end file now drives assignment, stale responses, and the narrow-screen guidance.
Refs: feature/rdm-duty-matrix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 07:23:02 +00:00
weichenandCursor 1b7ba779f1 Show document duties in three shared views
Why: the settings page was mixing duty assignment with whole-document review and chapter objects. The three views need one draft and one save check.
Evidence: SELF vitest run of the frontend suite, 278 tests passed, including duty-draft and design-guard.
Impact: the duties tab assigns existing documents only. Submit, approve, reject, and final stay out of this page. Commands that are not ready keep the tab hidden.
Refs: feature/rdm-duty-matrix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 07:20:48 +00:00
16 changed files with 971 additions and 477 deletions
+59 -118
View File
@@ -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' import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server') 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) { async function dutiesPage(page: Page) {
const writes: { command: string; params: Record<string, unknown> }[] = [] const writes: { command: string; params: Record<string, unknown> }[] = []
const calls: { command: string; params: Record<string, unknown> }[] = []
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>() const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
const active = new Set<string>() let generation = 0
const submissions: Record<string, unknown> = { 'doc-A': null, 'doc-B': null } const commands = ['rdm.project.member.list', 'rdm.duty.matrix', 'rdm.duty.preview',
const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove', 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.scan', 'rdm.duty.legacy.apply']
'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' },
]
await page.route('**/api/**', async route => { await page.route('**/api/**', async route => {
const request = route.request() const request = route.request()
const path = new URL(request.url()).pathname const path = new URL(request.url()).pathname
@@ -36,125 +29,73 @@ async function dutiesPage(page: Page) {
const command = decodeURIComponent(path.substring('/api/commands/'.length)) const command = decodeURIComponent(path.substring('/api/commands/'.length))
const params = request.postDataJSON().params as Record<string, unknown> const params = request.postDataJSON().params as Record<string, unknown>
expect(params.projectId).toBe('domain-7') expect(params.projectId).toBe('domain-7')
calls.push({ command, params }) const hold = holds.get(command)
const hold = holds.get(`${command}:${params.docId ?? ''}`)
if (hold) { hold.started.resolve(); await hold.release.promise } if (hold) { hold.started.resolve(); await hold.release.promise }
let payload: unknown = {} const author = generation === 0 ? 'alice' : 'bob'
if (command === 'rdm.duty.catalog') payload = { docs } if (command === 'rdm.duty.matrix') {
else if (command === 'rdm.project.member.list') payload = { members: ['alice', 'bob', 'carol', 'dave'].map(owner => ({ owner, role: 'member', addedBy: '', addedAt: '' })) } body = { payload: {
else if (command === 'rdm.duty.get') { canAssign: true,
const doc = docs.find(d => d.docId === params.docId)! docs: [{ docId: 'doc-A', code: 'A', title: '文档A', typeKey: 'design', stage: 'BD', dutyVersion: 1, authors: 1, reviewers: 1, approvers: 1, conflicts: 0, legacySectionOverrides: 0 }],
payload = { docId: doc.docId, title: doc.title, assigned: doc.assigned, members: ['alice', 'bob', 'carol'].map(owner => ({ owner, role: 'member', valid: true, canAuthor: true, canReview: true, canApprove: true })),
roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [], cells: [
active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice' } : null } { docId: 'doc-A', owner: 'alice', duty: author === 'alice' ? 'author' : 'none' },
} else if (command === 'rdm.review.doc.status') payload = { submission: submissions[String(params.docId)] } { docId: 'doc-A', owner: 'bob', duty: author === 'bob' ? 'author' : 'reviewer' },
else { writes.push({ command, params }) } { docId: 'doc-A', owner: 'carol', duty: 'approver' },
body = { payload } ],
} }
} else if (command === 'rdm.duty.preview') {
writes.push({ command, params })
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: [] } } else if (path.endsWith('/cards')) body = { cards: [] }
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }) await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
}) })
await page.goto('/') await page.goto('/')
await page.getByRole('button', { name: '项目设置', exact: true }).click() await page.getByRole('button', { name: '项目设置', exact: true }).click()
await page.getByRole('tab', { name: /文档职责/ }).click() await page.getByRole('tab', { name: '文档职责', exact: true }).click()
await expect(page.getByRole('button', { name: '文档A', exact: true })).toBeVisible() await expect(page.getByText('本页只分配已有文档的职责,不创建或生成文档。')).toBeVisible()
return { writes, calls, active, submissions, return { writes, hold(command: string) {
hold(command: string, docId: string) { const pending = { started: deferred(), release: deferred() }
const pending = { started: deferred(), release: deferred() } holds.set(command, pending)
holds.set(`${command}:${docId}`, pending) return pending
return pending } }
} }
} }
async function chooseMember(page: Page, label: string, owner: string) { test('three views share one draft and do not offer review actions', async ({ page }) => {
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<void>(done => requestAnimationFrame(() => requestAnimationFrame(() => done()))))
}
test('a newly assigned document can submit its first whole-document review', async ({ page }) => {
const fixture = await dutiesPage(page) const fixture = await dutiesPage(page)
await page.getByRole('button', { name: '文档A', exact: true }).click() await page.getByLabel('选择成员').selectOption('bob')
await expect(page.getByText('该文档尚无整文提交记录(当前职责仍可分配)。', { exact: true })).toBeVisible() await page.getByLabel('bob 在 文档A 的职责').selectOption({ label: '编制(将替换alice)' })
const submit = page.getByRole('button', { name: '整文提交(编制人)', exact: true }) await page.getByRole('tab', { name: '按文档' }).click()
await expect(submit).toBeEnabled() await page.getByLabel('选择文档').selectOption('doc-A')
await submit.click() await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
await expect(page.getByText('整文已提交审核(全文锁定)', { exact: true })).toBeVisible() await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
expect(fixture.writes).toEqual([{ command: 'rdm.review.doc.submit', params: { projectId: 'domain-7', docId: 'doc-A' } }]) 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) const fixture = await dutiesPage(page)
fixture.active.add('doc-A') await page.getByLabel('选择成员').selectOption('alice')
const dialogs: string[] = [] const held = fixture.hold('rdm.duty.matrix')
let accept = false await page.getByLabel('筛选文档名称或编号').fill('文档A')
page.on('dialog', async dialog => { dialogs.push(dialog.message()); accept ? await dialog.accept() : await dialog.dismiss() }) await held.started.promise
await page.getByRole('button', { name: '文档A', exact: true }).click() await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
await expect(page.getByRole('heading', { name: /文档A · 当前职责/ })).toBeVisible() held.release.resolve()
const save = page.getByRole('button', { name: '保存当前职责', exact: true }) await expect(page.getByLabel('alice 在 文档A 的职责')).toHaveValue('reviewer')
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',
} }])
}) })
test('a late document detail cannot overwrite another selection or its saved roles', async ({ page }) => { test('a narrow viewport points away from the full matrix', async ({ page }) => {
const fixture = await dutiesPage(page) await dutiesPage(page)
const old = fixture.hold('rdm.duty.get', 'doc-A') await page.setViewportSize({ width: 390, height: 800 })
await page.getByRole('button', { name: '文档A', exact: true }).click() await page.getByRole('tab', { name: '矩阵' }).click()
await old.started.promise await expect(page.getByText('当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。')).toBeVisible()
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([])
}) })
+21
View File
@@ -15,6 +15,16 @@ const BASE = ''
* 自身命令面 fail-closed。 */ * 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number } export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
export interface ExecEnvView {
configured: boolean
root?: string
python?: string
base?: string
layers?: string[]
requests?: { id: string; conversationId: string; owner: string; spec: string; reason: string; status: string; layerId: string; detail: string; approver: string }[]
bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[]
}
async function checked(res: Response): Promise<Response> { async function checked(res: Response): Promise<Response> {
if (!res.ok) { if (!res.ok) {
const text = await res.text() const text = await res.text()
@@ -79,6 +89,17 @@ export const api = {
adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)), adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)),
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []), adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)), adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
adminExecEnvGC: () => auth.fetch(`${BASE}/api/admin/exec-env/gc`, { method: 'POST' }).then(r => json<{ removed: string[] | null }>(r)),
adminExecEnvWheel: (file: File, conversationId: string, reason: string) => {
const body = new FormData()
body.set('wheel', file)
body.set('conversationId', conversationId)
body.set('reason', reason)
return auth.fetch(`${BASE}/api/admin/exec-env/wheels`, { method: 'POST', body }).then(r => json<{ file: string; outcome: { status: string; requestId?: string; detail?: string } }>(r))
},
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)), createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }), deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)), setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
+2
View File
@@ -8,6 +8,7 @@ import { useState } from 'react'
import { store, useStore } from '../store' import { store, useStore } from '../store'
import { EmptyState, Icon, type IconName } from '@ui' import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections' import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
import { ExecEnvPanel } from './ExecEnvPanel'
import { RdmAuditWorkspace } from './RdmAuditWorkspace' import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { RdmRolesPanel } from './adminRdmSections' import { RdmRolesPanel } from './adminRdmSections'
import type { AdminZoneSection } from '../api' import type { AdminZoneSection } from '../api'
@@ -34,6 +35,7 @@ const SECTIONS: AdminSectionDef[] = [
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。 // P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> }, { key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> }, { key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => <ExecEnvPanel /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> }, { key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
] ]
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { api, type ExecEnvView } from '../api'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup } from '@ui'
export function ExecEnvPanel() {
const [view, setView] = useState<ExecEnvView | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [conv, setConv] = useState('')
const [reason, setReason] = useState('')
const [wheel, setWheel] = useState<File | null>(null)
const refresh = async () => {
try { setView(await api.adminExecEnv()) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const run = async (work: () => Promise<string>) => {
setErr(''); setNotice(''); setBusy(true)
try { setNotice(await work()); await refresh() } catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
if (!view) return <EmptyState icon="package">加载 Python 环境…</EmptyState>
if (!view.configured) return <EmptyState icon="package">这台服务没有配置分层 Python 环境根</EmptyState>
return (
<div>
<SettingsGroup title="当前环境">
<SettingRow label="环境根" desc={view.root || '未设置'}><span /></SettingRow>
<SettingRow label="解释器" desc={view.python || '尚未安装托管解释器'}><span /></SettingRow>
<SettingRow label="基础层" desc={view.base || '尚未建立'}><span /></SettingRow>
<SettingRow label="层目录" desc={(view.layers ?? []).join('、') || '无'}>
<Btn variant="ghost" icon="undo" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvRebase()
return `基础层已切换到 ${out.base}。已有会话仍用原来的绑定。`
})}>重建基础层</Btn>
</SettingRow>
</SettingsGroup>
<SettingsGroup title="申请">
{(view.requests ?? []).length === 0 && <EmptyState icon="inbox">还没有扩展包申请</EmptyState>}
{(view.requests ?? []).map(req => (
<SettingRow key={req.id} label={req.spec || req.id} desc={`${req.status} · ${req.conversationId} · ${req.detail}`}>
{req.status === 'pending_approval' && (
<span className="ad-row">
<Btn variant="primary" icon="check" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, true, false)
return out.detail || out.status
})}>批准</Btn>
<Btn variant="ghost" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, true, true)
return out.detail || '已用隔离层'
})}>隔离层</Btn>
<Btn variant="ghost" icon="x" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, false, false)
return out.detail || '已拒绝'
})}>拒绝</Btn>
</span>
)}
</SettingRow>
))}
</SettingsGroup>
<SettingsGroup title="会话绑定">
{(view.bindings ?? []).map(b => (
<SettingRow key={b.conversationId} label={b.conversationId} desc={b.isolated ? `隔离 ${b.isolated}` : (b.extensions ?? []).join('、') || '只用基础层'}>
<span />
</SettingRow>
))}
<SettingRow label="回收" desc="删掉 7 天内没有会话引用、也不是当前基础层的目录">
<Btn variant="ghost" icon="trash" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvGC()
const n = out.removed?.length ?? 0
return n === 0 ? '没有可回收的层' : `已回收 ${n} 层`
})}>回收未引用层</Btn>
</SettingRow>
</SettingsGroup>
<SettingsGroup title="上传 wheel">
<SettingRow label="会话" desc="这个 wheel 只挂到指定会话,并进入审批">
<input className="lg-input" value={conv} onChange={e => setConv(e.target.value)} placeholder="会话 ID" />
</SettingRow>
<SettingRow label="理由" desc="写给审批记录">
<input className="lg-input" value={reason} onChange={e => setReason(e.target.value)} />
</SettingRow>
<SettingRow label="文件" desc="只接受预编译 wheel,不执行里面的脚本">
<input type="file" accept=".whl" onChange={e => setWheel(e.target.files?.[0] ?? null)} />
</SettingRow>
<div className="ad-row">
<Btn variant="primary" icon="download" disabled={busy || !wheel || !conv.trim()} onClick={() => run(async () => {
const out = await api.adminExecEnvWheel(wheel as File, conv.trim(), reason.trim())
return `${out.file}:${out.outcome.status}`
})}>提交审批</Btn>
</div>
</SettingsGroup>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
+1 -1
View File
@@ -72,7 +72,7 @@ export function ProjectSettingsModal() {
{ key: 'project', label: '本项目', icon: 'folder' }, { key: 'project', label: '本项目', icon: 'folder' },
// U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。 // U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。
...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []), ...(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 }] : []), ...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
]} ]}
footer={saved ? <span style={{ color: 'var(--ok-text)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'} footer={saved ? <span style={{ color: 'var(--ok-text)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
+3 -317
View File
@@ -1,5 +1,5 @@
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 / // U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力 // 文档职责」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal // 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用 // 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。 // 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
@@ -13,8 +13,7 @@ import {
ROLE_ACTIONS, ROLE_ACTIONS,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload, roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload, memberSetRolePayload, memberAddPayload, memberRemovePayload, memberSetRolePayload,
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload, permCheckPayload, describeSource,
docFinalPayload, docStatusPayload, permCheckPayload, describeSource,
rolesListPlaceholder, describeRoleListError, rolesListPlaceholder, describeRoleListError,
} from '../rdmManagement' } from '../rdmManagement'
@@ -260,320 +259,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
) )
} }
// ---- 项目设置:文档职责(P7-B:文档清单+选中职责详情;方案 §4)---- export { RdmDutiesSection } from './duties/RdmDutiesSection'
interface DocSubmissionView {
round: number
state: string
digest: string
createdBy: string
createdAt: string
duties: { author: string; reviewers: string[]; approver: string }
reviews: Record<string, boolean>
sections: Record<string, number>
}
interface DutyCatalogDoc {
docId: string; typeKey: string; title: string; stage?: string; sections: number
assigned: boolean; author?: string; reviewers?: string[]; approver?: string; activeRound?: number
}
interface DocsTemplateRow {
templateId: string; version: string; title: string; docTypes: string[]; stage?: string
}
/** rdm.docs.instantiate 参数:docType 取首个适用类型;空标题交由后端取模板标题。 */
export function docsInstantiateParams(tpl: DocsTemplateRow, title: string): Record<string, unknown> {
return {
docType: tpl.docTypes[0], templateId: tpl.templateId, version: tpl.version,
...(title.trim() ? { title: title.trim() } : {}),
}
}
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<DutyCatalogDoc[] | null>(null)
const [members, setMembers] = useState<{ owner: string; role: string }[] | null>(null)
const [docId, setDocId] = useState('')
const [detail, setDetail] = useState<DutyGetOut | null | undefined>(undefined)
const [author, setAuthor] = useState<string[]>([])
const [reviewers, setReviewers] = useState<string[]>([])
const [approver, setApprover] = useState<string[]>([])
const [sub, setSub] = useState<DocSubmissionView | null | undefined>(undefined)
const [batchSel, setBatchSel] = useState<Set<string>>(new Set())
const [batchAuthor, setBatchAuthor] = useState<string[]>([])
const [batchReviewers, setBatchReviewers] = useState<string[]>([])
const [batchApprover, setBatchApprover] = useState<string[]>([])
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [tpls, setTpls] = useState<DocsTemplateRow[] | null>(null)
const [tplSel, setTplSel] = useState('')
const [newTitle, setNewTitle] = useState('')
const cmd = <T,>(id: string, params: Record<string, unknown>) => api.rdmProjectCommand<T>(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<DutyGetOut>('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([]) })
api.rdmCommand<DocsTemplateRow[]>('rdm.docs.templates')
.then(rows => { if (currentProject(epoch)) setTpls(rows ?? []) })
.catch(() => { if (currentProject(epoch)) setTpls([]) })
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<unknown>) => {
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] })) }))
/** U4/UI:按已发布 DHF 模板整份生成文档(章节骨架自动播种),随后选中新文档。 */
const instantiate = () => {
const tpl = (tpls ?? []).find(r => `${r.templateId}@${r.version}` === tplSel)
if (!tpl) return
const title = newTitle.trim()
return run(`已按模板生成「${title || tpl.title}」`, async () => {
const res = await cmd<{ docId: string }>('rdm.docs.instantiate', docsInstantiateParams(tpl, title))
await refreshCatalog()
await selectDoc(res.docId, true)
})
}
const tpl = (tpls ?? []).find(r => `${r.templateId}@${r.version}` === tplSel)
return (
<div>
<h4 className="settings-group-title">按模板生成文档</h4>
<div className="ad-create">
<div className="ad-row">
<select className="lg-input" aria-label="文档模板" value={tplSel}
onChange={e => { setTplSel(e.target.value); setNewTitle('') }}>
<option value="">{tpls === null ? '模板加载中…' : '选择模板…'}</option>
{(tpls ?? []).map(r => (
<option key={`${r.templateId}@${r.version}`} value={`${r.templateId}@${r.version}`}>
{r.title}({r.templateId}@{r.version})
</option>
))}
</select>
<input className="lg-input" aria-label="文档标题" placeholder={`文档标题(默认:${tpl?.title ?? '模板标题'})`}
value={newTitle} onChange={e => setNewTitle(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !tpl} onClick={instantiate}>生成文档</button>
</div>
<p className="ad-hint">按已发布 DHF 模板整份生成:章节骨架自动播种;生成后在下方清单选中文档分配三权并走整文审签。</p>
</div>
<h4 className="settings-group-title">文档清单(职责分配对象为整份文档;搜索/浏览同一目录)</h4>
<table className="ad-table">
<thead><tr><th></th><th>文档</th><th>类型 / 阶段</th><th>当前职责</th><th>状态</th><th></th></tr></thead>
<tbody>
{(docs ?? []).map(d => (
<tr key={d.docId} className={d.docId === docId ? 'ad-row-on' : ''}>
<td><input type="checkbox" checked={batchSel.has(d.docId)}
onChange={e => { const n = new Set(batchSel); e.target.checked ? n.add(d.docId) : n.delete(d.docId); setBatchSel(n) }} /></td>
<td><button className="ad-mini" onClick={() => selectDoc(d.docId)}>{d.title}</button>
<span className="ad-dim ad-mono"> · {d.docId}</span></td>
<td className="ad-dim">{d.typeKey}{d.stage ? ` / ${d.stage}` : ''}</td>
<td className="ad-dim">{d.assigned
? `编 ${d.author} · 审 ${(d.reviewers ?? []).join('、')} · 批 ${d.approver}` : '未分配'}</td>
<td>{d.activeRound ? <span className="ad-chip ad-chip-warn">第 {d.activeRound} 轮在途</span> : d.assigned ? <span className="ad-chip">—</span> : <span className="ad-chip">未分配</span>}</td>
<td className="ad-dim">{d.sections} 章节</td>
</tr>
))}
{docs === null && <tr><td colSpan={6} className="ad-dim">加载中…</td></tr>}
{docs !== null && docs.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无文档</td></tr>}
</tbody>
</table>
{detail === null && docId && <EmptyState icon="inbox">读取职责失败</EmptyState>}
{detail && (
<div className="settings-group" style={{ marginTop: 14 }}>
<h4>{detail.title} · 当前职责{detail.active ? <span className="ad-chip ad-chip-warn">在途第 {detail.active.round} 轮(变更将失效需重提交)</span> : null}</h4>
<div className="setting-row"><label>编制人(单选)</label>
<div style={{ maxWidth: 320 }}><Picker aria-label="编制人" selected={author} onSelect={setAuthor} fetchPage={memberPage} /></div></div>
<div className="setting-row"><label>审核人(多选 ≥1)</label>
<div style={{ maxWidth: 320 }}><Picker aria-label="审核人" multiple selected={reviewers} onSelect={setReviewers} fetchPage={memberPage} /></div>
{reviewers.length > 0 && <span className="ad-dim">已选:{reviewers.join('、')}</span>}</div>
<div className="setting-row"><label>批准人(单选)</label>
<div style={{ maxWidth: 320 }}><Picker aria-label="批准人" selected={approver} onSelect={setApprover} fetchPage={memberPage} /></div></div>
<div className="ad-row">
<Btn variant="primary" icon="check" disabled={saveDisabled || !dirty} onClick={save}>保存当前职责</Btn>
{detail.active && <span className="ad-hint">保存将使在途第 {detail.active.round} 轮失效(提交人 {detail.active.createdBy})</span>}
</div>
{detail.sections.length > 0 && (
<p className="ad-hint">章节覆盖:{detail.sections.filter(x => x.overridden).length}/{detail.sections.length} 章节单独指派(覆盖文档默认)。</p>
)}
</div>
)}
{detail && docId && (
<div className="ad-row" style={{ marginTop: 8 }}>
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !detail.assigned || sub === undefined}
onClick={() => run('整文已提交审核(全文锁定)',
() => cmd('rdm.review.doc.submit', docSubmitPayload(projectId, docId)))}>整文提交(编制人)</button>
</div>
)}
{sub && (
<div className="settings-group" style={{ marginTop: 14 }}>
<h4>整文审签 · 第 {sub.round} 轮 · {sub.state === 'submitted' ? <span className="ad-chip ad-chip-warn">在途(全文锁定)</span> : sub.state === 'final' ? <span className="ad-chip ad-chip-admin">已终批</span> : <span className="ad-chip">已驳回</span>}</h4>
<div className="setting-row"><label>冻结名单</label><span>编 <b className="ad-mono">{sub.duties.author}</b> · 审 <b className="ad-mono">{(sub.duties.reviewers ?? []).join('、')}</b> · 批 <b className="ad-mono">{sub.duties.approver}</b></span></div>
<div className="setting-row"><label>审核进度</label><span>{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}</span></div>
<div className="ad-row" style={{ marginTop: 8 }}>
<button className="ad-mini" disabled={busy || !!writeError || sub.state !== 'submitted'}
onClick={() => run('已通过整文审核',
() => cmd('rdm.review.doc.approve', docApprovePayload(projectId, docId, '通过')))}>审核通过</button>
<button className="ad-mini" disabled={busy || !!writeError || sub.state !== 'submitted'}
onClick={() => run('已驳回整文(解锁回编写态)',
() => cmd('rdm.review.doc.reject', docRejectPayload(projectId, docId, '退回修改')))}>驳回</button>
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || sub.state !== 'submitted'}
onClick={() => run('整文终批完成(各章节已基线)',
() => cmd('rdm.review.doc.final', docFinalPayload(projectId, docId)))}>终批(批准人)</button>
</div>
</div>
)}
{sub === null && docId && <p className="ad-hint">该文档尚无整文提交记录(当前职责仍可分配)。</p>}
{batchSel.size > 0 && (
<div className="settings-group" style={{ marginTop: 14 }}>
<h4>批量分配(已选 {batchSel.size} 份;任一校验失败整批拒绝并列原因)</h4>
<div className="setting-row"><label>统一编制人</label>
<div style={{ maxWidth: 320 }}><Picker aria-label="批量编制人" selected={batchAuthor} onSelect={setBatchAuthor} fetchPage={memberPage} /></div></div>
<div className="setting-row"><label>统一审核人</label>
<div style={{ maxWidth: 320 }}><Picker aria-label="批量审核人" multiple selected={batchReviewers} onSelect={setBatchReviewers} fetchPage={memberPage} /></div></div>
<div className="setting-row"><label>统一批准人</label>
<div style={{ maxWidth: 320 }}><Picker aria-label="批量批准人" selected={batchApprover} onSelect={setBatchApprover} fetchPage={memberPage} /></div></div>
<div className="ad-row">
<Btn variant="primary" icon="check" disabled={busy || !!writeError || !batchAuthor[0] || !batchApprover[0] || batchReviewers.length === 0}
onClick={batchApply}>批量分配 {batchSel.size} 份文档</Btn>
<button className="ad-mini" onClick={() => setBatchSel(new Set())}>清空选择</button>
</div>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖; // ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖;
// 与实际判定同源——UI 不重算,全部读服务端解释) ---- // 与实际判定同源——UI 不重算,全部读服务端解释) ----
+253
View File
@@ -0,0 +1,253 @@
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<ViewName>('member')
const [docs, setDocs] = useState<DutyDocDraft[]>([])
const [members, setMembers] = useState<DutyMember[]>([])
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<PreviewOut['docs']>([])
const [confirm, setConfirm] = useState(false)
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
const [legacyOpen, setLegacyOpen] = useState(false)
const seq = useRef(0)
const op = useRef(0)
const docsRef = useRef(docs)
docsRef.current = docs
const load = async (cursor: string, request = ++seq.current, replaceDirty = false) => {
const seenMembers: DutyMember[] = []
const seenCells: MatrixOut['cells'] = []
let pageDocs: MatrixDoc[] = []
let memberCursor = ''
let nextDocument = ''
let assignable = false
try {
do {
const page = await api.rdmProjectCommand<MatrixOut>(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, replaceDirty))
} 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([])
setConfirm(false)
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
const ticket = ++op.current
setBusy(true); setErr(''); setNotice('')
let sent = false
try {
const looked = await api.rdmProjectCommand<PreviewOut>(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
}
sent = true
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm).docs))
if (request !== seq.current) return
setNotice('已保存文档职责')
setPreview([])
setConfirm(false)
setDocs(current => current.map(doc => ({ ...doc, dirty: false })))
await load(docCursor, request)
} catch (error) {
if (request !== seq.current) return
if (!sent) {
setErr(String(error))
return
}
setErr(`保存结果未确认:${error}`)
setConfirm(false)
await load(docCursor, request, true)
} finally {
if (ticket === op.current) setBusy(false)
}
}
const scanLegacy = async (request = ++seq.current) => {
try {
const out = await api.rdmProjectCommand<LegacyOut>(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
const ticket = ++op.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 (ticket === op.current) setBusy(false)
}
}
const disabled = busy || !!writeError || !canAssign
const ambiguous = legacy.filter(item => item.kind !== 'identical')
return (
<div>
<p className="ad-hint">本页只分配已有文档的职责,不创建或生成文档。</p>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-row" role="tablist" aria-label="职责视图">
<button className="ad-mini" role="tab" aria-selected={view === 'member'} onClick={() => setView('member')}>按人员</button>
<button className="ad-mini" role="tab" aria-selected={view === 'document'} onClick={() => setView('document')}>按文档</button>
<button className="ad-mini" role="tab" aria-selected={view === 'matrix'} onClick={() => setView('matrix')}>矩阵</button>
</div>
<div className="ad-row">
<input aria-label="筛选文档名称或编号" value={query} placeholder="名称或编号" disabled={busy} onChange={event => { setDocCursor(''); setQuery(event.target.value) }} />
<input aria-label="筛选类型" value={typeKey} placeholder="类型" disabled={busy} onChange={event => { setDocCursor(''); setTypeKey(event.target.value) }} />
<input aria-label="筛选阶段" value={stage} placeholder="阶段" disabled={busy} onChange={event => { setDocCursor(''); setStage(event.target.value) }} />
<select aria-label="筛选当前职责" value={assignment} disabled={busy} onChange={event => { setDocCursor(''); setAssignment(event.target.value) }}>
<option value="">全部</option>
<option value="assigned">已分配</option>
<option value="unassigned">未分配</option>
</select>
</div>
{view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />}
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
<div className="ad-row">
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
{nextDoc && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor(nextDoc)}>下一页文档</button>}
<Btn variant="primary" icon="check" disabled={disabled || dirtyDocs(docs).length === 0} onClick={() => { void save() }}>保存职责</Btn>
</div>
<DutyChangePreview impacts={preview} confirm={confirm} onConfirm={setConfirm} />
<Collapsible id="duty-legacy" open={legacyOpen} onToggle={setLegacyOpen} trigger="旧章节职责">
<button className="ad-mini" disabled={busy} onClick={() => { void scanLegacy() }}>扫描</button>
<button className="ad-mini" disabled={disabled} onClick={() => { void applyIdentical() }}>归并无歧义覆盖</button>
{legacy.length === 0 ? <p className="ad-hint">尚未扫描,或没有章节级覆盖。</p> : (
<ul>
{legacy.map(item => <li key={`${item.docId}-${item.sectionTitle}`}>{item.title} / {item.sectionTitle}:{item.kind === 'identical' ? '与文档职责一致' : '有歧义,需逐文档确认'}</li>)}
</ul>
)}
{ambiguous.length > 0 && <p className="ad-hint">有歧义的文档不会自动覆盖。请在上面的视图里逐份确认后再保存。</p>}
</Collapsible>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{docs.length === 0 && !err && <EmptyState icon="inbox">暂无文档</EmptyState>}
</div>
)
}
+197
View File
@@ -0,0 +1,197 @@
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
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
}
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 ''
}
function dutyConflicts(roles: DutyRoles): number {
const seen = new Map<string, number>()
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
}
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 }[],
replaceDirty = false,
): 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 = !replaceDirty && 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 (!replaceDirty && 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,
}
}
+203
View File
@@ -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 (
<label className="duty-cell">
<select
aria-label={`${owner} 在 ${doc.title} 的职责`}
value={current}
disabled={disabled}
onChange={event => {
const value = event.target.value
if (value === 'none' || value === 'author' || value === 'reviewer' || value === 'approver') onChange(value)
}}
>
{DUTY_OPTIONS.map(option => {
const hint = replacementNote(doc, owner, option.value)
return <option key={option.value} value={option.value}>{hint ? `${option.label}(${hint})` : option.label}</option>
})}
</select>
</label>
)
}
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 (
<div>
<div className="setting-row">
<label htmlFor="duty-member">成员</label>
<select id="duty-member" aria-label="选择成员" value={member} onChange={event => onMember(event.target.value)}>
<option value="">选择成员</option>
{members.map(person => <option key={person.owner} value={person.owner}>{person.owner}</option>)}
</select>
</div>
{!member ? <p className="ad-hint">先选择成员,再为每份文档指定无职责、编制、审核或批准。</p> : (
<table className="ad-table">
<thead><tr><th>文档</th><th>类型 / 阶段</th><th>职责</th><th>完整性</th></tr></thead>
<tbody>
{docs.map(doc => {
const score = completeness(doc)
return (
<tr key={doc.docId}>
<td>{doc.title}<span className="ad-dim ad-mono"> · {doc.code}</span></td>
<td className="ad-dim">{doc.typeKey}{doc.stage ? ` / ${doc.stage}` : ''}</td>
<td><DutySelect doc={doc} owner={member} disabled={disabled} onChange={duty => onCell(doc.docId, member, duty)} /></td>
<td className="ad-dim">编 {score.authors}/1 · 审 {score.reviewers} · 批 {score.approvers}/1</td>
</tr>
)
})}
{docs.length === 0 && <tr><td colSpan={4} className="ad-dim">没有符合筛选的文档</td></tr>}
</tbody>
</table>
)}
</div>
)
}
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 (
<div>
<div className="setting-row">
<label htmlFor="duty-doc">文档</label>
<select id="duty-doc" aria-label="选择文档" value={docId} onChange={event => onDoc(event.target.value)}>
<option value="">选择整份文档</option>
{docs.map(item => <option key={item.docId} value={item.docId}>{item.title}</option>)}
</select>
</div>
{score && <p className="ad-hint">编制 {score.authors}/1、审核 {score.reviewers} 人、批准 {score.approvers}/1、冲突 {score.conflicts}{issues.length > 0 ? `。${issues.join(',')}` : ''}</p>}
{!doc ? <p className="ad-hint">先选择整份文档。这里不能选择章节,也没有生成入口。</p> : (
<table className="ad-table">
<thead><tr><th>成员</th><th>项目身份</th><th>职责</th></tr></thead>
<tbody>
{members.map(person => (
<tr key={person.owner}>
<td className="ad-mono">{person.owner}{person.valid ? '' : '(无效)'}</td>
<td className="ad-dim">{person.role === 'owner' ? '项目负责人' : '项目成员'}</td>
<td><DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} /></td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
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 <p className="ad-hint">当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。</p>
}
return (
<div className="duty-matrix-wrap">
<table className="ad-table duty-matrix">
<thead>
<tr>
<th className="duty-sticky">成员</th>
{docs.map(doc => {
const score = completeness(doc)
const ready = score.authors === 1 && score.reviewers >= 1 && score.approvers === 1 && score.conflicts === 0
return <th key={doc.docId}>{doc.title}<span className={ready ? 'ad-chip' : 'ad-chip ad-chip-warn'}>{ready ? '三权齐' : '未齐'}</span></th>
})}
</tr>
</thead>
<tbody>
{members.map(person => (
<tr key={person.owner}>
<td className="duty-sticky ad-mono">{person.owner}</td>
{docs.map(doc => (
<td key={doc.docId}>
<DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
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 (
<div className="settings-group">
<h4>保存前预览</h4>
<ul>
{impacts.map(item => (
<li key={item.docId}>
{item.title || item.docId}
{item.replacedAuthor ? `:编制将替换${item.replacedAuthor}` : ''}
{item.replacedApprover ? `:批准将替换${item.replacedApprover}` : ''}
{item.needsConfirm ? ';在途审签将失效,历史名单保持不变' : ''}
{item.reasons && item.reasons.length > 0 ? `;${item.reasons.join(',')}` : ''}
</li>
))}
</ul>
{needs && (
<label>
<input type="checkbox" checked={confirm} onChange={event => onConfirm(event.target.checked)} />
确认使受影响的在途审签失效
</label>
)}
</div>
)
}
+40 -11
View File
@@ -26,6 +26,7 @@ export function rdmProjectReadError(project: Project | undefined): string | null
const PROJECT_READ_COMMANDS = new Set([ const PROJECT_READ_COMMANDS = new Set([
'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix', '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',
]) ])
export function rdmProjectCommandError(project: Project, command: string): string | null { export function rdmProjectCommandError(project: Project, command: string): string | null {
@@ -54,14 +55,12 @@ export const RDM_PERM_MATRIX_COMMANDS = [
'rdm.project.member.list', 'rdm.project.member.list',
] as const ] 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.assign.doc',
'rdm.review.doc.submit', 'rdm.review.assign.doc.batch',
'rdm.review.doc.approve',
'rdm.review.doc.reject',
'rdm.review.doc.final',
'rdm.review.doc.status',
] as const ] as const
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
@@ -79,9 +78,9 @@ export const rdmRolesReady = (cmds: CommandListItem[] | null | undefined) =>
export const rdmMembersReady = (cmds: CommandListItem[] | null | undefined) => export const rdmMembersReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_MEMBER_COMMANDS) ready(cmds, RDM_MEMBER_COMMANDS)
/** 文档职责/整文审签页是否就绪。 */ /** 文档职责页是否就绪。命令未齐则 fail-closed。 */
export const rdmDocReviewReady = (cmds: CommandListItem[] | null | undefined) => export const rdmDutyReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_DOC_REVIEW_COMMANDS) ready(cmds, RDM_DUTY_COMMANDS)
/** 项目权限矩阵页是否就绪。 */ /** 项目权限矩阵页是否就绪。 */
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) => export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
@@ -109,7 +108,37 @@ export const memberSetRolePayload = (projectId: string, member: string, role: 'o
export const assignDocDutiesPayload = ( export const assignDocDutiesPayload = (
projectId: string, docId: string, author: string, reviewers: string[], approver: string, 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 docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId })
export const docApprovePayload = (projectId: string, docId: string, note?: string) => export const docApprovePayload = (projectId: string, docId: string, note?: string) =>
+2 -2
View File
@@ -12,7 +12,7 @@ import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme' import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey' import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
import { rdmAuditReady, rdmExportReady } from './workspaces' 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 type FontSizeChoice = 13 | 14 | 16 | 18
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18] const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
@@ -1200,7 +1200,7 @@ export const store = {
rdmExportAvailable: rdmExportReady(cmds), rdmExportAvailable: rdmExportReady(cmds),
rdmRolesAvailable: rdmRolesReady(cmds), rdmRolesAvailable: rdmRolesReady(cmds),
rdmMembersAvailable: rdmMembersReady(cmds), rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDocReviewReady(cmds), rdmDocReviewAvailable: rdmDutyReady(cmds),
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds), rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
}) })
} catch { } catch {
+4
View File
@@ -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 { display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); } .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; } .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); }
-21
View File
@@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest'
import { docsInstantiateParams } from '../src/components/adminRdmSections'
const tpl = {
templateId: 'dhf.2750fbaf1f67d10c', version: '1.0.0', title: '项目建议书',
docTypes: ['rd_proposal'], stage: '0. 商务阶段',
}
describe('docsInstantiateParams', () => {
it('carries docType/templateId/version and trims the title', () => {
expect(docsInstantiateParams(tpl, ' U5 试点项目建议书 ')).toEqual({
docType: 'rd_proposal', templateId: 'dhf.2750fbaf1f67d10c',
version: '1.0.0', title: 'U5 试点项目建议书',
})
})
it('omits blank titles so the backend falls back to the template title', () => {
const p = docsInstantiateParams(tpl, ' ')
expect(Object.hasOwn(p, 'title')).toBe(false)
expect(p).toEqual({ docType: 'rd_proposal', templateId: 'dhf.2750fbaf1f67d10c', version: '1.0.0' })
})
})
+73
View File
@@ -0,0 +1,73 @@
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> = {}): 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('写入结果未知时采用服务端版本,不再留着旧草稿重放', () => {
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' },
], true)
expect(absorbed[0]).toMatchObject({ author: 'alice', dutyVersion: 4, dirty: false })
})
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,
})
})
})
+7 -2
View File
@@ -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 => { 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 historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
const commands = ['rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix', 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' }) 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([ 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' } }, { 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.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.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.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 => { 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 }) 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', 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() await expect(api.rdmProjectCommand(historical, command)).rejects.toThrow()
} }
expect(requests).toEqual([]) expect(requests).toEqual([])
+8 -5
View File
@@ -3,8 +3,8 @@
//(未登录/请求失败/null)一律视为未就绪(fail-closed),普通基座不出现 RDM 页面。 //(未登录/请求失败/null)一律视为未就绪(fail-closed),普通基座不出现 RDM 页面。
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DOC_REVIEW_COMMANDS, ROLE_ACTIONS, RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DUTY_COMMANDS, ROLE_ACTIONS,
rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmRolesReady, rdmMembersReady, rdmDutyReady, dutyMatrixPayload, dutyPreviewPayload,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload, roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload, assignDocDutiesPayload, memberAddPayload, memberRemovePayload, assignDocDutiesPayload,
docSubmitPayload, docApprovePayload, docRejectPayload, docFinalPayload, docStatusPayload, docSubmitPayload, docApprovePayload, docRejectPayload, docFinalPayload, docStatusPayload,
@@ -19,19 +19,19 @@ describe('RDM 页面能力门控', () => {
it('命令齐备且 enabled 才就绪', () => { it('命令齐备且 enabled 才就绪', () => {
expect(rdmRolesReady(all(RDM_ROLES_COMMANDS))).toBe(true) expect(rdmRolesReady(all(RDM_ROLES_COMMANDS))).toBe(true)
expect(rdmMembersReady(all(RDM_MEMBER_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('缺一命令即未就绪', () => { it('缺一命令即未就绪', () => {
const drop = (ids: readonly string[], skip: string) => ids.filter(i => i !== skip).map(id => cmd(id)) 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(rdmRolesReady(drop(RDM_ROLES_COMMANDS, 'rdm.role.list'))).toBe(false)
expect(rdmMembersReady(drop(RDM_MEMBER_COMMANDS, 'rdm.project.member.add'))).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)', () => { it('disabled / null / 非数组 / 请求失败均未就绪(fail-closed)', () => {
expect(rdmRolesReady(RDM_ROLES_COMMANDS.map(id => cmd(id, false)))).toBe(false) expect(rdmRolesReady(RDM_ROLES_COMMANDS.map(id => cmd(id, false)))).toBe(false)
expect(rdmRolesReady(null)).toBe(false) expect(rdmRolesReady(null)).toBe(false)
expect(rdmMembersReady(undefined)).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')).toEqual({ projectId: 'p1', docId: 'd1' })
expect(docFinalPayload('p1', 'd1', 3)).toEqual({ projectId: 'p1', docId: 'd1', round: 3 }) expect(docFinalPayload('p1', 'd1', 3)).toEqual({ projectId: 'p1', docId: 'd1', round: 3 })
expect(docStatusPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' }) 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 解析:去空白、去空项、保序去重', () => { it('审阅人 CSV 解析:去空白、去空项、保序去重', () => {
expect(parseReviewers('bob, carol,,bob')).toEqual(['bob', 'carol']) expect(parseReviewers('bob, carol,,bob')).toEqual(['bob', 'carol'])