Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
781cde6667 | ||
|
|
e33d17e1a6 | ||
|
|
5c0e9f2711 | ||
|
|
04269eed25 | ||
|
|
985fc0f931 | ||
|
|
4835c45b8d | ||
|
|
6093beb27e | ||
|
|
c2ff4fcc8e | ||
|
|
03db4b81be | ||
|
|
06249dd83f | ||
|
|
44205e1262 | ||
|
|
1b7ba779f1 |
+56
-115
@@ -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}:${docId}`, pending)
|
holds.set(command, 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([])
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ test('keeps optional choices nonblocking and supports defer, reopen, free input
|
|||||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||||
await expect.poll(() => state.sent?.text).toBe('先聊一下本周的安排')
|
await expect.poll(() => state.sent?.text).toBe('先聊一下本周的安排')
|
||||||
expect(state.sent?.taskRef).toBeUndefined()
|
expect(state.sent?.taskRef).toBeUndefined()
|
||||||
|
// Receiving the POST is earlier than rendering its legitimate foreground reply.
|
||||||
|
await expect(page.locator('.msg.assistant[data-msg-id="live:run1"]')).toBeVisible()
|
||||||
const assistantCount = await page.locator('.msg.assistant').count()
|
const assistantCount = await page.locator('.msg.assistant').count()
|
||||||
await inline.getByRole('button', { name: '继续选择', exact: true }).click()
|
await inline.getByRole('button', { name: '继续选择', exact: true }).click()
|
||||||
await inline.getByRole('checkbox', { name: '生成摘要', exact: true }).check()
|
await inline.getByRole('checkbox', { name: '生成摘要', exact: true }).check()
|
||||||
|
|||||||
+21
@@ -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)),
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 类对新任务生效)'}
|
||||||
|
|||||||
@@ -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 不重算,全部读服务端解释) ----
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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([])
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
Reference in New Issue
Block a user