Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f366ecc8d | ||
|
|
a5becea652 | ||
|
|
0a052f445b | ||
|
|
04a25f5fad | ||
|
|
09c6ac41e0 | ||
|
|
5313cfad4e | ||
|
|
4e1eb39035 | ||
|
|
53ceadf51e | ||
|
|
6106db250c | ||
|
|
2bc1e51456 | ||
|
|
d9bb780384 |
@@ -123,4 +123,24 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
|
||||
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
|
||||
)
|
||||
})
|
||||
|
||||
test('概览导出:生成后显示水位与校验和,并可下载', async ({ page }) => {
|
||||
await login(page)
|
||||
await openAudit(page)
|
||||
const exportBox = page.locator('.rdm-audit-export')
|
||||
await expect(exportBox).toBeVisible()
|
||||
await exportBox.getByLabel('导出类型').selectOption('cgraph-docx')
|
||||
await exportBox.getByRole('button', { name: '生成', exact: true }).click()
|
||||
const draft = exportBox.getByRole('button', { name: '生成草稿' })
|
||||
if (await draft.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true).catch(() => false)) {
|
||||
await draft.click()
|
||||
}
|
||||
await expect(exportBox.getByText(/水位 \d+/)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(exportBox.getByText(/sha256 [0-9a-f]{64}/)).toBeVisible()
|
||||
await expect(exportBox.getByText(/有效期/)).toBeVisible()
|
||||
const download = page.waitForEvent('download')
|
||||
await exportBox.getByRole('button', { name: '下载' }).click()
|
||||
const file = await download
|
||||
expect(file.suggestedFilename()).toMatch(/\.docx$/)
|
||||
})
|
||||
})
|
||||
|
||||
+128
-16
@@ -9,25 +9,32 @@ function deferred() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function dutiesPage(page: Page) {
|
||||
async function dutiesPage(page: Page, options: { canAssign?: boolean; missingVersion?: boolean } = {}) {
|
||||
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 active = new Set<string>()
|
||||
const submissions: Record<string, unknown> = { 'doc-A': null, 'doc-B': null }
|
||||
const canAssign = options.canAssign !== false
|
||||
const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove',
|
||||
'rdm.duty.catalog', 'rdm.duty.get', 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch',
|
||||
'rdm.project.member.set-role', 'rdm.project.member.impact', 'rdm.account.list', 'rdm.role.options',
|
||||
'rdm.duty.catalog', 'rdm.duty.tree', 'rdm.duty.get', 'rdm.review.assign', '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' },
|
||||
{ docId: 'doc-A', title: '文档A', typeKey: 'design', typeLabel: '设计方案', sections: 1, dutyVersion: 3, assigned: true, author: 'alice', reviewers: ['carol'], approver: 'dave' },
|
||||
{ docId: 'doc-B', title: '文档B', typeKey: 'design', typeLabel: '设计方案', sections: 1, dutyVersion: 7, assigned: true, author: 'bob', reviewers: ['carol'], approver: 'dave' },
|
||||
]
|
||||
const members = ['alice', 'bob', 'carol', 'dave'].map(owner => ({ owner, role: owner === 'alice' ? 'owner' : 'member', addedBy: '', addedAt: '', valid: true,
|
||||
profRole: { roleId: owner === 'bob' ? 'systems' : 'software', name: owner === 'bob' ? '系统工程师' : '软件工程师' },
|
||||
dutyEligibility: { author: { allowed: true }, reviewer: { allowed: true }, approver: { allowed: true } } }))
|
||||
const filteredDocs = (params: Record<string, unknown>) => docs.filter(document => !params.query || (params.match === 'exact'
|
||||
? document.docId === params.query || document.title === params.query : `${document.docId} ${document.title}`.includes(String(params.query))))
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: true }
|
||||
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: false }
|
||||
else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '',
|
||||
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
|
||||
else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} }
|
||||
@@ -40,15 +47,43 @@ async function dutiesPage(page: Page) {
|
||||
const hold = holds.get(`${command}:${params.docId ?? ''}`)
|
||||
if (hold) { hold.started.resolve(); await hold.release.promise }
|
||||
let payload: unknown = {}
|
||||
if (command === 'rdm.duty.catalog') payload = { docs }
|
||||
else if (command === 'rdm.project.member.list') payload = { members: ['alice', 'bob', 'carol', 'dave'].map(owner => ({ owner, role: 'member', addedBy: '', addedAt: '' })) }
|
||||
if (command === 'rdm.duty.catalog') payload = { docs: filteredDocs(params), next: '', count: filteredDocs(params).length, total: docs.length, canAssignDuties: canAssign,
|
||||
filters: { stages: [], typeKeys: [{ value: 'design', label: '设计方案' }], assignments: [{ value: 'assigned', label: '已分配' }, { value: 'unassigned', label: '未分配' }], statuses: [] } }
|
||||
else if (command === 'rdm.duty.tree') payload = { nodes: filteredDocs(params).map(document => ({ id: document.docId, kind: 'doc', label: document.title, selectable: true, docId: document.docId,
|
||||
children: [{ id: document.docId + '-section', kind: 'section', label: '方案章节', selectable: false, children: [] }] })), next: '', canAssignDuties: canAssign }
|
||||
else if (command === 'rdm.role.options') payload = { roles: [{ roleId: 'systems', name: '系统工程师' }, { roleId: 'software', name: '软件工程师' }] }
|
||||
else if (command === 'rdm.project.member.list') payload = { members: members.filter(person => (!params.query || person.owner.includes(String(params.query))) && (!params.roleId || person.profRole.roleId === params.roleId)),
|
||||
next: '', accountDirectoryAvailable: true, canManageMembers: true, canAssignDuties: canAssign, canConfigurePermissions: false }
|
||||
else if (command === 'rdm.duty.get') {
|
||||
const doc = docs.find(d => d.docId === params.docId)!
|
||||
payload = { docId: doc.docId, title: doc.title, assigned: doc.assigned,
|
||||
roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [],
|
||||
active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice' } : null }
|
||||
roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [], canAssignDuties: canAssign,
|
||||
...(options.missingVersion ? {} : { dutyVersion: doc.dutyVersion }),
|
||||
active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice', duties: { Author: doc.author, Reviewers: doc.reviewers, Approver: doc.approver } } : null }
|
||||
} else if (command === 'rdm.review.doc.status') payload = { submission: submissions[String(params.docId)] }
|
||||
else { writes.push({ command, params }) }
|
||||
else {
|
||||
writes.push({ command, params })
|
||||
if (command === 'rdm.review.doc.submit') {
|
||||
const document = docs.find(document => document.docId === params.docId)!
|
||||
active.add(document.docId)
|
||||
submissions[document.docId] = { round: 1, state: 'submitted', createdBy: 'alice',
|
||||
duties: { Author: document.author, Reviewers: document.reviewers, Approver: document.approver }, reviews: {} }
|
||||
}
|
||||
if (command === 'rdm.review.assign.doc' || command === 'rdm.review.assign.doc.batch') {
|
||||
const entries = command === 'rdm.review.assign.doc' ? [params] : params.docs as Record<string, unknown>[]
|
||||
for (const entry of entries) {
|
||||
const document = docs.find(document => document.docId === entry.docId)!
|
||||
expect(entry.expectedDutyVersion).toBe(document.dutyVersion)
|
||||
const changed = entry.author !== document.author || entry.approver !== document.approver
|
||||
|| JSON.stringify(entry.reviewers) !== JSON.stringify(document.reviewers)
|
||||
if (changed && active.has(document.docId)) expect(entry.confirmInvalidation || params.confirmInvalidation).toBe(true)
|
||||
if (changed) {
|
||||
document.author = String(entry.author); document.reviewers = entry.reviewers as string[]; document.approver = String(entry.approver); document.dutyVersion++
|
||||
active.delete(document.docId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
body = { payload }
|
||||
} else if (path.endsWith('/cards')) body = { cards: [] }
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
@@ -80,11 +115,13 @@ async function settleResponse(page: Page, command: string, release: () => void)
|
||||
test('a newly assigned document can submit its first whole-document review', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await expect(page.getByText('该文档尚无整文提交记录(当前职责仍可分配)。', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('尚无整文提交记录', { exact: true })).toBeVisible()
|
||||
const submit = page.getByRole('button', { name: '整文提交(编制人)', exact: true })
|
||||
await expect(submit).toBeEnabled()
|
||||
await submit.click()
|
||||
await expect(page.getByText('整文已提交审核(全文锁定)', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('整文已提交审核', { exact: true })).toBeVisible()
|
||||
await expect(submit).toBeDisabled()
|
||||
await expect(page.getByRole('heading', { name: '整文审签 · 第 1 轮 · 审核中', exact: true })).toBeVisible()
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.review.doc.submit', params: { projectId: 'domain-7', docId: 'doc-A' } }])
|
||||
})
|
||||
|
||||
@@ -107,10 +144,10 @@ test('viewing active duties is passive; only a dirty save asks for confirmation'
|
||||
expect(fixture.writes).toEqual([])
|
||||
accept = true
|
||||
await save.click()
|
||||
await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible()
|
||||
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',
|
||||
projectId: 'domain-7', docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave', expectedDutyVersion: 3, confirmInvalidation: true,
|
||||
} }])
|
||||
})
|
||||
|
||||
@@ -126,12 +163,87 @@ test('a late document detail cannot overwrite another selection or its saved rol
|
||||
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()
|
||||
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',
|
||||
projectId: 'domain-7', docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'alice', expectedDutyVersion: 7, confirmInvalidation: false,
|
||||
} }])
|
||||
})
|
||||
|
||||
test('document ID and title searches share list and tree selection without making chapters selectable', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
await page.getByLabel('文档匹配方式', { exact: true }).selectOption('exact')
|
||||
await page.getByRole('textbox', { name: '搜索文档', exact: true }).fill('doc-B')
|
||||
await expect(page.getByRole('button', { name: '文档A', exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: '文档B', exact: true })).toBeVisible()
|
||||
await page.getByLabel('文档浏览方式', { exact: true }).selectOption('tree')
|
||||
await page.getByRole('button', { name: '文档B', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '展开 文档B', exact: true }).click()
|
||||
await expect(page.getByText('方案章节 · 章节', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '方案章节', exact: true })).toHaveCount(0)
|
||||
expect(fixture.writes).toEqual([])
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.duty.tree' && call.params.query === 'doc-B' && call.params.match === 'exact')).toBe(true)
|
||||
})
|
||||
|
||||
test('professional role filtering keeps duty candidates within project membership and selected labels stay visible', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await page.getByLabel('专业角色筛选', { exact: true }).selectOption('systems')
|
||||
await page.locator('.picker[aria-label="编制人"]').getByRole('combobox').focus()
|
||||
await expect(page.getByRole('option', { name: /^bob/ })).toBeVisible()
|
||||
await expect(page.getByRole('option', { name: /^carol/ })).toHaveCount(0)
|
||||
await page.getByRole('option', { name: /^bob/ }).click()
|
||||
await expect(page.locator('.picker[aria-label="编制人"] .picker-selected')).toContainText('bob')
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.project.member.list' && call.params.roleId === 'systems')).toBe(true)
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.account.list')).toBe(false)
|
||||
})
|
||||
|
||||
test('missing version or caller assignment capability keeps duty editors read only', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page, { canAssign: false, missingVersion: true })
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '保存当前职责', exact: true })).toBeDisabled()
|
||||
await expect(page.locator('.picker[aria-label="编制人"]').getByRole('combobox')).toBeDisabled()
|
||||
await expect(page.getByText('职责版本尚未就绪,只能查看;请升级项目服务后重新读取', { exact: true })).toBeVisible()
|
||||
expect(fixture.writes).toEqual([])
|
||||
})
|
||||
|
||||
test('batch preview carries each document version and explicitly confirms active review invalidation', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
fixture.active.add('doc-A')
|
||||
const prompts: string[] = []
|
||||
page.on('dialog', async dialog => { prompts.push(dialog.message()); await dialog.accept() })
|
||||
await page.getByRole('checkbox', { name: '选择 文档A', exact: true }).check()
|
||||
await page.getByRole('checkbox', { name: '选择 文档B', exact: true }).check()
|
||||
await chooseMember(page, '批量编制人', 'bob')
|
||||
await chooseMember(page, '批量审核人', 'carol')
|
||||
await chooseMember(page, '批量批准人', 'dave')
|
||||
await page.getByRole('button', { name: '预览并分配 2 份文档', exact: true }).click()
|
||||
await expect(page.getByText('批量职责已保存,已重新读取目录', { exact: true })).toBeVisible()
|
||||
expect(prompts).toHaveLength(1)
|
||||
expect(prompts[0]).toContain('第 1 轮将失效')
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc.batch', params: { projectId: 'domain-7', confirmInvalidation: true,
|
||||
docs: [
|
||||
{ docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave', expectedDutyVersion: 3, confirmInvalidation: true },
|
||||
{ docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'dave', expectedDutyVersion: 7, confirmInvalidation: true },
|
||||
] } }])
|
||||
})
|
||||
|
||||
test('a batch keeping existing duties does not claim its active review will be invalidated', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
fixture.active.add('doc-A')
|
||||
const prompts: string[] = []
|
||||
page.on('dialog', async dialog => { prompts.push(dialog.message()); await dialog.accept() })
|
||||
await page.getByRole('checkbox', { name: '选择 文档A', exact: true }).check()
|
||||
await chooseMember(page, '批量编制人', 'alice')
|
||||
await chooseMember(page, '批量审核人', 'carol')
|
||||
await chooseMember(page, '批量批准人', 'dave')
|
||||
await page.getByRole('button', { name: '预览并分配 1 份文档', exact: true }).click()
|
||||
await expect(page.getByText('批量职责已保存,已重新读取目录', { exact: true })).toBeVisible()
|
||||
expect(prompts[0]).not.toContain('失效')
|
||||
expect(fixture.writes[0].params.confirmInvalidation).toBe(false)
|
||||
expect(fixture.active.has('doc-A')).toBe(true)
|
||||
})
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Isolated browser component regression: actual React workspace, deliberately deferred API responses.
|
||||
// This checks client request ownership, not server authorization or business acceptance.
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_COMPONENT_BASE_URL, 'requires the isolated Vite component test server')
|
||||
test.use({ baseURL: process.env.PLAYWRIGHT_COMPONENT_BASE_URL })
|
||||
|
||||
async function mount(page: Page, delayed: string) {
|
||||
const index = await page.request.get('/')
|
||||
const html = (await index.text()).replace(/<script[^>]+src="\/src\/main\.tsx[^" ]*"[^>]*><\/script>/, '')
|
||||
// Let Vite prepare ReactDOM before requesting its optimized module directly.
|
||||
await page.request.get('/src/main.tsx')
|
||||
await page.route('**/__export_test', route => route.fulfill({ contentType: 'text/html',
|
||||
body: html }))
|
||||
await page.goto('/__export_test')
|
||||
await page.waitForFunction(() => typeof (window as any).$RefreshReg$ === 'function')
|
||||
await page.evaluate(async delayed => {
|
||||
const load = (path: string) => import(/* @vite-ignore */ path)
|
||||
const { default: React } = await load('/node_modules/.vite/deps/react.js')
|
||||
const { default: ReactDOM } = await load('/node_modules/.vite/deps/react-dom_client.js')
|
||||
const { RdmAuditWorkspace } = await load('/src/components/RdmAuditWorkspace.tsx')
|
||||
const { api } = await load('/src/api.ts')
|
||||
const { auth } = await load('/src/auth.ts')
|
||||
const { store } = await load('/src/store.ts')
|
||||
const { RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } = await load('/src/workspaces.ts')
|
||||
const calls: { command: string; params: any }[] = []
|
||||
let actor = 'alice'
|
||||
const pending: (() => void)[] = []
|
||||
let downloads = 0
|
||||
URL.createObjectURL = () => { downloads++; return 'blob:export-test' }
|
||||
URL.revokeObjectURL = () => {}
|
||||
HTMLAnchorElement.prototype.click = () => {}
|
||||
api.listPlugins = async () => []
|
||||
api.listWorkspaces = async () => []
|
||||
api.listCommands = async () => [...RDM_AUDIT_COMMANDS, ...RDM_EXPORT_COMMANDS].map(id => ({ id, enabled: true }))
|
||||
api.adminZoneSections = async () => []
|
||||
api.getSettings = async () => ({ user: {} })
|
||||
api.rdmCommand = async (command: string, params: any = {}) => {
|
||||
calls.push({ command, params })
|
||||
const result = command === 'rdm.inspect.capabilities'
|
||||
? { actor, crossProject: false, projects: ['A', 'B'].map(projectId => ({ projectId, name: actor + projectId, canAudit: true })) }
|
||||
: command === 'rdm.inspect.summary'
|
||||
? { projectId: params.projectId, counts: {}, watermark: 7 }
|
||||
: command === 'rdm.inspect.export.create'
|
||||
? { exportId: params.projectId + '-report', watermark: 7, sha256: 'abc', fileName: 'report.docx' }
|
||||
: command === 'rdm.inspect.export.status'
|
||||
? { expiresAt: '2099-01-01', watermark: 7, sha256: 'abc' }
|
||||
: { fileName: 'report.docx', mime: 'application/octet-stream', dataB64: 'QQ==', sha256: 'abc' }
|
||||
if (command === delayed && params.projectId === 'A') {
|
||||
await new Promise<void>(resolve => pending.push(resolve))
|
||||
}
|
||||
return result
|
||||
}
|
||||
await store.refreshPlugins()
|
||||
const root = ReactDOM.createRoot(document.getElementById('root')!)
|
||||
root.render(React.createElement(RdmAuditWorkspace))
|
||||
;(window as any).exportTest = { calls, release: () => pending.splice(0).forEach(resolve => resolve()),
|
||||
downloads: () => downloads, identity: async () => { actor = 'bob'; auth.beginSession(); await store.refreshPlugins() },
|
||||
unmount: () => root.unmount() }
|
||||
}, delayed)
|
||||
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||
}
|
||||
async function release(page: Page) {
|
||||
await page.evaluate(async () => { (window as any).exportTest.release(); await new Promise(resolve => setTimeout(resolve, 0)) })
|
||||
}
|
||||
const generate = (page: Page) => page.getByRole('button', { name: '生成', exact: true }).click()
|
||||
|
||||
for (const stage of ['create', 'status']) {
|
||||
test(`ignores old ${stage} response after project A -> B -> A`, async ({ page }) => {
|
||||
await mount(page, 'rdm.inspect.export.' + stage)
|
||||
await generate(page)
|
||||
await expect.poll(() => page.evaluate(stage => (window as any).exportTest.calls
|
||||
.some((c: any) => c.command === 'rdm.inspect.export.' + stage), stage)).toBe(true)
|
||||
await page.getByLabel('选择项目').selectOption('B')
|
||||
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||
await page.getByLabel('选择项目').selectOption('A')
|
||||
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||
await release(page)
|
||||
await expect(page.getByRole('button', { name: '下载', exact: true })).toHaveCount(0)
|
||||
if (stage === 'create') expect(await page.evaluate(() => (window as any).exportTest.calls
|
||||
.filter((c: any) => c.command === 'rdm.inspect.export.status').length)).toBe(0)
|
||||
await expect(page.getByRole('button', { name: '生成', exact: true })).toBeEnabled()
|
||||
})
|
||||
}
|
||||
for (const change of ['project', 'identity', 'unmount']) {
|
||||
test(`suppresses delayed download after ${change} change`, async ({ page }) => {
|
||||
await mount(page, 'rdm.inspect.export.download')
|
||||
await generate(page)
|
||||
await page.getByRole('button', { name: '下载', exact: true }).click()
|
||||
if (change === 'project') {
|
||||
await page.getByLabel('选择项目').selectOption('B')
|
||||
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||
} else if (change === 'identity') {
|
||||
await page.evaluate(() => (window as any).exportTest.identity())
|
||||
await expect(page.getByLabel('选择项目').locator('option').first()).toHaveText('bobA')
|
||||
} else await page.evaluate(() => (window as any).exportTest.unmount())
|
||||
await release(page)
|
||||
expect(await page.evaluate(() => (window as any).exportTest.downloads())).toBe(0)
|
||||
})
|
||||
}
|
||||
test('current identity can generate and download normally', async ({ page }) => {
|
||||
await mount(page, '')
|
||||
await generate(page)
|
||||
await page.getByRole('button', { name: '下载', exact: true }).click()
|
||||
await expect.poll(() => page.evaluate(() => (window as any).exportTest.downloads())).toBe(1)
|
||||
await expect(page.locator('.rdm-audit-export-result')).toContainText('水位 7')
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
// Deterministic HTTP contract QA of the real React settings surfaces. Live domain QA is separate.
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
|
||||
|
||||
async function settingsPage(page: Page, canManage = true) {
|
||||
const calls: { command: string; params: Record<string, unknown> }[] = []
|
||||
const writes: { command: string; params: Record<string, unknown> }[] = []
|
||||
const settingWrites: { path: string; body: unknown }[] = []
|
||||
const people = [
|
||||
{ owner: 'alice', name: '项目负责人', valid: true, joined: true, role: 'owner', projectRole: 'owner', profRole: { roleId: 'systems', name: '系统工程师' } },
|
||||
{ owner: 'bob', name: '李工', valid: true, joined: false, role: 'member', projectRole: 'member', profRole: { roleId: 'systems', name: '系统工程师' } },
|
||||
{ owner: 'carol', name: '陈工', valid: true, joined: true, role: 'member', projectRole: 'member', profRole: { roleId: 'software', name: '软件工程师' } },
|
||||
{ owner: 'unknown', name: '待核验', joined: false, profRole: null },
|
||||
]
|
||||
const schema = [
|
||||
{ key: 'deliverables.style', group: 'deliverables', label: '项目交付风格', type: 'enum', options: ['modern', 'formal', 'minimal'], default: 'modern', effect: 'new-run', scope: 'project' },
|
||||
{ key: 'models.chat.model', group: 'models', label: '主模型', type: 'string', effect: 'new-run', scope: 'system' },
|
||||
]
|
||||
let projectRevision = 4
|
||||
let projectValues: Record<string, unknown> = { deliverables: { style: 'formal' } }
|
||||
const overrides = new Map<string, boolean | null>([['alice:doc.read', false], ['alice:doc.write', true]])
|
||||
const permissions = (member: string) => [
|
||||
{ action: 'project.create', label: '创建项目', scope: 'project', allowed: true, defaultAllowed: true, capAllowed: true, override: null, source: 'default' },
|
||||
{ action: 'doc.read', label: '读取文档', scope: 'project', allowed: overrides.get(member + ':doc.read') ?? true,
|
||||
defaultAllowed: true, capAllowed: true, override: overrides.get(member + ':doc.read') ?? null, source: overrides.has(member + ':doc.read') ? 'override' : 'prof-role-default' },
|
||||
{ action: 'doc.write', label: '编制与编辑文档', scope: 'project', allowed: false, defaultAllowed: true, capAllowed: false, override: overrides.get(member + ':doc.write') ?? null, source: 'cap', reason: '管理员上限禁止' },
|
||||
]
|
||||
const matches = (person: typeof people[number], params: Record<string, unknown>) => {
|
||||
const query = String(params.query || '')
|
||||
return (!params.roleId || person.profRole?.roleId === params.roleId) && (!params.projectRole || person.projectRole === params.projectRole)
|
||||
&& (!query || (params.match === 'exact' ? person.owner === query || person.name === query : `${person.owner} ${person.name}`.includes(query)))
|
||||
}
|
||||
const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.set-role', 'rdm.project.member.impact',
|
||||
'rdm.account.list', 'rdm.role.options', 'rdm.perm.check', 'rdm.perm.matrix', 'rdm.perm.set', 'rdm.perm.unset']
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const path = url.pathname
|
||||
let body: unknown = []
|
||||
if (path === '/api/me') body = { owner: 'alice', name: '项目负责人', auth: true, isAdmin: false }
|
||||
else if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/projects') body = [{ id: 'host-1', name: '项目配置验收', createdAt: '', updatedAt: '', settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
|
||||
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
|
||||
else if (path === '/api/settings') body = url.searchParams.has('project') ? { schema, defaults: {}, user: {}, project: projectValues, projectRevision, projectCanConfigure: canManage,
|
||||
personalSchema: [{ key: 'crossSession.read', group: 'crossSession', label: '跨会话读取', type: 'boolean', default: false, effect: 'new-run', scope: 'project-personal' }], personalDefaults: {}, personalUser: {}, personalProject: {} }
|
||||
: { schema: [{ key: 'ui.instructions', group: 'ui', label: '个人偏好', type: 'string', effect: 'new-run', scope: 'user' }], defaults: {}, user: {}, project: {} }
|
||||
else if (path.startsWith('/api/settings/project/') && request.method() === 'PUT') {
|
||||
const patch = request.postDataJSON()
|
||||
settingWrites.push({ path, body: patch })
|
||||
expect(patch.expectedRevision).toBe(projectRevision)
|
||||
if (patch.values.deliverables) projectValues = patch.values
|
||||
if (patch.unset?.includes('deliverables.style')) projectValues = {}
|
||||
projectRevision++
|
||||
body = { ok: true, projectRevision }
|
||||
} else if (path.startsWith('/api/commands/')) {
|
||||
const command = path.substring('/api/commands/'.length)
|
||||
const params = request.postDataJSON().params as Record<string, unknown>
|
||||
expect(params.projectId).toBe('domain-7')
|
||||
expect(params).not.toHaveProperty('actor')
|
||||
calls.push({ command, params })
|
||||
let payload: unknown = {}
|
||||
if (command === 'rdm.role.options') payload = { roles: [{ roleId: 'systems', name: '系统工程师' }, { roleId: 'software', name: '软件工程师' }] }
|
||||
else if (command === 'rdm.account.list') payload = { accounts: people.filter(person => matches(person, params)), next: '' }
|
||||
else if (command === 'rdm.project.member.list') payload = { members: people.filter(person => person.joined && matches(person, params)), next: '',
|
||||
accountDirectoryAvailable: true, canManageMembers: canManage, canAssignDuties: false, canConfigurePermissions: canManage }
|
||||
else if (command === 'rdm.project.member.impact') payload = { member: params.member, canRemove: params.member !== 'carol', lastOwner: false,
|
||||
documents: params.member === 'carol' ? [{ docId: 'private-doc', label: '受控文档', sectionId: 'chapter-1', activeRound: 2 }] : [], reasons: params.member === 'carol' ? ['请先交接文档职责'] : [] }
|
||||
else if (command === 'rdm.perm.check') {
|
||||
const person = people.find(person => person.owner === params.member)!
|
||||
payload = { projectId: 'domain-7', member: person.owner, name: person.name, role: person.role, profRole: person.profRole, actions: permissions(person.owner), canConfigurePermissions: canManage }
|
||||
} else if (command === 'rdm.perm.matrix') payload = { projectId: 'domain-7', canConfigurePermissions: canManage,
|
||||
rows: people.filter(person => person.joined && matches(person, params)).map(person => ({ member: person.owner, name: person.name, role: person.role, profRole: person.profRole, actions: permissions(person.owner) })), next: '' }
|
||||
else {
|
||||
writes.push({ command, params })
|
||||
if (command === 'rdm.project.member.add') { const person = people.find(person => person.owner === params.member)!; person.joined = true; person.role = String(params.role); person.projectRole = person.role }
|
||||
if (command === 'rdm.project.member.set-role') { const person = people.find(person => person.owner === params.member)!; person.role = String(params.role); person.projectRole = person.role }
|
||||
if (command === 'rdm.perm.unset') overrides.delete(`${params.member}:${params.action}`)
|
||||
if (command === 'rdm.perm.set') overrides.set(`${params.member}:${params.action}`, params.allow === true)
|
||||
}
|
||||
body = { payload }
|
||||
} else if (path.endsWith('/cards')) body = { cards: [] }
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: '项目设置', exact: true }).click()
|
||||
return { calls, writes, settingWrites }
|
||||
}
|
||||
|
||||
test('shared settings exclude system controls and restore inheritance with an explicit versioned removal', async ({ page }) => {
|
||||
const fixture = await settingsPage(page)
|
||||
await expect(page.getByLabel('项目交付风格', { exact: true })).toHaveValue('formal')
|
||||
await expect(page.getByLabel('主模型', { exact: true })).toHaveCount(0)
|
||||
await page.getByLabel('项目交付风格', { exact: true }).selectOption('')
|
||||
await page.getByRole('button', { name: '保存项目设置', exact: true }).click()
|
||||
await expect(page.getByText('已保存共享项目配置;按各项标注的生效时机应用', { exact: true })).toBeVisible()
|
||||
expect(fixture.settingWrites).toEqual([{ path: '/api/settings/project/host-1', body: { values: {}, expectedRevision: 4, unset: ['deliverables.style'] } }])
|
||||
})
|
||||
|
||||
test('members can be found by name and professional role; joined accounts cannot silently change identity', async ({ page }) => {
|
||||
const fixture = await settingsPage(page)
|
||||
await page.getByRole('tab', { name: '项目成员', exact: true }).click()
|
||||
await page.getByLabel('专业角色筛选', { exact: true }).selectOption('systems')
|
||||
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').fill('李工')
|
||||
await page.getByRole('option', { name: /^李工/ }).click()
|
||||
await page.getByRole('button', { name: '添加成员', exact: true }).click()
|
||||
await expect(page.getByRole('row').filter({ hasText: '李工' })).toContainText('项目成员')
|
||||
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').fill('李工')
|
||||
await expect(page.getByRole('option', { name: /^李工/ })).toHaveAttribute('aria-disabled', 'true')
|
||||
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').press('ArrowDown')
|
||||
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').press('Enter')
|
||||
await expect(page.getByRole('button', { name: '添加成员', exact: true })).toBeDisabled()
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.project.member.add', params: { projectId: 'domain-7', member: 'bob', role: 'member' } }])
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.account.list' && call.params.query === '李工' && call.params.roleId === 'systems')).toBe(true)
|
||||
})
|
||||
|
||||
test('member removal shows scoped duty impacts and blocks affected members before any write', async ({ page }) => {
|
||||
const fixture = await settingsPage(page)
|
||||
await page.getByRole('tab', { name: '项目成员', exact: true }).click()
|
||||
await page.getByRole('row').filter({ hasText: '陈工' }).getByRole('button', { name: '移除', exact: true }).click()
|
||||
await expect(page.getByText('受控文档', { exact: false })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '确认移除', exact: true })).toBeDisabled()
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.project.member.impact' && call.params.member === 'carol')).toBe(true)
|
||||
expect(fixture.writes).toEqual([])
|
||||
})
|
||||
|
||||
test('ordinary members see their own permissions immediately and cannot open management views', async ({ page }) => {
|
||||
const fixture = await settingsPage(page, false)
|
||||
await page.getByRole('tab', { name: '项目权限', exact: true }).click()
|
||||
await expect(page.getByRole('cell', { name: '读取文档', exact: true })).toBeVisible()
|
||||
await expect(page.locator('option[value="matrix"]')).toBeDisabled()
|
||||
await expect(page.locator('.picker[aria-label="选择权限成员"]').getByRole('combobox')).toBeDisabled()
|
||||
await expect(page.getByLabel('读取文档项目覆盖', { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByText('创建项目', { exact: true })).toHaveCount(0)
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.perm.check' && call.params.member === 'alice')).toBe(true)
|
||||
expect(fixture.writes).toEqual([])
|
||||
})
|
||||
|
||||
test('permission matrix filters members and cap-bound overrides use actual stored values and removal', async ({ page }) => {
|
||||
const fixture = await settingsPage(page)
|
||||
await page.getByRole('tab', { name: '项目权限', exact: true }).click()
|
||||
await expect(page.getByLabel('编制与编辑文档项目覆盖', { exact: true })).toHaveValue('allow')
|
||||
await expect(page.getByLabel('编制与编辑文档项目覆盖', { exact: true }).locator('option[value="allow"]')).toBeDisabled()
|
||||
await page.getByLabel('读取文档项目覆盖', { exact: true }).selectOption('default')
|
||||
await expect(page.getByLabel('读取文档项目覆盖', { exact: true })).toHaveValue('default')
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.perm.unset', params: { projectId: 'domain-7', member: 'alice', action: 'doc.read' } }])
|
||||
await page.getByLabel('权限查看方式', { exact: true }).selectOption('matrix')
|
||||
await page.getByLabel('专业角色筛选', { exact: true }).selectOption('software')
|
||||
await expect(page.getByRole('row').filter({ hasText: '陈工' })).toBeVisible()
|
||||
await expect(page.getByRole('row').filter({ hasText: '项目负责人' })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '陈工 · 读取文档 · 允许', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '陈工 · 权限依据', exact: true })).toBeVisible()
|
||||
await expect(page.getByText('创建项目', { exact: true })).toHaveCount(0)
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.perm.matrix' && call.params.roleId === 'software')).toBe(true)
|
||||
})
|
||||
+13
-6
@@ -15,6 +15,13 @@ const BASE = ''
|
||||
* 自身命令面 fail-closed。 */
|
||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||
|
||||
export interface SettingsView {
|
||||
schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown>
|
||||
projectRevision?: number; projectCanConfigure?: boolean
|
||||
personalSchema?: SettingDesc[]; personalDefaults?: Record<string, unknown>
|
||||
personalUser?: Record<string, unknown>; personalProject?: Record<string, unknown>
|
||||
}
|
||||
|
||||
async function checked(res: Response): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
@@ -136,14 +143,14 @@ export const api = {
|
||||
saveProfile: (p: AgentProfile) => auth.fetch(`${BASE}/api/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }),
|
||||
deleteProfile: (id: string) => auth.fetch(`${BASE}/api/profiles/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r =>
|
||||
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
|
||||
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
||||
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
|
||||
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + encodeURIComponent(projectId) : ''}`).then(r => json<SettingsView>(r)),
|
||||
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
|
||||
putProjectSettings: (projectId: string, values: Record<string, unknown>, expectedRevision: number, unset?: string[]) => auth.fetch(`${BASE}/api/settings/project/${encodeURIComponent(projectId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values, expectedRevision, ...(unset?.length ? { unset } : {}) }) }).then(r => json<{ ok: boolean; projectRevision: number }>(r)),
|
||||
putPersonalProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${encodeURIComponent(projectId)}/personal`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
|
||||
// P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。
|
||||
getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r =>
|
||||
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown> }>(r)),
|
||||
putSystemSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
||||
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown>; systemRevision?: number }>(r)),
|
||||
putSystemSettings: (values: Record<string, unknown>, expectedRevision: number, unset?: string[]) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values, expectedRevision, ...(unset?.length ? { unset } : {}) }) }).then(checked),
|
||||
|
||||
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
|
||||
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
|
||||
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { EmptyState, Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
|
||||
@@ -18,7 +18,7 @@ interface AdminSectionDef {
|
||||
icon: IconName
|
||||
desc: string
|
||||
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
|
||||
render: () => React.ReactNode
|
||||
render: (onDirtyChange?: (dirty: boolean) => void) => React.ReactNode
|
||||
}
|
||||
|
||||
function ProfilesPanelWrapper() {
|
||||
@@ -32,7 +32,7 @@ const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
||||
// 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: onDirtyChange => <SystemSettingsPanel onDirtyChange={onDirtyChange} /> },
|
||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||
]
|
||||
@@ -64,6 +64,9 @@ export function AdminZone() {
|
||||
const declared = useStore(s => s.adminSections)
|
||||
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
|
||||
const [sectionKey, setSectionKey] = useState('users')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const dirtyChange = useCallback((value: boolean) => setDirty(value), [])
|
||||
const depart = () => !dirty || window.confirm('系统配置尚未保存,离开后将丢弃这些改动。继续?')
|
||||
const isAdmin = !!me?.isAdmin
|
||||
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
|
||||
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
|
||||
@@ -82,18 +85,18 @@ export function AdminZone() {
|
||||
return (
|
||||
<div className="az" role="dialog" aria-label="系统管理">
|
||||
<div className="az-head">
|
||||
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
|
||||
<button className="btn ghost" onClick={() => { if (depart()) store.setUI({ adminZoneOpen: false }) }}>
|
||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||
</button>
|
||||
<span className="az-title">系统管理</span>
|
||||
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
|
||||
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 按配置注明的时机生效</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||
</div>
|
||||
<div className="az-body">
|
||||
<nav className="az-side">
|
||||
{sections.map(s => (
|
||||
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
|
||||
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => { if (depart()) { setDirty(false); setSectionKey(s.key) } }}>
|
||||
<Icon name={s.icon} size={15} />
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
@@ -104,7 +107,7 @@ export function AdminZone() {
|
||||
<h3>{section.label}</h3>
|
||||
<p>{section.desc}</p>
|
||||
</div>
|
||||
{section.render()}
|
||||
{section.render(dirtyChange)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,25 +2,12 @@ import { useEffect, useState } from 'react'
|
||||
import { Btn, SettingRow, SettingsGroup } from '@ui'
|
||||
import { api } from '../api'
|
||||
|
||||
const services = [
|
||||
['models.chat', '主模型'],
|
||||
['models.lightweight', '轻量模型'],
|
||||
['models.embedding', 'Embedding'],
|
||||
['models.rerank', 'Reranker'],
|
||||
['tools.tavily', 'Tavily'],
|
||||
] as const
|
||||
|
||||
type Secret = { id: string; service: string; label: string; active: boolean; mask: string }
|
||||
type Entry = { id: string; keys: string[]; content: string; constant: boolean; logic: string }
|
||||
type Cmd = { id: string; name: string; title: string; template: string }
|
||||
|
||||
export function BorrowPanels() {
|
||||
const [secrets, setSecrets] = useState<Secret[]>([])
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [cmds, setCmds] = useState<Cmd[]>([])
|
||||
const [service, setService] = useState<string>(services[0][0])
|
||||
const [label, setLabel] = useState('')
|
||||
const [value, setValue] = useState('')
|
||||
const [keys, setKeys] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [constant, setConstant] = useState(false)
|
||||
@@ -29,7 +16,6 @@ export function BorrowPanels() {
|
||||
const [cmdBody, setCmdBody] = useState('请总结以上内容。\n{{input}}')
|
||||
|
||||
const reload = () => {
|
||||
api.listSecrets().then(setSecrets).catch(() => undefined)
|
||||
api.listContextEntries().then(setEntries).catch(() => undefined)
|
||||
api.listPromptCommands().then(setCmds).catch(() => undefined)
|
||||
}
|
||||
@@ -37,28 +23,6 @@ export function BorrowPanels() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsGroup title="密钥">
|
||||
<SettingRow label="新增密钥" desc="每个服务同时只有一把生效。列表只显示掩码。">
|
||||
<select value={service} onChange={e => setService(e.target.value)}>
|
||||
{services.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="标签与密钥" desc="保存后立即生效,并停用同服务的其他密钥。">
|
||||
<input className="search-input" value={label} placeholder="标签" onChange={e => setLabel(e.target.value)} />
|
||||
<input className="search-input" type="password" autoComplete="new-password" value={value} placeholder="密钥" onChange={e => setValue(e.target.value)} />
|
||||
<Btn variant="primary" onClick={async () => {
|
||||
if (!value.trim()) return
|
||||
await api.createSecret(service, label || '未命名', value.trim())
|
||||
setValue(''); setLabel(''); reload()
|
||||
}}>添加</Btn>
|
||||
</SettingRow>
|
||||
{secrets.map(s => (
|
||||
<SettingRow key={s.id} label={s.label} desc={`${s.service} · ${s.mask}${s.active ? ' · 生效' : ''}`}>
|
||||
{!s.active && <Btn onClick={async () => { await api.activateSecret(s.id); reload() }}>启用</Btn>}
|
||||
<Btn onClick={async () => { await api.deleteSecret(s.id); reload() }}>删除</Btn>
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
<SettingsGroup title="条件注入">
|
||||
<SettingRow label="关键词" desc="逗号分隔。用 /正则/ 表示正则。勾选后变为常驻。">
|
||||
<input className="search-input" value={keys} placeholder="关键词" onChange={e => setKeys(e.target.value)} />
|
||||
|
||||
@@ -1,113 +1,98 @@
|
||||
// 项目设置面板(提案 2026-09-30-cross-session-reading):首个项目级设置 UI。
|
||||
// schema 驱动渲染 project scope(4.9 覆盖链:system → user → project),
|
||||
// 写 putProjectSettings;后续项目级设置项经 settings schema 自动出现,零开发。
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, type SettingsView } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, Btn, EmptyState } from '@ui'
|
||||
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
|
||||
import { flatten, scopedDraft, scopedSchema, SettingsFields, settingsMutation } from './SettingsFields'
|
||||
import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections'
|
||||
import { rdmProjectReadError } from '../rdmManagement'
|
||||
import { projectActionError, projectWorkError } from '../projectmeta'
|
||||
import { Feedback, useRequestFence, validVersion } from './rdmSettingsShared'
|
||||
|
||||
export function ProjectSettingsModal() {
|
||||
const projectId = useStore(s => s.ui.projectSettingsId)
|
||||
const projects = useStore(s => s.projects)
|
||||
const project = projects.find(p => p.id === projectId)
|
||||
const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable)
|
||||
const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable)
|
||||
const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable)
|
||||
const project = useStore(s => s.projects.find(project => project.id === projectId))
|
||||
const owner = useStore(s => s.me?.owner)
|
||||
const membersAvailable = useStore(s => s.rdmMembersAvailable)
|
||||
const dutiesAvailable = useStore(s => s.rdmDocReviewAvailable)
|
||||
const permissionsAvailable = useStore(s => s.rdmPermMatrixAvailable)
|
||||
const context = `${owner || ''}:${projectId || ''}`
|
||||
const fence = useRequestFence(context)
|
||||
const [tab, setTab] = useState('project')
|
||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||
const [settings, setSettings] = useState<SettingsView | null>(null)
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [childDirty, setChildDirty] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
let active = true
|
||||
setLoaded(false); setSaved(false); setError(''); setDraft({}); setSchema([])
|
||||
api.getSettings(projectId).then(s => {
|
||||
if (!active) return
|
||||
setSchema(s.schema)
|
||||
setDraft(flatten(s.project))
|
||||
setLoaded(true)
|
||||
}).catch(() => { if (active) setError('项目设置未能加载,请关闭后重新打开或联系项目负责人') })
|
||||
return () => { active = false }
|
||||
}, [projectId, owner])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const g: Record<string, SettingDesc[]> = {}
|
||||
for (const d of schema) (g[d.group] ??= []).push(d)
|
||||
return g
|
||||
}, [schema])
|
||||
|
||||
if (!projectId) return null
|
||||
const rdmError = rdmProjectReadError(project)
|
||||
const [notice, setNotice] = useState('')
|
||||
const schema = settings ? scopedSchema(settings.schema, 'project') : []
|
||||
const initial = settings ? scopedDraft(settings.project, schema) : {}
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(initial)
|
||||
const writeError = projectWorkError(project)
|
||||
const rdmTab = tab === 'members' || tab === 'duties' || tab === 'matrix'
|
||||
|
||||
const save = async () => {
|
||||
if (!projectId) return
|
||||
if (writeError) { store.toast(writeError); return }
|
||||
const canSave = settings?.projectCanConfigure === true && validVersion(settings.projectRevision) && !writeError
|
||||
const load = async () => {
|
||||
if (!projectId) return false
|
||||
const current = fence.begin('load')
|
||||
try {
|
||||
await api.putProjectSettings(projectId, unflatten(draft))
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 1500)
|
||||
} catch (e) {
|
||||
store.toast(`项目设置保存失败:${projectActionError(e)}`)
|
||||
}
|
||||
const next = await api.getSettings(projectId)
|
||||
if (!current()) return false
|
||||
setSettings(next); setDraft(scopedDraft(next.project, scopedSchema(next.schema, 'project')))
|
||||
return true
|
||||
} catch (e) { if (current()) setError(`项目设置未能加载:${projectActionError(e)}`); return false }
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
title={`项目设置 · ${project?.name ?? projectId}`}
|
||||
active={tab}
|
||||
onSelect={setTab}
|
||||
onClose={() => store.openProjectSettings(null)}
|
||||
sections={[
|
||||
{ key: 'project', label: '本项目', icon: 'folder' },
|
||||
// U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。
|
||||
...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []),
|
||||
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责 · 整文审签', icon: 'shield' as const }] : []),
|
||||
...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
|
||||
]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok-text)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||||
>
|
||||
<div>
|
||||
{rdmTab && rdmError ? <EmptyState icon="folder">{rdmError}</EmptyState> : project && <>
|
||||
{tab === 'members' && <RdmMembersSection key={project.id} project={project} />}
|
||||
{tab === 'duties' && <RdmDutiesSection key={project.id} project={project} />}
|
||||
{tab === 'matrix' && <RdmPermMatrixSection key={project.id} project={project} />}
|
||||
</>}
|
||||
{tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? (
|
||||
<EmptyState icon="settings">{error || '加载中…'}</EmptyState>
|
||||
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? (
|
||||
<>
|
||||
<div className="empty-note" style={{ marginBottom: 10 }}>
|
||||
{writeError ?? '此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。'}
|
||||
</div>
|
||||
{Object.entries(groups).map(([g, descs]) => (
|
||||
<div className="settings-group" key={g}>
|
||||
<h4>{groupNames[g] ?? g}</h4>
|
||||
{descs.map(d => (
|
||||
<div className="setting-row" key={d.key}>
|
||||
<label title={d.key}>{d.label}</label>
|
||||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||||
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
|
||||
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Btn variant="primary" icon="check" disabled={!!writeError} onClick={save}>保存项目设置</Btn>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsShell>
|
||||
)
|
||||
useEffect(() => {
|
||||
fence.invalidate(); setSettings(null); setDraft({}); setTab('project'); setChildDirty(false)
|
||||
setError(''); setNotice(''); setBusy(false)
|
||||
void load()
|
||||
}, [context])
|
||||
const dirtyChange = useCallback((value: boolean) => setChildDirty(value), [])
|
||||
const depart = () => !(dirty || childDirty) || window.confirm('存在未保存的设置,离开后将丢弃这些改动。继续?')
|
||||
useEffect(() => {
|
||||
if (!dirty && !childDirty) return
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault() }
|
||||
window.addEventListener('beforeunload', beforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', beforeUnload)
|
||||
}, [dirty, childDirty])
|
||||
const save = async () => {
|
||||
if (!projectId || !canSave || !settings || !validVersion(settings.projectRevision) || busy) return
|
||||
const current = fence.begin('save')
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
try {
|
||||
const mutation = settingsMutation(initial, draft, schema)
|
||||
await api.putProjectSettings(projectId, mutation.values, settings.projectRevision, mutation.unset)
|
||||
if (current() && await load()) setNotice('已保存共享项目配置;按各项标注的生效时机应用')
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
const message = String(e).includes('409') ? '项目设置已被其他成员更新;请核对最新设置后重新调整' : `保存结果未能确认:${projectActionError(e)};请核对最新设置后再保存`
|
||||
await load()
|
||||
if (current()) setError(message)
|
||||
} finally { if (current()) setBusy(false) }
|
||||
}
|
||||
if (!projectId) return null
|
||||
const rdmTab = ['members', 'duties', 'matrix'].includes(tab)
|
||||
const readError = rdmProjectReadError(project)
|
||||
return <SettingsShell title={project?.name || '当前项目'} ariaLabel={`项目设置 · ${project?.name || '当前项目'}`} active={tab}
|
||||
onSelect={next => { if (!busy && depart()) { if (dirty) setDraft(initial); setChildDirty(false); setNotice(''); setTab(next) } }}
|
||||
onClose={() => { if (depart()) { fence.invalidate(); store.openProjectSettings(null) } }}
|
||||
sections={[
|
||||
{ key: 'project', label: '共享项目配置', icon: 'folder' },
|
||||
...(membersAvailable ? [{ key: 'members', label: '项目成员', icon: 'users' as const }] : []),
|
||||
...(dutiesAvailable ? [{ key: 'duties', label: '文档职责', icon: 'shield' as const }] : []),
|
||||
...(permissionsAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
|
||||
]}
|
||||
footer="项目配置由成员共享;个人项目偏好在个人设置中调整"
|
||||
actions={!rdmTab && settings && schema.length > 0 ? <Btn variant="primary" icon="check" disabled={!canSave || busy || !dirty} onClick={save}>保存项目设置</Btn> : undefined}>
|
||||
{rdmTab && readError ? <EmptyState icon="folder">{readError}</EmptyState> : project && <>
|
||||
{tab === 'members' && <RdmMembersSection key={context} project={project} />}
|
||||
{tab === 'duties' && <RdmDutiesSection key={context} project={project} onDirtyChange={dirtyChange} />}
|
||||
{tab === 'matrix' && <RdmPermMatrixSection key={context} project={project} />}
|
||||
</>}
|
||||
{!rdmTab && <>
|
||||
<p className="ad-hint">此处配置项目共享的业务规则。模型、工具与系统插件由系统管理员配置;个人上下文、检索与会话偏好在个人设置中调整</p>
|
||||
{!settings ? <><EmptyState icon="settings">{error || '加载项目设置…'}</EmptyState>{error && <Btn onClick={() => { setError(''); void load() }}>重试</Btn>}</>
|
||||
: schema.length === 0 ? <EmptyState icon="settings">当前没有可配置的共享项目设置</EmptyState>
|
||||
: <SettingsFields schema={schema} draft={draft} defaults={flatten(settings.defaults)} disabled={!canSave || busy} source="本项目共享配置" onChange={(key, value) => { setNotice(''); setDraft(previous => ({ ...previous, [key]: value })) }} />}
|
||||
{settings && !canSave && <p className="ad-hint">{writeError || (validVersion(settings.projectRevision) ? '你可以查看共享配置;保存需要项目设置管理权限' : '共享配置版本尚未就绪,暂不能保存')}</p>}
|
||||
<Feedback error={error} notice={notice} />
|
||||
</>}
|
||||
</SettingsShell>
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
|
||||
import { classifyRdmError } from '../rdmErrors'
|
||||
import { store, useStore } from '../store'
|
||||
import { exportNeedsDraft } from '../workspaces'
|
||||
|
||||
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
||||
|
||||
@@ -45,22 +47,30 @@ function RdmErrorState({ raw }: { raw: string }) {
|
||||
}
|
||||
|
||||
export function RdmAuditWorkspace() {
|
||||
const identity = useStore(() => store.getIdentityEpoch())
|
||||
const [caps, setCaps] = useState<Capabilities | null>(null)
|
||||
const [capsIdentity, setCapsIdentity] = useState(-1)
|
||||
const [pid, setPid] = useState('')
|
||||
const [tab, setTab] = useState<TabId>('summary')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setCaps(null); setPid(''); setError('')
|
||||
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
|
||||
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
|
||||
.catch(e => { if (!cancelled) setError(e.message) })
|
||||
.then(c => {
|
||||
if (cancelled || identity !== store.getIdentityEpoch()) return
|
||||
setCaps(c); setCapsIdentity(identity)
|
||||
if (c.projects.length > 0) setPid(c.projects[0].projectId)
|
||||
})
|
||||
.catch(e => { if (!cancelled && identity === store.getIdentityEpoch()) setError(e.message) })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
}, [identity])
|
||||
|
||||
const project = caps?.projects.find(p => p.projectId === pid)
|
||||
const exportOn = useStore(s => s.rdmExportAvailable)
|
||||
if (error) return <RdmErrorState raw={error} />
|
||||
if (!caps) return <div className="empty-note">加载中…</div>
|
||||
if (!caps || capsIdentity !== identity) return <div className="empty-note">加载中…</div>
|
||||
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
|
||||
|
||||
return (
|
||||
@@ -82,7 +92,7 @@ export function RdmAuditWorkspace() {
|
||||
))}
|
||||
</div>
|
||||
<div className="rdm-audit-body">
|
||||
{tab === 'summary' && <SummaryTab pid={pid} />}
|
||||
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
|
||||
{tab === 'materials' && <MaterialsTab pid={pid} />}
|
||||
{tab === 'trace' && <TraceTab pid={pid} />}
|
||||
{tab === 'timeline' && <TimelineTab pid={pid} />}
|
||||
@@ -93,7 +103,7 @@ export function RdmAuditWorkspace() {
|
||||
|
||||
// ---- 概览 ----
|
||||
|
||||
function SummaryTab({ pid }: { pid: string }) {
|
||||
function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) {
|
||||
const [data, setData] = useState<SummaryData | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
useEffect(() => {
|
||||
@@ -133,6 +143,131 @@ function SummaryTab({ pid }: { pid: string }) {
|
||||
{data.coverage && data.coverage.length > 0 && (
|
||||
<CoverageList cov={data.coverage} />
|
||||
)}
|
||||
{exportOn && <ExportSection pid={pid} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 概览导出(三条命令都启用才挂上;不参与七命令就绪门) ----
|
||||
|
||||
interface ExportCreated {
|
||||
exportId: string
|
||||
fileName: string
|
||||
watermark: number
|
||||
sha256: string
|
||||
draft?: boolean
|
||||
}
|
||||
interface ExportStatus {
|
||||
expiresAt: string
|
||||
sha256: string
|
||||
watermark: number
|
||||
}
|
||||
interface ExportDownloaded {
|
||||
fileName: string
|
||||
mime: string
|
||||
dataB64: string
|
||||
sha256: string
|
||||
}
|
||||
|
||||
function saveExportFile(fileName: string, mime: string, dataB64: string) {
|
||||
const bin = atob(dataB64)
|
||||
const bytes = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||
const blob = new Blob([bytes], { type: mime || 'application/octet-stream' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function ExportSection({ pid }: { pid: string }) {
|
||||
const identity = useStore(() => store.getIdentityEpoch())
|
||||
const generation = useRef(0)
|
||||
const [kind, setKind] = useState<'cgraph-docx' | 'cgraph-matrix'>('cgraph-docx')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [needDraft, setNeedDraft] = useState(false)
|
||||
const [artifact, setArtifact] = useState<(ExportCreated & { expiresAt: string }) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
generation.current++
|
||||
setArtifact(null)
|
||||
setErr('')
|
||||
setNeedDraft(false)
|
||||
setBusy(false)
|
||||
return () => { generation.current++ }
|
||||
}, [pid, identity])
|
||||
|
||||
const generate = async (draft: boolean) => {
|
||||
const request = ++generation.current
|
||||
const current = () => request === generation.current && identity === store.getIdentityEpoch()
|
||||
setBusy(true)
|
||||
setErr('')
|
||||
setNeedDraft(false)
|
||||
try {
|
||||
const created = await api.rdmCommand<ExportCreated>('rdm.inspect.export.create', { projectId: pid, kind, draft })
|
||||
if (!current()) return
|
||||
const st = await api.rdmCommand<ExportStatus>('rdm.inspect.export.status', { projectId: pid, exportId: created.exportId })
|
||||
if (!current()) return
|
||||
setArtifact({ ...created, expiresAt: st.expiresAt })
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
if (!draft && exportNeedsDraft(msg)) setNeedDraft(true)
|
||||
else setErr(msg)
|
||||
} finally {
|
||||
if (current()) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
if (!artifact) return
|
||||
const request = ++generation.current
|
||||
const current = () => request === generation.current && identity === store.getIdentityEpoch()
|
||||
setBusy(true)
|
||||
setErr('')
|
||||
try {
|
||||
const dl = await api.rdmCommand<ExportDownloaded>('rdm.inspect.export.download', { projectId: pid, exportId: artifact.exportId })
|
||||
if (!current()) return
|
||||
saveExportFile(dl.fileName, dl.mime, dl.dataB64)
|
||||
} catch (e) {
|
||||
if (current()) setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
if (current()) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-export">
|
||||
<SectionLabel>导出</SectionLabel>
|
||||
<div className="rdm-audit-export-row">
|
||||
<label className="rdm-audit-export-kind">
|
||||
类型
|
||||
<select className="ui-input" aria-label="导出类型" value={kind} onChange={e => setKind(e.target.value as 'cgraph-docx' | 'cgraph-matrix')}>
|
||||
<option value="cgraph-docx">合规追溯报告</option>
|
||||
<option value="cgraph-matrix">追溯矩阵</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(false) }}>生成</button>
|
||||
</div>
|
||||
{needDraft && (
|
||||
<div className="rdm-audit-export-draft">
|
||||
<p>正式报告不完整。可以生成带缺口标注的草稿。</p>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(true) }}>生成草稿</button>
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="rdm-audit-export-err">{err}</div>}
|
||||
{artifact && (
|
||||
<div className="rdm-audit-export-result">
|
||||
<div>水位 {artifact.watermark}</div>
|
||||
<div>sha256 {artifact.sha256}</div>
|
||||
<div>有效期 {artifact.expiresAt ? new Date(artifact.expiresAt).toLocaleString('zh-CN') : '—'}</div>
|
||||
{artifact.draft && <div>草稿</div>}
|
||||
<button type="button" className="btn v-ghost" disabled={busy} onClick={() => { void download() }}>下载</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Btn, Collapsible, Picker, SettingRow, SettingsGroup, type PickPage } from '@ui'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { assignDocDutiesPayload, docApprovePayload, docFinalPayload, docRejectPayload, docSubmitPayload } from '../rdmManagement'
|
||||
import { Feedback, personItem, projectContext, readableError, RoleFilter, selectedPeople,
|
||||
useRequestFence, useRoleOptions, validVersion, type MemberList, type Person } from './rdmSettingsShared'
|
||||
|
||||
interface Duties { author: string; reviewers: string[]; approver: string }
|
||||
type WireDuties = Partial<Duties> & { Author?: string; Reviewers?: string[]; Approver?: string }
|
||||
export function normalizeDuties(value?: WireDuties | null): Duties {
|
||||
return { author: value?.author ?? value?.Author ?? '', reviewers: value?.reviewers ?? value?.Reviewers ?? [], approver: value?.approver ?? value?.Approver ?? '' }
|
||||
}
|
||||
const blank = (): Duties => ({ author: '', reviewers: [], approver: '' })
|
||||
const sameDuties = (left: Duties, right: Duties) => left.author === right.author && left.approver === right.approver
|
||||
&& [...left.reviewers].sort().join('\u0000') === [...right.reviewers].sort().join('\u0000')
|
||||
export function dutyValidation(duties: Duties): string {
|
||||
if (!duties.author || !duties.approver || duties.reviewers.length === 0) return '请选择编制人、至少一位审核人和批准人'
|
||||
if (duties.author === duties.approver || duties.reviewers.includes(duties.author) || duties.reviewers.includes(duties.approver)) return '编制、审核、批准须由不同成员承担'
|
||||
return ''
|
||||
}
|
||||
interface CatalogDoc {
|
||||
docId: string; title: string; typeKey: string; typeLabel?: string; stage?: string; stageLabel?: string
|
||||
sections: number; assigned: boolean; author?: string; reviewers?: string[]; approver?: string
|
||||
activeRound?: number; activeSections?: number; status?: string; statusLabel?: string; dutyVersion?: number
|
||||
}
|
||||
interface DutyTreeNode {
|
||||
id: string; kind: 'stage' | 'type' | 'doc' | 'section'; label: string; selectable: boolean
|
||||
docId?: string; sectionId?: string; children?: DutyTreeNode[]
|
||||
}
|
||||
interface Catalog {
|
||||
docs: CatalogDoc[]; next?: string; count?: number; total?: number; canAssignDuties?: boolean
|
||||
filters?: { stages?: { value: string; label: string }[]; typeKeys?: { value: string; label: string }[]; statuses?: { value: string; label: string }[]; assignments?: { value: string; label: string }[] }
|
||||
}
|
||||
interface ActiveDuty { round?: number; state?: string; createdBy?: string; duties?: WireDuties; cardId?: string }
|
||||
interface Chapter {
|
||||
sectionId: string; title: string; roles: WireDuties; dutyVersion?: number; overridden: boolean; active?: ActiveDuty | null
|
||||
}
|
||||
interface DutyDetail {
|
||||
docId: string; title: string; assigned: boolean; roles: WireDuties; dutyVersion?: number
|
||||
sections: Chapter[]; active?: ActiveDuty | null; canAssignDuties?: boolean
|
||||
}
|
||||
/** A default change affects whole-document review and only chapters inheriting that default. */
|
||||
export function documentAssignmentImpact(document: Pick<DutyDetail, 'roles' | 'active' | 'sections'>, duties: Duties): boolean {
|
||||
if (sameDuties(normalizeDuties(document.roles), duties)) return false
|
||||
return !!document.active || document.sections.some(section => !section.overridden && !!section.active
|
||||
&& !sameDuties(normalizeDuties(section.roles), duties))
|
||||
}
|
||||
interface Submission {
|
||||
round: number; state: string; duties: WireDuties; reviews: Record<string, boolean>; createdBy: string
|
||||
}
|
||||
const statusLabel = (state?: string) => ({ submitted: '审核中', final: '已批准', rejected: '已驳回', invalidated: '职责变更,待重新提交', 'in-review': '审核中', approved: '已批准', draft: '编写中', unassigned: '未分配' }[state || ''] || '暂无审签')
|
||||
|
||||
/** Only document nodes may become a document selection. Chapters remain visible context. */
|
||||
export function DutyTree({ nodes, selected, onSelect }: { nodes: DutyTreeNode[]; selected: string; onSelect: (docId: string) => void }) {
|
||||
return <ul className="rdm-duty-tree">{nodes.map(node => <li key={node.id}>
|
||||
{node.kind === 'doc' && node.selectable === true && node.docId
|
||||
? <button className={`ad-mini${selected === node.docId ? ' ad-row-on' : ''}`} onClick={() => onSelect(node.docId!)}>{node.label}</button>
|
||||
: <span className="ad-dim">{node.label}{node.kind === 'section' ? ' · 章节' : ''}</span>}
|
||||
{!!node.children?.length && <Collapsible trigger={`展开 ${node.label}`} defaultOpen={node.kind === 'stage' || node.kind === 'type'}>
|
||||
<DutyTree nodes={node.children} selected={selected} onSelect={onSelect} />
|
||||
</Collapsible>}
|
||||
</li>)}</ul>
|
||||
}
|
||||
function mergeTree(previous: DutyTreeNode[], incoming: DutyTreeNode[]): DutyTreeNode[] {
|
||||
const merged = new Map(previous.map(node => [node.id, node]))
|
||||
for (const node of incoming) {
|
||||
const old = merged.get(node.id)
|
||||
merged.set(node.id, old ? { ...node, children: mergeTree(old.children || [], node.children || []) } : node)
|
||||
}
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
function DutyPeople({ value, onChange, fetchPage, people, disabled, labelPrefix = '' }: {
|
||||
value: Duties; onChange: (value: Duties) => void; fetchPage: (query: string, cursor: string, limit: number, purpose: 'author' | 'reviewer' | 'approver') => Promise<PickPage>
|
||||
people: Person[]; disabled: boolean; labelPrefix?: string
|
||||
}) {
|
||||
const authorPage = useCallback((query: string, cursor: string, limit: number) => fetchPage(query, cursor, limit, 'author'), [fetchPage])
|
||||
const reviewerPage = useCallback((query: string, cursor: string, limit: number) => fetchPage(query, cursor, limit, 'reviewer'), [fetchPage])
|
||||
const approverPage = useCallback((query: string, cursor: string, limit: number) => fetchPage(query, cursor, limit, 'approver'), [fetchPage])
|
||||
return <>
|
||||
<SettingRow label={`${labelPrefix}编制人`} desc="单选项目成员">
|
||||
<Picker aria-label={`${labelPrefix}编制人`} selected={value.author ? [value.author] : []} selectedItems={selectedPeople([value.author].filter(Boolean), people)}
|
||||
onSelect={ids => onChange({ ...value, author: ids[0] || '' })} fetchPage={authorPage} disabled={disabled} />
|
||||
</SettingRow>
|
||||
<SettingRow label={`${labelPrefix}审核人`} desc="至少选择一位,与编制人和批准人互异">
|
||||
<Picker aria-label={`${labelPrefix}审核人`} multiple selected={value.reviewers} selectedItems={selectedPeople(value.reviewers, people)}
|
||||
onSelect={reviewers => onChange({ ...value, reviewers })} fetchPage={reviewerPage} disabled={disabled} />
|
||||
</SettingRow>
|
||||
<SettingRow label={`${labelPrefix}批准人`} desc="单选项目成员">
|
||||
<Picker aria-label={`${labelPrefix}批准人`} selected={value.approver ? [value.approver] : []} selectedItems={selectedPeople([value.approver].filter(Boolean), people)}
|
||||
onSelect={ids => onChange({ ...value, approver: ids[0] || '' })} fetchPage={approverPage} disabled={disabled} />
|
||||
</SettingRow>
|
||||
</>
|
||||
}
|
||||
|
||||
export function RdmDutiesSection({ project, onDirtyChange }: { project: Project; onDirtyChange?: (dirty: boolean) => void }) {
|
||||
const me = useStore(s => s.me)
|
||||
const context = projectContext(project, me?.owner)
|
||||
const fence = useRequestFence(context)
|
||||
const writeError = projectWorkError(project)
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null)
|
||||
const [tree, setTree] = useState<DutyTreeNode[] | null>(null)
|
||||
const [view, setView] = useState<'list' | 'tree'>('list')
|
||||
const [query, setQuery] = useState('')
|
||||
const [match, setMatch] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [stage, setStage] = useState('')
|
||||
const [typeKey, setTypeKey] = useState('')
|
||||
const [assignment, setAssignment] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [roleId, setRoleId] = useState('')
|
||||
const [members, setMembers] = useState<MemberList | null>(null)
|
||||
const [docId, setDocId] = useState('')
|
||||
const [sectionId, setSectionId] = useState('')
|
||||
const [detail, setDetail] = useState<DutyDetail | null>(null)
|
||||
const [draft, setDraft] = useState<Duties>(blank)
|
||||
const [sub, setSub] = useState<Submission | null | undefined>(undefined)
|
||||
const [selection, setSelection] = useState<Set<string>>(new Set())
|
||||
const [batchDraft, setBatchDraft] = useState<Duties>(blank)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const { roles, error: roleError } = useRoleOptions(project, 'duties', members?.canAssignDuties === true)
|
||||
const selectedRef = useRef('')
|
||||
const memberNames = useRef(new Map<string, Person>())
|
||||
const canAssign = catalog?.canAssignDuties === true && members?.canAssignDuties === true && !writeError
|
||||
const canEditCurrent = canAssign && detail?.canAssignDuties === true
|
||||
const currentChapter = detail?.sections.find(section => section.sectionId === sectionId)
|
||||
const original = normalizeDuties(sectionId ? currentChapter?.roles : detail?.roles)
|
||||
const currentVersion = sectionId ? currentChapter?.dutyVersion : detail?.dutyVersion
|
||||
const active = sectionId ? currentChapter?.active || detail?.active : detail?.active
|
||||
const dirty = !!detail && !sameDuties(draft, original)
|
||||
const batchDirty = selection.size > 0 && (batchDraft.author !== '' || batchDraft.approver !== '' || batchDraft.reviewers.length > 0)
|
||||
useEffect(() => { onDirtyChange?.(dirty || batchDirty); return () => onDirtyChange?.(false) }, [dirty, batchDirty, onDirtyChange])
|
||||
|
||||
const filters = { query, match, stage, typeKey, assignment, status }
|
||||
const clearDetail = () => {
|
||||
fence.cancel('detail'); fence.cancel('status'); fence.cancel('mutation'); fence.cancel('batch-preview')
|
||||
selectedRef.current = ''; setDocId(''); setSectionId(''); setDetail(null); setDraft(blank()); setSub(undefined)
|
||||
setSelection(new Set()); setBatchDraft(blank()); setNotice(''); setBusy(false)
|
||||
}
|
||||
const depart = () => !(dirty || batchDirty) || window.confirm('有未保存的职责调整,离开后将丢弃这些改动。继续?')
|
||||
const changeFilter = (change: () => void) => { if (depart()) { fence.cancel('catalog'); clearDetail(); setCatalog(null); setTree(null); setError(''); change() } }
|
||||
|
||||
const loadCatalog = async (cursor = '') => {
|
||||
const current = fence.begin('catalog')
|
||||
setLoading(true)
|
||||
try {
|
||||
const out = await api.rdmProjectCommand<Catalog>(project, 'rdm.duty.catalog', { ...filters, cursor, limit: 30 })
|
||||
if (!current()) return false
|
||||
if (!Array.isArray(out.docs) || typeof out.canAssignDuties !== 'boolean') throw new Error('文档目录契约尚未就绪,请升级项目服务')
|
||||
setCatalog(prev => cursor && prev ? { ...out, docs: [...prev.docs, ...out.docs] } : out)
|
||||
if (view === 'tree') {
|
||||
const treeOut = await api.rdmProjectCommand<{ nodes: DutyTreeNode[]; next?: string }>(project, 'rdm.duty.tree', { ...filters, cursor, limit: 30 })
|
||||
if (!current()) return false
|
||||
if (!Array.isArray(treeOut.nodes)) throw new Error('文档树接口尚未就绪')
|
||||
setTree(previous => cursor ? mergeTree(previous || [], treeOut.nodes) : treeOut.nodes)
|
||||
}
|
||||
return true
|
||||
} catch (e) { if (current()) { setError(readableError(e)); if (!cursor) { setCatalog(null); setTree(null) } } return false }
|
||||
finally { if (current()) setLoading(false) }
|
||||
}
|
||||
const loadDetail = async (id: string) => {
|
||||
const current = fence.begin('detail')
|
||||
const statusCurrent = fence.begin('status')
|
||||
selectedRef.current = id; setDocId(id); setSectionId(''); setDetail(null); setDraft(blank()); setSub(undefined); setError('')
|
||||
try {
|
||||
const duty = await api.rdmProjectCommand<DutyDetail>(project, 'rdm.duty.get', { docId: id })
|
||||
if (!current() || selectedRef.current !== id) return false
|
||||
if (duty.docId !== id || !Array.isArray(duty.sections)) throw new Error('职责结果与所选文档不一致')
|
||||
setDetail(duty); setDraft(normalizeDuties(duty.roles))
|
||||
} catch (e) {
|
||||
if (current()) setError(readableError(e))
|
||||
return false
|
||||
}
|
||||
if (!current() || !statusCurrent()) return false
|
||||
try {
|
||||
const output = await api.rdmProjectCommand<{ submission: Submission | null }>(project, 'rdm.review.doc.status', { docId: id })
|
||||
if (!current() || !statusCurrent() || selectedRef.current !== id) return false
|
||||
setSub(output.submission)
|
||||
return true
|
||||
} catch (e) {
|
||||
if (current() && statusCurrent()) setError(`整文审签状态读取失败:${readableError(e)}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const selectDoc = (id: string) => {
|
||||
if (!depart()) return
|
||||
fence.cancel('mutation'); setBusy(false); setSelection(new Set()); setBatchDraft(blank()); setNotice('')
|
||||
void loadDetail(id)
|
||||
}
|
||||
useEffect(() => {
|
||||
fence.invalidate(); clearDetail(); setCatalog(null); setTree(null); setMembers(null); memberNames.current.clear()
|
||||
setQuery(''); setStage(''); setTypeKey(''); setAssignment(''); setStatus(''); setRoleId(''); setError('')
|
||||
const current = fence.begin('members')
|
||||
api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { limit: 50 })
|
||||
.then(out => { if (current()) { setMembers(out); for (const person of out.members ?? []) memberNames.current.set(person.owner, person) } })
|
||||
.catch(e => { if (current()) setError(`成员候选无法读取:${readableError(e)}`) })
|
||||
}, [context])
|
||||
useEffect(() => {
|
||||
fence.cancel('catalog')
|
||||
const timer = setTimeout(() => { void loadCatalog() }, query ? 250 : 0)
|
||||
return () => { clearTimeout(timer); fence.cancel('catalog') }
|
||||
}, [context, query, match, stage, typeKey, assignment, status, view])
|
||||
|
||||
const memberPage = useCallback(async (query: string, cursor: string, limit: number, purpose: 'author' | 'reviewer' | 'approver'): Promise<PickPage> => {
|
||||
const current = fence.capture()
|
||||
const out = await api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { query, cursor, limit, roleId })
|
||||
if (!current()) return { items: [] }
|
||||
if (!Array.isArray(out.members) || out.accountDirectoryAvailable !== true) throw new Error('项目成员账号状态尚未就绪')
|
||||
return { items: out.members.map(person => {
|
||||
memberNames.current.set(person.owner, person)
|
||||
const item = personItem(person)
|
||||
const eligibility = person.dutyEligibility?.[purpose]
|
||||
return eligibility?.allowed === false ? { ...item, disabled: true, disabledReason: eligibility.reason || '当前成员暂不具备该职责资格' } : item
|
||||
}), nextCursor: out.next || undefined }
|
||||
}, [project, roleId, context])
|
||||
|
||||
const run = async (message: string, mutation: () => Promise<unknown>, id = selectedRef.current) => {
|
||||
const current = fence.begin('mutation')
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
try {
|
||||
await mutation()
|
||||
if (!current()) return
|
||||
const catalogVerified = await loadCatalog()
|
||||
if (!current()) return false
|
||||
const detailVerified = !id || await loadDetail(id)
|
||||
if (current() && catalogVerified && detailVerified) { setNotice(message); return true }
|
||||
return false
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
setError(`${readableError(e)};请核对最新状态后再操作`)
|
||||
// Outcome may be unknown after disconnection. Reconcile, never retry a write automatically.
|
||||
if (id) await loadDetail(id)
|
||||
if (current()) setError(`${readableError(e)};已尝试读取最新状态,未自动重试写入`)
|
||||
return false
|
||||
} finally { if (current()) setBusy(false) }
|
||||
}
|
||||
const save = () => {
|
||||
if (!canEditCurrent || !detail || !validVersion(currentVersion) || !dirty || dutyValidation(draft) || busy) return
|
||||
const affected = sectionId ? !!active : documentAssignmentImpact(detail, draft)
|
||||
const confirmed = affected ? window.confirm(`「${detail.title}」${active?.round ? `第 ${active.round} 轮` : '相关章节'}审签将失效,需重新提交。确认职责变更?`) : false
|
||||
if (affected && !confirmed) return
|
||||
const payload = assignDocDutiesPayload(project.id, detail.docId, draft.author, draft.reviewers, draft.approver, currentVersion, confirmed)
|
||||
void run('职责已保存,已重新读取当前状态', () => api.rdmProjectCommand(project,
|
||||
sectionId ? 'rdm.review.assign' : 'rdm.review.assign.doc', sectionId ? { ...payload, sectionId } : payload))
|
||||
}
|
||||
const batchApply = async () => {
|
||||
if (!canAssign || busy || selection.size === 0 || dutyValidation(batchDraft)) return
|
||||
const current = fence.begin('batch-preview'); setBusy(true); setError('')
|
||||
try {
|
||||
const ids = [...selection]
|
||||
const preview = await Promise.all(ids.map(id => api.rdmProjectCommand<DutyDetail>(project, 'rdm.duty.get', { docId: id })))
|
||||
if (!current()) return
|
||||
if (preview.some((document, index) => document.docId !== ids[index] || !validVersion(document.dutyVersion))) throw new Error('批次中有文档版本未核验,请重新选择')
|
||||
const affected = preview.filter(document => documentAssignmentImpact(document, batchDraft))
|
||||
const summary = preview.map(document => `${document.title}${!documentAssignmentImpact(document, batchDraft) ? '' : document.active?.round ? `(第 ${document.active.round} 轮将失效)` : '(章节审签将失效)'}`).join('\n')
|
||||
if (!window.confirm(`将对以下 ${preview.length} 份文档应用相同职责,任一失败整批不写入:\n${summary}\n编制:${batchDraft.author}\n审核:${batchDraft.reviewers.join('、')}\n批准:${batchDraft.approver}\n${affected.length ? '受影响的审签需重新提交。' : ''}确认分配?`)) return
|
||||
if (!current()) return
|
||||
const saved = await run('批量职责已保存,已重新读取目录', () => api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', {
|
||||
docs: preview.map(document => ({ docId: document.docId, author: batchDraft.author, reviewers: batchDraft.reviewers, approver: batchDraft.approver,
|
||||
expectedDutyVersion: document.dutyVersion!, confirmInvalidation: affected.length > 0 })),
|
||||
confirmInvalidation: affected.length > 0,
|
||||
}))
|
||||
if (current() && saved) { setSelection(new Set()); setBatchDraft(blank()) }
|
||||
} catch (e) { if (current()) setError(readableError(e)) }
|
||||
finally { if (current()) setBusy(false) }
|
||||
}
|
||||
const submissionDuties = normalizeDuties(sub?.duties)
|
||||
const canSubmit = !!detail && detail.assigned && normalizeDuties(detail.roles).author === me?.owner && sub !== undefined && sub?.state !== 'submitted'
|
||||
const canReview = sub?.state === 'submitted' && !!me?.owner && submissionDuties.reviewers.includes(me.owner)
|
||||
const canFinal = sub?.state === 'submitted' && submissionDuties.approver === me?.owner && submissionDuties.reviewers.every(owner => sub.reviews?.[owner] === true)
|
||||
const people = [...memberNames.current.values()]
|
||||
|
||||
return <div className="rdm-settings">
|
||||
<h4 className="settings-group-title">文档职责</h4>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索文档" placeholder="输入文档名称或 ID" value={query} onChange={e => changeFilter(() => setQuery(e.target.value))} />
|
||||
<select className="lg-input" aria-label="文档匹配方式" value={match} onChange={e => changeFilter(() => setMatch(e.target.value as 'exact' | 'fuzzy'))}>
|
||||
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
|
||||
</select>
|
||||
<select className="lg-input" aria-label="文档浏览方式" value={view} onChange={e => { fence.cancel('catalog'); setTree(null); setView(e.target.value as 'list' | 'tree') }}>
|
||||
<option value="list">列表</option><option value="tree">树形</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="ad-row rdm-filter-row">
|
||||
<select className="lg-input" aria-label="阶段筛选" value={stage} onChange={e => changeFilter(() => setStage(e.target.value))}><option value="">全部阶段</option>{(catalog?.filters?.stages ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select className="lg-input" aria-label="文档类型筛选" value={typeKey} onChange={e => changeFilter(() => setTypeKey(e.target.value))}><option value="">全部文档类型</option>{(catalog?.filters?.typeKeys ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select className="lg-input" aria-label="职责分配筛选" value={assignment} onChange={e => changeFilter(() => setAssignment(e.target.value))}><option value="">全部职责</option>{(catalog?.filters?.assignments ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select className="lg-input" aria-label="审签状态筛选" value={status} onChange={e => changeFilter(() => setStatus(e.target.value))}><option value="">全部审签状态</option>{(catalog?.filters?.statuses ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
</div>
|
||||
{view === 'tree' && tree ? <DutyTree nodes={tree} selected={docId} onSelect={selectDoc} /> : view === 'list' && catalog && <div className="rdm-table-scroll"><table className="ad-table">
|
||||
<thead><tr><th>选择</th><th>文档</th><th>类型/阶段</th><th>当前职责</th><th>审签状态</th></tr></thead>
|
||||
<tbody>{catalog.docs.map(document => <tr key={document.docId} className={document.docId === docId ? 'ad-row-on' : ''}>
|
||||
<td><input type="checkbox" aria-label={`选择 ${document.title}`} checked={selection.has(document.docId)} disabled={!canAssign || busy || (selection.size >= 20 && !selection.has(document.docId))} onChange={e => {
|
||||
if (dirty && !window.confirm('丢弃当前文档未保存的职责调整,切换到批量分配?')) return
|
||||
if (docId) { fence.cancel('detail'); setDocId(''); selectedRef.current = ''; setDetail(null); setDraft(blank()); setSub(undefined) }
|
||||
const next = new Set(selection); e.target.checked ? next.add(document.docId) : next.delete(document.docId); setSelection(next)
|
||||
}} /></td>
|
||||
<td><button className="ad-mini" onClick={() => selectDoc(document.docId)}>{document.title}</button><div className="ad-dim">{document.docId}</div></td>
|
||||
<td>{document.typeLabel || catalog.filters?.typeKeys?.find(option => option.value === document.typeKey)?.label || '文档'}{document.stage && <div className="ad-dim">{document.stageLabel || catalog.filters?.stages?.find(option => option.value === document.stage)?.label || '阶段未提供'}</div>}</td>
|
||||
<td>{document.assigned ? `编制 ${document.author || '—'} · 审核 ${(document.reviewers ?? []).join('、') || '—'} · 批准 ${document.approver || '—'}` : '未分配'}</td>
|
||||
<td>{document.activeRound ? `第 ${document.activeRound} 轮审核中` : document.statusLabel || statusLabel(document.status)}{document.activeSections ? <div className="ad-dim">{document.activeSections} 个章节审签中</div> : null}</td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>}
|
||||
{loading && <p className="ad-hint" role="status">加载文档…</p>}
|
||||
{!loading && catalog?.docs.length === 0 && <p className="ad-hint">没有匹配且可访问的文档</p>}
|
||||
{!loading && !catalog && <Btn onClick={() => { setError(''); void loadCatalog() }}>重新加载文档</Btn>}
|
||||
{catalog?.next && <Btn disabled={loading} onClick={() => loadCatalog(catalog.next)}>加载更多文档</Btn>}
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
{!canAssign && catalog && <p className="ad-hint">你可以查看有权访问的文档;分配职责需要文档职责管理权限</p>}
|
||||
{docId && !detail && <p className="ad-hint">{error ? '文档职责未能加载' : '读取当前职责…'}</p>}
|
||||
{detail && <SettingsGroup title={`${detail.title} · 当前职责`}>
|
||||
<div className="ad-row"><select className="lg-input" aria-label="职责对象" value={sectionId} onChange={e => {
|
||||
if (dirty && !window.confirm('丢弃未保存的职责调整?')) return
|
||||
const id = e.target.value; setSectionId(id); setDraft(normalizeDuties(id ? detail.sections.find(section => section.sectionId === id)?.roles : detail.roles))
|
||||
}}><option value="">整份文档默认职责</option>{detail.sections.map(section => <option key={section.sectionId} value={section.sectionId}>{section.title}{section.overridden ? ' · 单独指派' : ' · 沿用文档默认'}</option>)}</select>
|
||||
<RoleFilter roles={roles} value={roleId} onChange={value => { fence.cancel('member-candidates'); setRoleId(value) }} disabled={!canEditCurrent || !!roleError} />
|
||||
</div>
|
||||
<DutyPeople value={draft} onChange={setDraft} fetchPage={memberPage} people={people} disabled={!canEditCurrent || busy || !validVersion(currentVersion) || members?.accountDirectoryAvailable !== true} />
|
||||
{dirty && dutyValidation(draft) && <p className="ad-hint">{dutyValidation(draft)}</p>}
|
||||
{!validVersion(currentVersion) && <p className="ad-hint">职责版本尚未就绪,只能查看;请升级项目服务后重新读取</p>}
|
||||
{active && <p className="ad-hint">{active.round ? `第 ${active.round} 轮` : '章节'}审签正在进行,职责变更需要确认后重新提交</p>}
|
||||
<Btn variant="primary" disabled={!canEditCurrent || busy || !dirty || !validVersion(currentVersion) || !!dutyValidation(draft)} onClick={save}>保存当前职责</Btn>
|
||||
<p className="ad-hint">当前职责与提交时冻结名单独立。项目管理权限不代表编制、审核或批准资格</p>
|
||||
</SettingsGroup>}
|
||||
{detail && <SettingsGroup title={sub ? `整文审签 · 第 ${sub.round} 轮 · ${statusLabel(sub.state)}` : '整文审签'}>
|
||||
<Btn disabled={busy || !!writeError || !canSubmit} onClick={() => run('整文已提交审核', () => api.rdmProjectCommand(project, 'rdm.review.doc.submit', docSubmitPayload(project.id, docId)))}>整文提交(编制人)</Btn>
|
||||
{sub && <>
|
||||
<p>第 {sub.round} 轮 · {statusLabel(sub.state)}</p>
|
||||
<p className="ad-hint">冻结名单:编制 {submissionDuties.author} · 审核 {submissionDuties.reviewers.join('、')} · 批准 {submissionDuties.approver}</p>
|
||||
<p className="ad-hint">审核进度:{submissionDuties.reviewers.map(owner => `${owner} ${sub.reviews?.[owner] ? '已通过' : '待审核'}`).join(';')}</p>
|
||||
<div className="ad-row"><Btn disabled={busy || !!writeError || !canReview} onClick={() => run('已完成审核', () => api.rdmProjectCommand(project, 'rdm.review.doc.approve', docApprovePayload(project.id, docId, '通过')))}>审核通过</Btn>
|
||||
<Btn disabled={busy || !!writeError || !canReview} onClick={() => { const reason = window.prompt('请输入驳回原因'); if (reason?.trim()) void run('已驳回整文', () => api.rdmProjectCommand(project, 'rdm.review.doc.reject', docRejectPayload(project.id, docId, reason.trim()))) }}>驳回</Btn>
|
||||
<Btn variant="primary" disabled={busy || !!writeError || !canFinal} onClick={() => run('整文已批准', () => api.rdmProjectCommand(project, 'rdm.review.doc.final', docFinalPayload(project.id, docId, sub.round)))}>终批(批准人)</Btn></div>
|
||||
</>}
|
||||
{sub === null && <p className="ad-hint">尚无整文提交记录</p>}
|
||||
{sub === undefined && <p className="ad-hint">审签状态尚未核验,暂不能执行审签操作</p>}
|
||||
</SettingsGroup>}
|
||||
{selection.size > 0 && <SettingsGroup title={`批量分配 · 已选 ${selection.size} 份文档`}>
|
||||
<p className="ad-hint">最多选择 20 份;提交前读取各文档版本、预览影响,任一失败整批不写入</p>
|
||||
<RoleFilter roles={roles} value={roleId} onChange={setRoleId} disabled={!canAssign || !!roleError} />
|
||||
<DutyPeople value={batchDraft} onChange={setBatchDraft} fetchPage={memberPage} people={people} disabled={!canAssign || busy || members?.accountDirectoryAvailable !== true} labelPrefix="批量" />
|
||||
<div className="ad-row"><Btn variant="primary" disabled={!canAssign || busy || !!dutyValidation(batchDraft)} onClick={batchApply}>预览并分配 {selection.size} 份文档</Btn>
|
||||
<Btn disabled={busy} onClick={() => { if (!batchDirty || window.confirm('清空已选文档及未保存的批量职责?')) { fence.cancel('batch-preview'); setSelection(new Set()); setBatchDraft(blank()) } }}>清空选择</Btn></div>
|
||||
</SettingsGroup>}
|
||||
<Feedback error={error || roleError} notice={notice} />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Btn, Picker } from '@ui'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { memberAddPayload, memberRemovePayload, memberSetRolePayload } from '../rdmManagement'
|
||||
import { Feedback, personItem, personLabel, projectContext, projectRoleLabel, readableError,
|
||||
RoleFilter, useRequestFence, useRoleOptions, type Person, type MemberList } from './rdmSettingsShared'
|
||||
|
||||
export function RdmMembersSection({ project }: { project: Project }) {
|
||||
const owner = useStore(s => s.me?.owner)
|
||||
const context = projectContext(project, owner)
|
||||
const fence = useRequestFence(context)
|
||||
const writeError = projectWorkError(project)
|
||||
const [list, setList] = useState<MemberList | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [picked, setPicked] = useState<string[]>([])
|
||||
const [role, setRole] = useState<'member' | 'owner'>('member')
|
||||
const [roleId, setRoleId] = useState('')
|
||||
const [match, setMatch] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [impact, setImpact] = useState<{ member: string; canRemove: boolean; lastOwner: boolean; reasons: string[]; documents: { docId: string; title?: string; label?: string; sectionId?: string; activeRound?: number }[] } | null>(null)
|
||||
const { roles, error: roleError } = useRoleOptions(project, 'members', list?.canManageMembers === true)
|
||||
const candidates = useRef(new Map<string, Person>())
|
||||
const canManage = list?.canManageMembers === true && !writeError
|
||||
const directoryReady = list?.accountDirectoryAvailable === true
|
||||
const rowsRef = useRef(list?.members ?? [])
|
||||
rowsRef.current = list?.members ?? []
|
||||
|
||||
const refresh = async (cursor = '') => {
|
||||
const current = fence.begin('members')
|
||||
try {
|
||||
const out = await api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { cursor, limit: 50 })
|
||||
if (!current()) return false
|
||||
if (!Array.isArray(out.members)) throw new Error('成员查询接口未就绪')
|
||||
setList(prev => cursor && prev ? { ...out, members: [...prev.members, ...out.members] } : out)
|
||||
return true
|
||||
} catch (e) { if (current()) { setError(readableError(e)); if (!cursor) setList(null) } return false }
|
||||
}
|
||||
useEffect(() => {
|
||||
setList(null); setPicked([]); candidates.current.clear(); setRoleId(''); setRole('member')
|
||||
setError(''); setNotice(''); setBusy(false); setImpact(null)
|
||||
void refresh()
|
||||
}, [context])
|
||||
|
||||
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const current = fence.begin('candidates')
|
||||
const out = await api.rdmProjectCommand<{ accounts: Person[]; next?: string }>(project, 'rdm.account.list', { query, cursor, limit, roleId, match })
|
||||
if (!current()) return { items: [] }
|
||||
if (!Array.isArray(out.accounts)) throw new Error('账号目录接口未就绪')
|
||||
const joined = new Set(rowsRef.current.map(person => person.owner))
|
||||
return { items: out.accounts.map(person => {
|
||||
const candidate = { ...person, joined: person.joined === true || joined.has(person.owner) }
|
||||
candidates.current.set(candidate.owner, candidate)
|
||||
return personItem(candidate, true)
|
||||
}), nextCursor: out.next || undefined }
|
||||
}, [project, roleId, match, context])
|
||||
|
||||
const run = async (message: string, mutation: () => Promise<unknown>, clearPick = false) => {
|
||||
if (!canManage || busy) return
|
||||
const current = fence.begin('mutation')
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
try {
|
||||
await mutation()
|
||||
if (!current()) return
|
||||
if (clearPick) setPicked([])
|
||||
const verified = await refresh()
|
||||
if (current() && verified) setNotice(message)
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
setError(`${readableError(e)};已重新读取成员状态,请核对后再操作`)
|
||||
await refresh()
|
||||
} finally { if (current()) setBusy(false) }
|
||||
}
|
||||
const pickedPerson = candidates.current.get(picked[0] || '')
|
||||
const addable = pickedPerson?.valid === true && pickedPerson.joined === false
|
||||
|
||||
const previewRemove = async (person: Person) => {
|
||||
if (!canManage || busy) return
|
||||
const current = fence.begin('impact'); setBusy(true); setError(''); setImpact(null)
|
||||
try {
|
||||
const out = await api.rdmProjectCommand<NonNullable<typeof impact>>(project, 'rdm.project.member.impact', { member: person.owner })
|
||||
if (!current()) return
|
||||
if (out.member !== person.owner || typeof out.canRemove !== 'boolean' || !Array.isArray(out.documents) || !Array.isArray(out.reasons)) throw new Error('成员职责影响查询尚未就绪,暂不能移除成员')
|
||||
setImpact(out)
|
||||
} catch (e) { if (current()) setError(readableError(e)) }
|
||||
finally { if (current()) setBusy(false) }
|
||||
}
|
||||
|
||||
return <div className="rdm-settings">
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<RoleFilter roles={roles} value={roleId} disabled={!canManage || !!roleError} onChange={value => { fence.cancel('candidates'); candidates.current.clear(); setRoleId(value); setPicked([]) }} />
|
||||
<select aria-label="人员匹配方式" className="lg-input" value={match} disabled={!canManage} onChange={e => { fence.cancel('candidates'); setMatch(e.target.value as 'exact' | 'fuzzy'); setPicked([]) }}>
|
||||
<option value="fuzzy">模糊匹配</option><option value="exact">精确账号或姓名</option>
|
||||
</select>
|
||||
<Picker aria-label="选择人员" selected={picked} onSelect={setPicked} fetchPage={fetchPage}
|
||||
selectedItems={pickedPerson ? [personItem(pickedPerson)] : []} disabled={!canManage || !directoryReady || busy}
|
||||
reloadKey={`${context}:${roleId}:${match}:${list?.members.map(p => p.owner).join(',')}`}
|
||||
placeholder="输入账号或姓名,或浏览候选…" />
|
||||
<select aria-label="新成员项目身份" className="lg-input" value={role} disabled={!canManage || busy} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">项目成员</option><option value="owner">项目负责人</option>
|
||||
</select>
|
||||
<Btn variant="primary" disabled={!canManage || !directoryReady || !addable || busy} onClick={() => {
|
||||
if (!pickedPerson || !addable) return
|
||||
void run(`已添加 ${personLabel(pickedPerson)}`, () => api.rdmProjectCommand(project, 'rdm.project.member.add', memberAddPayload(project.id, pickedPerson.owner, role)), true)
|
||||
}}>添加成员</Btn>
|
||||
</div>
|
||||
<p className="ad-hint">专业角色用于查找人员,项目身份用于项目管理。已加入人员请在下表调整身份;最后一位项目负责人不可移除或降级</p>
|
||||
{list && !list.canManageMembers && <p className="ad-hint">你可以查看成员;添加、调整与移除需要管理项目成员权限</p>}
|
||||
{list && !directoryReady && <p className="ad-hint">系统账号目录尚未就绪,暂不能添加成员</p>}
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
</div>
|
||||
<div className="rdm-table-scroll"><table className="ad-table">
|
||||
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th>操作</th></tr></thead>
|
||||
<tbody>{(list?.members ?? []).map(person => <tr key={person.owner}>
|
||||
<td>{personLabel(person)}{person.name && <span className="ad-dim"> · {person.owner}</span>}</td>
|
||||
<td>{person.profRole?.name || '未配置专业角色'}</td><td>{projectRoleLabel(person.role)}</td>
|
||||
<td className="ad-dim">{person.addedBy || '—'}</td><td className="ad-dim">{person.addedAt ? new Date(person.addedAt).toLocaleString() : '—'}</td>
|
||||
<td><button className="ad-mini" disabled={!canManage || busy} onClick={() => {
|
||||
const next = person.role === 'owner' ? 'member' : 'owner'
|
||||
if (window.confirm(`将「${personLabel(person)}」调整为${projectRoleLabel(next)}?`)) void run('已调整项目身份', () => api.rdmProjectCommand(project, 'rdm.project.member.set-role', memberSetRolePayload(project.id, person.owner, next)))
|
||||
}}>{person.role === 'owner' ? '降为成员' : '升为负责人'}</button>{' '}
|
||||
<button className="ad-mini" disabled={!canManage || busy} onClick={() => previewRemove(person)}>移除</button></td>
|
||||
</tr>)}{!list && <tr><td colSpan={6}>{error ? '成员未能加载' : '加载中…'}</td></tr>}
|
||||
{list?.members.length === 0 && <tr><td colSpan={6}>暂无成员</td></tr>}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{list?.next && <Btn disabled={busy} onClick={() => refresh(list.next)}>加载更多成员</Btn>}
|
||||
{error && !list && <Btn onClick={() => { setError(''); void refresh() }}>重试</Btn>}
|
||||
{impact && <div className="settings-group"><h4>移除成员 · {list?.members.find(person => person.owner === impact.member)?.name || impact.member}</h4>
|
||||
{impact.documents.length > 0 && <><p className="ad-hint">该成员仍承担以下文档职责,请先在文档职责中完成交接</p><ul>{impact.documents.map(document => <li key={`${document.docId}:${document.sectionId || ''}`}>
|
||||
{document.title || document.label || '受控文档'} <span className="ad-dim">{document.docId}{document.sectionId ? ' · 章节职责' : ''}{document.activeRound ? ` · 第 ${document.activeRound} 轮审核中` : ''}</span>
|
||||
</li>)}</ul></>}
|
||||
{impact.reasons.map(reason => <p key={reason} className="ad-hint">{reason}</p>)}
|
||||
{impact.lastOwner && <p className="ad-hint">最后一位项目负责人不能移除,请先指定其他负责人</p>}
|
||||
<div className="ad-row"><Btn disabled={busy || !canManage || !impact.canRemove || impact.lastOwner || impact.documents.length > 0} onClick={() => {
|
||||
if (window.confirm(`确认移除「${impact.member}」?`)) void run('已移除项目成员', async () => { await api.rdmProjectCommand(project, 'rdm.project.member.remove', memberRemovePayload(project.id, impact.member)); setImpact(null) })
|
||||
}}>确认移除</Btn><Btn disabled={busy} onClick={() => { fence.cancel('impact'); setImpact(null) }}>取消</Btn></div>
|
||||
</div>}
|
||||
<Feedback error={error || roleError} notice={notice} />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Btn, Collapsible, Picker } from '@ui'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { actionLabel, describeSource } from '../rdmManagement'
|
||||
import { Feedback, personItem, projectContext, projectRoleLabel, readableError, RoleFilter,
|
||||
useRequestFence, useRoleOptions, type MemberList } from './rdmSettingsShared'
|
||||
|
||||
export interface MatrixAction {
|
||||
action: string; label?: string; scope?: string; allowed: boolean; defaultAllowed?: boolean; capAllowed?: boolean
|
||||
override?: null | boolean; source: string; sourceLabel?: string; reason?: string
|
||||
}
|
||||
interface MatrixRow {
|
||||
member: string; name?: string; role: string; profRole?: { roleId: string; name: string } | null; actions: MatrixAction[]
|
||||
}
|
||||
interface MatrixOut extends MatrixRow { projectId: string; canConfigurePermissions?: boolean }
|
||||
interface MatrixBatch { projectId: string; rows: MatrixRow[]; next?: string; count?: number; canConfigurePermissions?: boolean }
|
||||
export function projectActions(actions: MatrixAction[]): MatrixAction[] {
|
||||
return actions.filter(action => action.scope === 'project' && action.action !== 'project.create')
|
||||
}
|
||||
export function overrideMode(action: MatrixAction): 'default' | 'allow' | 'deny' | 'unavailable' {
|
||||
return action.override === null ? 'default' : action.override === true ? 'allow' : action.override === false ? 'deny' : 'unavailable'
|
||||
}
|
||||
const actionGroup = (action: string) => action.startsWith('doc.') || action.startsWith('review.') || action === 'duty.assign' ? 'documents'
|
||||
: action.startsWith('trace.') || action.startsWith('knowledge.') ? 'knowledge' : 'project'
|
||||
const reasonText = (action: MatrixAction) => {
|
||||
if (!action.reason) return action.allowed ? '具备项目动作权限;具体资源仍按职责与状态核验' : '当前项目策略未允许'
|
||||
if (action.reason.includes('project.create') || action.reason === action.action) return '配置范围不符'
|
||||
return /[\u4e00-\u9fff]/.test(action.reason) ? action.reason : '当前策略未允许,请联系项目负责人核对'
|
||||
}
|
||||
export function ActionDetailTable({ actions, canManage = false, busy = false, onOverride }: {
|
||||
actions: MatrixAction[]; canManage?: boolean; busy?: boolean
|
||||
onOverride?: (action: string, mode: 'default' | 'allow' | 'deny') => void
|
||||
}) {
|
||||
return <div className="rdm-table-scroll"><table className="ad-table">
|
||||
<thead><tr><th>项目操作</th><th>权限</th><th>来源</th><th>说明</th>{canManage && <th>项目内配置</th>}</tr></thead>
|
||||
<tbody>{projectActions(actions).map(action => {
|
||||
const mode = overrideMode(action)
|
||||
const supported = mode !== 'unavailable' && typeof action.capAllowed === 'boolean' && typeof action.defaultAllowed === 'boolean'
|
||||
return <tr key={action.action}><td>{actionLabel(action.action, action.label)}</td>
|
||||
<td><span className={`ad-chip${action.allowed ? ' ad-chip-admin' : ' ad-chip-warn'}`}>{action.allowed ? '允许' : '禁止'}</span></td>
|
||||
<td>{action.sourceLabel && action.sourceLabel !== action.source ? action.sourceLabel : describeSource(action.source)}</td>
|
||||
<td>{reasonText(action)}<Collapsible trigger="查看权限依据">
|
||||
<p className="ad-hint">角色默认:{action.defaultAllowed === true ? '允许' : action.defaultAllowed === false ? '禁止' : '未提供'} · 管理员上限:{action.capAllowed === true ? '允许' : action.capAllowed === false ? '禁止' : '未提供'}</p>
|
||||
<p className="ad-hint">项目内配置:{mode === 'default' ? '沿用默认' : mode === 'allow' ? '允许' : mode === 'deny' ? '禁止' : '尚未核验'}</p>
|
||||
</Collapsible></td>
|
||||
{canManage && <td><select className="lg-input" aria-label={`${actionLabel(action.action, action.label)}项目覆盖`} value={mode} disabled={busy || !supported}
|
||||
onChange={e => onOverride?.(action.action, e.target.value as 'default' | 'allow' | 'deny')}>
|
||||
{!supported && <option value="unavailable">配置尚未核验</option>}
|
||||
<option value="default">沿用角色默认</option><option value="allow" disabled={action.capAllowed !== true}>项目内允许</option><option value="deny">项目内禁止</option>
|
||||
</select>{action.capAllowed === false && <p className="ad-hint">管理员上限禁止授予</p>}</td>}
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</table></div>
|
||||
}
|
||||
|
||||
export function RdmPermMatrixSection({ project }: { project: Project }) {
|
||||
const me = useStore(s => s.me)
|
||||
const context = projectContext(project, me?.owner)
|
||||
const fence = useRequestFence(context)
|
||||
const writeError = projectWorkError(project)
|
||||
const [view, setView] = useState<'member' | 'matrix'>('member')
|
||||
const [member, setMember] = useState(me?.owner || '')
|
||||
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
|
||||
const [batch, setBatch] = useState<MatrixBatch | null>(null)
|
||||
const [canConfigure, setCanConfigure] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [roleId, setRoleId] = useState('')
|
||||
const [projectRole, setProjectRole] = useState('')
|
||||
const [group, setGroup] = useState('all')
|
||||
const [expanded, setExpanded] = useState<{ member: string; action: string } | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const { roles, error: roleError } = useRoleOptions(project, 'permissions', canConfigure)
|
||||
const queryMember = async (owner: string) => {
|
||||
if (!owner) return false
|
||||
const current = fence.begin('read')
|
||||
setMatrix(null); setLoading(true); setError('')
|
||||
try {
|
||||
const out = await api.rdmProjectCommand<MatrixOut>(project, 'rdm.perm.check', { member: owner })
|
||||
if (!current()) return false
|
||||
if (out.member !== owner || !Array.isArray(out.actions)) throw new Error('权限查询结果与成员不一致')
|
||||
setCanConfigure(out.canConfigurePermissions === true)
|
||||
setMatrix(out)
|
||||
return true
|
||||
} catch (e) { if (current()) { setError(readableError(e)); setCanConfigure(false) } return false }
|
||||
finally { if (current()) setLoading(false) }
|
||||
}
|
||||
const queryBatch = async (cursor = '') => {
|
||||
if (!canConfigure) return false
|
||||
const current = fence.begin('read')
|
||||
setLoading(true); setError(''); if (!cursor) setBatch(null)
|
||||
try {
|
||||
const out = await api.rdmProjectCommand<MatrixBatch>(project, 'rdm.perm.matrix', { query, roleId, projectRole, cursor, limit: 20 })
|
||||
if (!current()) return false
|
||||
if (out.canConfigurePermissions !== true || !Array.isArray(out.rows)) throw new Error('没有查看项目权限矩阵的能力')
|
||||
setBatch(previous => cursor && previous ? { ...out, rows: [...previous.rows, ...out.rows] } : out)
|
||||
return true
|
||||
} catch (e) { if (current()) setError(readableError(e)); return false }
|
||||
finally { if (current()) setLoading(false) }
|
||||
}
|
||||
useEffect(() => {
|
||||
fence.invalidate(); setView('member'); setMember(me?.owner || ''); setBatch(null); setMatrix(null)
|
||||
setCanConfigure(false); setExpanded(null); setQuery(''); setRoleId(''); setProjectRole(''); setBusy(false); setNotice(''); setError('')
|
||||
void queryMember(me?.owner || '')
|
||||
}, [context])
|
||||
useEffect(() => {
|
||||
if (view !== 'matrix' || !canConfigure) return
|
||||
fence.cancel('read'); setExpanded(null)
|
||||
const timer = setTimeout(() => { void queryBatch() }, query ? 250 : 0)
|
||||
return () => { clearTimeout(timer); fence.cancel('read') }
|
||||
}, [view, query, roleId, projectRole, canConfigure])
|
||||
const memberPage = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const out = await api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { query, roleId, projectRole, cursor, limit })
|
||||
if (!Array.isArray(out.members) || out.canConfigurePermissions !== true) throw new Error('无权选择其他项目成员')
|
||||
return { items: out.members.map(person => personItem(person)), nextCursor: out.next || undefined }
|
||||
}, [project, roleId, projectRole])
|
||||
const override = async (owner: string, actionId: string, mode: 'allow' | 'deny' | 'default') => {
|
||||
const row = view === 'member' ? matrix : batch?.rows.find(row => row.member === owner)
|
||||
const action = row?.actions.find(action => action.action === actionId && action.scope === 'project' && action.action !== 'project.create')
|
||||
if (!canConfigure || writeError || busy || !action || overrideMode(action) === 'unavailable' || (mode === 'allow' && action.capAllowed !== true)) return
|
||||
const current = fence.begin('mutation')
|
||||
setBusy(true); setNotice(''); setError('')
|
||||
try {
|
||||
await api.rdmProjectCommand(project, mode === 'default' ? 'rdm.perm.unset' : 'rdm.perm.set',
|
||||
{ member: owner, action: actionId, ...(mode === 'default' ? {} : { allow: mode === 'allow' }) })
|
||||
if (!current()) return
|
||||
const verified = view === 'matrix' ? await queryBatch() : await queryMember(owner)
|
||||
if (current() && verified) setNotice(`${actionLabel(actionId)}:${mode === 'default' ? '已恢复角色默认' : mode === 'allow' ? '已配置为项目内允许' : '已配置为项目内禁止'}`)
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
const failure = readableError(e)
|
||||
if (view === 'matrix') await queryBatch(); else await queryMember(owner)
|
||||
if (current()) setError(`${failure};已尝试核对最新权限,未自动重试写入`)
|
||||
} finally { if (current()) setBusy(false) }
|
||||
}
|
||||
const visible = (actions: MatrixAction[]) => projectActions(actions).filter(action => group === 'all' || actionGroup(action.action) === group)
|
||||
const matrixActions = [...new Map((batch?.rows ?? []).flatMap(row => visible(row.actions)).map(action => [action.action, action])).values()]
|
||||
const expandedRow = expanded ? batch?.rows.find(row => row.member === expanded.member) : undefined
|
||||
return <div className="rdm-settings">
|
||||
<div className="ad-create"><div className="ad-row">
|
||||
<select aria-label="权限查看方式" className="lg-input" value={view} disabled={busy} onChange={e => {
|
||||
const next = e.target.value as 'member' | 'matrix'
|
||||
if (next === 'matrix' && !canConfigure) return
|
||||
fence.cancel('read'); fence.cancel('mutation'); setExpanded(null); setNotice(''); setView(next)
|
||||
if (next === 'member') void queryMember(member || me?.owner || '')
|
||||
}}><option value="member">按成员查看</option><option value="matrix" disabled={!canConfigure}>权限矩阵</option></select>
|
||||
<select aria-label="权限分组" className="lg-input" value={group} onChange={e => { setGroup(e.target.value); setExpanded(null) }}>
|
||||
<option value="all">全部项目操作</option><option value="project">项目与成员</option><option value="documents">文档与审签</option><option value="knowledge">知识与追溯</option>
|
||||
</select>
|
||||
<RoleFilter roles={roles} value={roleId} disabled={!canConfigure || !!roleError || busy} onChange={value => { fence.cancel('read'); setRoleId(value); setExpanded(null) }} />
|
||||
<select aria-label="项目身份筛选" className="lg-input" value={projectRole} disabled={!canConfigure || busy} onChange={e => { fence.cancel('read'); setProjectRole(e.target.value); setExpanded(null) }}>
|
||||
<option value="">全部项目身份</option><option value="owner">项目负责人</option><option value="member">项目成员</option>
|
||||
</select>
|
||||
{view === 'member' && <Picker aria-label="选择权限成员" selected={member ? [member] : []}
|
||||
selectedItems={[{ id: member, title: matrix?.name || (member === me?.owner ? me?.name || member : member), subtitle: member }]}
|
||||
onSelect={ids => { fence.cancel('read'); setMember(ids[0] || me?.owner || ''); void queryMember(ids[0] || me?.owner || '') }}
|
||||
fetchPage={memberPage} disabled={!canConfigure || busy} reloadKey={`${context}:${roleId}:${projectRole}`} />}
|
||||
{view === 'matrix' && <input aria-label="搜索权限成员" className="lg-input" placeholder="输入账号或姓名" value={query} onChange={e => { fence.cancel('read'); setBatch(null); setQuery(e.target.value) }} />}
|
||||
<Btn disabled={loading || busy} onClick={() => view === 'matrix' ? queryBatch() : queryMember(member)}>刷新权限</Btn>
|
||||
</div>
|
||||
<p className="ad-hint">默认显示自己的权限。查看其他成员、矩阵和配置需要权限管理能力;文档操作仍按具体资源、职责和审签状态核验</p>
|
||||
</div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
{loading && <p className="ad-hint" role="status">核验项目权限…</p>}
|
||||
{view === 'member' && matrix && <>
|
||||
<p>{matrix.name || matrix.member} · {projectRoleLabel(matrix.role)}{matrix.profRole?.name ? ` · ${matrix.profRole.name}` : ''}</p>
|
||||
<ActionDetailTable actions={visible(matrix.actions)} canManage={canConfigure && !writeError} busy={busy} onOverride={(action, mode) => override(matrix.member, action, mode)} />
|
||||
</>}
|
||||
{view === 'matrix' && batch && <>
|
||||
<div className="rdm-table-scroll"><table className="ad-table">
|
||||
<thead><tr><th>成员</th>{matrixActions.map(action => <th key={action.action}>{actionLabel(action.action, action.label)}</th>)}</tr></thead>
|
||||
<tbody>{batch.rows.map(row => <tr key={row.member}><td>{row.name || row.member}<div className="ad-dim">{projectRoleLabel(row.role)}{row.profRole?.name ? ` · ${row.profRole.name}` : ''}</div></td>
|
||||
{matrixActions.map(action => {
|
||||
const permission = row.actions.find(item => item.action === action.action && item.scope === 'project')
|
||||
return <td key={action.action}>{permission ? <button className="ad-mini" aria-label={`${row.name || row.member} · ${actionLabel(action.action)} · ${permission.allowed ? '允许' : '禁止'}`}
|
||||
onClick={() => setExpanded({ member: row.member, action: action.action })}>{permission.allowed ? '允许' : '禁止'}</button> : '未核验'}</td>
|
||||
})}</tr>)}</tbody>
|
||||
</table></div>
|
||||
{batch.rows.length === 0 && <p className="ad-hint">没有匹配的项目成员</p>}
|
||||
{batch.next && <Btn disabled={loading || busy} onClick={() => queryBatch(batch.next)}>加载更多成员</Btn>}
|
||||
{expanded && <div className="settings-group"><h4>{expandedRow?.name || expanded.member} · 权限依据</h4>
|
||||
<ActionDetailTable actions={expandedRow?.actions.filter(action => action.action === expanded.action) || []}
|
||||
canManage={canConfigure && !writeError} busy={busy} onOverride={(action, mode) => override(expanded.member, action, mode)} /></div>}
|
||||
</>}
|
||||
<Feedback error={error || (canConfigure ? roleError : '')} notice={notice} />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Btn, SettingsGroup, SettingRow } from '@ui'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
|
||||
const groupNames: Record<string, string> = {
|
||||
models: '模型与密钥', runtime: '运行环境', context: '上下文', retrieval: '检索',
|
||||
ui: '界面', plugins: '系统插件', crossSession: '个人会话协作', tools: '工具服务', deliverables: '项目交付规则', telemetry: '运行观测',
|
||||
}
|
||||
const optionLabels: Record<string, string> = { modern: '现代', formal: '正式', minimal: '简洁',
|
||||
dark: '深色', light: '浅色', system: '跟随系统', zh: '中文', en: '英文', auto: '自动', off: '关闭', on: '开启',
|
||||
disabled: '关闭', enabled: '开启', relevant: '相关会话', all: '全部本人会话', none: '关闭',
|
||||
direct: '直接对话', squad: '协作小队', zhCN: '简体中文', 'zh-CN': '简体中文', 'en-US': '英语',
|
||||
}
|
||||
function readableValue(value: unknown) {
|
||||
if (value === true) return '开启'
|
||||
if (value === false) return '关闭'
|
||||
if (value === undefined || value === null || value === '') return '未配置'
|
||||
const text = String(value)
|
||||
return optionLabels[text] || text
|
||||
}
|
||||
export function scopedSchema(schema: SettingDesc[], scope: string) {
|
||||
return schema.filter(desc => !desc.scope
|
||||
? scope === 'user' || scope === 'project-personal'
|
||||
: desc.scope === scope || (scope === 'project-personal' && desc.scope === 'user'))
|
||||
}
|
||||
export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } {
|
||||
return desc.secret || desc.type === 'secret' ? { type: 'password', autoComplete: 'new-password' } : { type: 'text', autoComplete: 'off' }
|
||||
}
|
||||
export function SettingInput({ desc, value, onChange, disabled = false }: { desc: SettingDesc; value: any; onChange: (value: any) => void; disabled?: boolean }) {
|
||||
if (desc.type === 'boolean') return <select aria-label={desc.label} value={value === true ? 'true' : value === false ? 'false' : ''} disabled={disabled}
|
||||
onChange={e => onChange(e.target.value === '' ? undefined : e.target.value === 'true')}>
|
||||
<option value="">沿用默认({readableValue(desc.default)})</option><option value="true">开启</option><option value="false">关闭</option>
|
||||
</select>
|
||||
if (desc.type === 'enum') return <select aria-label={desc.label} value={value ?? ''} disabled={disabled} onChange={e => onChange(e.target.value || undefined)}>
|
||||
<option value="">沿用默认({readableValue(desc.default)})</option>
|
||||
{(desc.options ?? []).map(option => <option key={option} value={option}>{optionLabels[option] || option}</option>)}
|
||||
</select>
|
||||
if (desc.type === 'number') return <input aria-label={desc.label} type="number" step="any" disabled={disabled} value={value ?? ''}
|
||||
placeholder={`默认:${readableValue(desc.default)}`} onChange={e => onChange(e.target.value === '' || !Number.isFinite(e.target.valueAsNumber) ? undefined : e.target.valueAsNumber)} />
|
||||
const secret = desc.secret || desc.type === 'secret'
|
||||
return <input aria-label={desc.label} {...settingInputAttrs(desc)} disabled={disabled} value={value === '••••••' ? '' : value ?? ''}
|
||||
placeholder={secret ? value === '••••••' ? '已配置,输入以替换' : '未配置,输入密钥' : `默认:${readableValue(desc.default)}`}
|
||||
onChange={e => onChange(secret ? e.target.value || undefined : e.target.value)} />
|
||||
}
|
||||
export function SettingsFields({ schema, draft, defaults = {}, onChange, disabled = false, source = '个人配置' }: {
|
||||
schema: SettingDesc[]; draft: Record<string, any>; defaults?: Record<string, any>
|
||||
onChange: (key: string, value: any) => void; disabled?: boolean; source?: string
|
||||
}) {
|
||||
const groups: Record<string, SettingDesc[]> = {}
|
||||
for (const desc of schema) (groups[desc.group] ??= []).push(desc)
|
||||
return <>{Object.entries(groups).map(([group, descs]) => <SettingsGroup key={group} title={groupNames[group] || '其他设置'}>
|
||||
{descs.map(desc => <SettingRow key={desc.key} label={desc.label} effect={desc.effect}
|
||||
desc={`${desc.description || ''}${desc.description ? ' · ' : ''}${draft[desc.key] === undefined ? `沿用默认:${desc.secret ? '受保护配置' : readableValue(defaults[desc.key] ?? desc.default)}` : source}`}>
|
||||
<SettingInput desc={{ ...desc, default: defaults[desc.key] ?? desc.default }} value={draft[desc.key]} disabled={disabled} onChange={value => onChange(desc.key, value)} />
|
||||
{desc.type === 'string' && !desc.secret && <Btn disabled={disabled || draft[desc.key] === undefined} onClick={() => onChange(desc.key, undefined)}>沿用默认</Btn>}
|
||||
</SettingRow>)}
|
||||
</SettingsGroup>)}</>
|
||||
}
|
||||
export function flatten(map: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
const visit = (object: Record<string, any>, prefix = '') => {
|
||||
for (const [key, value] of Object.entries(object || {})) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) visit(value, path)
|
||||
else out[path] = value
|
||||
}
|
||||
}
|
||||
visit(map); return out
|
||||
}
|
||||
function unflatten(map: Record<string, any>, preserveEmpty = false): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
for (const [path, value] of Object.entries(map)) {
|
||||
if (value === undefined || (value === '' && !preserveEmpty)) continue
|
||||
const parts = path.split('.')
|
||||
let current = out
|
||||
for (const part of parts.slice(0, -1)) current = current[part] ??= {}
|
||||
current[parts[parts.length - 1]] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
export function scopedDraft(values: Record<string, unknown>, schema: SettingDesc[]) {
|
||||
const keys = new Set(schema.map(desc => desc.key))
|
||||
return Object.fromEntries(Object.entries(flatten(values)).filter(([key]) => keys.has(key)))
|
||||
}
|
||||
/** A settings write is a merge patch; omitted overrides are deliberately removed. */
|
||||
export function settingsMutation(initial: Record<string, any>, draft: Record<string, any>, schema: SettingDesc[]) {
|
||||
const changed: Record<string, any> = {}
|
||||
const unset: string[] = []
|
||||
for (const desc of schema) {
|
||||
const next = draft[desc.key]
|
||||
if (Object.is(initial[desc.key], next)) continue
|
||||
if (desc.secret || desc.type === 'secret') {
|
||||
if (next === undefined || next === '••••••') continue
|
||||
changed[desc.key] = next
|
||||
} else if (next === undefined) unset.push(desc.key)
|
||||
else changed[desc.key] = next
|
||||
}
|
||||
return { values: unflatten(changed, true), unset }
|
||||
}
|
||||
export function personalSettingsMutation(initial: Record<string, any>, draft: Record<string, any>, schema: SettingDesc[]) {
|
||||
const mutation = settingsMutation(initial, draft, schema)
|
||||
const values = flatten(mutation.values)
|
||||
for (const key of mutation.unset) values[key] = null
|
||||
return unflatten(values, true)
|
||||
}
|
||||
@@ -1,165 +1,110 @@
|
||||
// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义,
|
||||
// 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。
|
||||
// 重设计方案 阶段②(IA):设置瘦身——只留个人偏好(常规);智能体画像/插件管理/
|
||||
// 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type SettingsView } from '../api'
|
||||
import { isFontSizeChoice, store, useStore } from '../store'
|
||||
import type { ThemeChoice } from '../theme'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
|
||||
import { SettingsShell, SettingRow, SettingsGroup, Btn, EmptyState } from '@ui'
|
||||
import { BorrowPanels } from './BorrowPanels'
|
||||
import { flatten, scopedDraft, scopedSchema, SettingsFields, personalSettingsMutation } from './SettingsFields'
|
||||
import { Feedback, readableError, useRequestFence } from './rdmSettingsShared'
|
||||
|
||||
type Section = 'settings'
|
||||
|
||||
// 分组显示名(项目设置面板共用)。
|
||||
export const groupNames: Record<string, string> = {
|
||||
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
|
||||
ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具',
|
||||
}
|
||||
export { SettingInput, settingInputAttrs } from './SettingsFields'
|
||||
|
||||
export function SettingsModal() {
|
||||
const [section] = useState<Section>('settings')
|
||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||
const [, setUser] = useState<Record<string, any>>({})
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [saved, setSaved] = useState(false)
|
||||
const owner = useStore(s => s.me?.owner)
|
||||
const projectId = useStore(s => s.currentProjectId)
|
||||
const projectName = useStore(s => s.projects.find(project => project.id === projectId)?.name)
|
||||
const theme = useStore(s => s.ui.theme)
|
||||
const fontSize = useStore(s => s.ui.fontSize)
|
||||
|
||||
const fence = useRequestFence(`${owner || ''}:${projectId || ''}`)
|
||||
const [section, setSection] = useState('settings')
|
||||
const [settings, setSettings] = useState<SettingsView | null>(null)
|
||||
const [projectSettings, setProjectSettings] = useState<SettingsView | null>(null)
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [personalDraft, setPersonalDraft] = useState<Record<string, any>>({})
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const schema = settings ? scopedSchema(settings.schema, 'user') : []
|
||||
const personalSchema = projectSettings ? scopedSchema(projectSettings.personalSchema || [], 'project-personal') : []
|
||||
const initial = settings ? scopedDraft(settings.user, schema) : {}
|
||||
const personalInitial = projectSettings ? scopedDraft(projectSettings.personalProject || {}, personalSchema) : {}
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(initial) || JSON.stringify(personalDraft) !== JSON.stringify(personalInitial)
|
||||
const load = async (projectOnly = false) => {
|
||||
const current = fence.begin('load')
|
||||
setError('')
|
||||
try {
|
||||
if (!projectOnly) {
|
||||
const out = await api.getSettings()
|
||||
if (!current()) return false
|
||||
setSettings(out); setDraft(scopedDraft(out.user, scopedSchema(out.schema, 'user')))
|
||||
}
|
||||
if (projectId) {
|
||||
const out = await api.getSettings(projectId)
|
||||
if (!current()) return false
|
||||
const personal = scopedSchema(out.personalSchema || [], 'project-personal')
|
||||
setProjectSettings(out); setPersonalDraft(scopedDraft(out.personalProject || {}, personal))
|
||||
}
|
||||
return true
|
||||
} catch (e) { if (current()) setError(`个人设置无法读取:${readableError(e)}`); return false }
|
||||
}
|
||||
useEffect(() => {
|
||||
api.getSettings().then(s => {
|
||||
setSchema(s.schema)
|
||||
setUser(flatten(s.user))
|
||||
setDraft(flatten(s.user))
|
||||
}).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const g: Record<string, SettingDesc[]> = {}
|
||||
for (const d of schema) (g[d.group] ??= []).push(d)
|
||||
return g
|
||||
}, [schema])
|
||||
|
||||
fence.invalidate(); setSettings(null); setProjectSettings(null); setDraft({}); setPersonalDraft({}); setNotice(''); setBusy(false)
|
||||
void load()
|
||||
}, [owner, projectId])
|
||||
const depart = () => !dirty || window.confirm('个人设置尚未保存,离开后将丢弃这些改动。继续?')
|
||||
const save = async () => {
|
||||
const values = unflatten(draft)
|
||||
await api.putUserSettings(values)
|
||||
setUser(draft)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 1500)
|
||||
// hot 类设置即时生效:主题。
|
||||
if (draft['ui.theme']) store.setUI({ theme: draft['ui.theme'] })
|
||||
store.refreshPlugins()
|
||||
if (busy || (section === 'settings' ? !settings : !projectSettings?.personalSchema)) return
|
||||
const current = fence.begin('save')
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
try {
|
||||
if (section === 'settings') await api.putUserSettings(personalSettingsMutation(initial, draft, schema))
|
||||
else if (projectId) await api.putPersonalProjectSettings(projectId, personalSettingsMutation(personalInitial, personalDraft, personalSchema))
|
||||
if (!current()) return
|
||||
if (await load()) {
|
||||
if (!current()) return
|
||||
setNotice('个人偏好已保存;按各项标注的生效时机应用')
|
||||
void store.refreshPlugins()
|
||||
}
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
const message = `保存结果未能确认:${readableError(e)};请核对最新设置后再保存`
|
||||
await load()
|
||||
if (current()) setError(message)
|
||||
} finally { if (current()) setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
title="设置" active={section} onSelect={() => undefined}
|
||||
onClose={() => store.openSettings(false)}
|
||||
sections={[
|
||||
{ key: 'settings', label: '常规', icon: 'settings' },
|
||||
]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
|
||||
actions={section === 'settings' && schema.length > 0 ? (
|
||||
<>
|
||||
{saved && <span style={{ color: 'var(--ok)', fontSize: 'var(--fs-ui-sm)', marginRight: 8 }}>✓ 已保存</span>}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
{section === 'settings' && (
|
||||
<SettingsGroup title="外观">
|
||||
<SettingRow label="主题" desc="墨绿是可选身份皮肤。跟随系统只在深色与浅色之间切换。深色和浅色会随设置保存" effect="hot">
|
||||
<select value={theme} onChange={e => {
|
||||
const next = e.target.value as ThemeChoice
|
||||
store.setUI({ theme: next })
|
||||
if (next === 'dark' || next === 'light') setDraft(x => ({ ...x, 'ui.theme': next }))
|
||||
}}>
|
||||
<option value="dark">深色</option>
|
||||
<option value="light">浅色</option>
|
||||
<option value="molv">墨绿</option>
|
||||
<option value="system">跟随系统</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="界面字号" desc="小、标准、大、特大。只保存在这台设备上,图标和间距不跟随缩放" effect="hot">
|
||||
<select value={fontSize} onChange={e => {
|
||||
const next = Number(e.target.value)
|
||||
if (isFontSizeChoice(next)) store.setUI({ fontSize: next })
|
||||
}}>
|
||||
<option value={13}>小</option>
|
||||
<option value={14}>标准</option>
|
||||
<option value={16}>大</option>
|
||||
<option value={18}>特大</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
{section === 'settings' && Object.entries(groups).map(([g, descs]) => {
|
||||
const rows = descs.filter(d => d.key !== 'ui.theme')
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<SettingsGroup key={g} title={groupNames[g] ?? g}>
|
||||
{rows.map(d => (
|
||||
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
|
||||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
)
|
||||
})}
|
||||
{section === 'settings' && <BorrowPanels />}
|
||||
</SettingsShell>
|
||||
)
|
||||
return <SettingsShell title="个人设置" active={section}
|
||||
onSelect={next => { if (!busy && depart()) { setDraft(initial); setPersonalDraft(personalInitial); setNotice(''); setSection(next) } }}
|
||||
onClose={() => { if (depart()) { fence.invalidate(); store.openSettings(false) } }}
|
||||
sections={[{ key: 'settings', label: '个人偏好', icon: 'settings' }, ...(projectId ? [{ key: 'project-personal', label: '我在当前项目的偏好', icon: 'folder' as const }] : [])]}
|
||||
footer="个人偏好只影响本人;项目业务规则在项目设置中管理"
|
||||
actions={(section === 'settings' ? settings : projectSettings?.personalSchema) ? <Btn variant="primary" icon="check" disabled={busy || !dirty} onClick={save}>保存个人设置</Btn> : undefined}>
|
||||
{section === 'settings' && <>
|
||||
<SettingsGroup title="外观">
|
||||
<SettingRow label="主题" desc="立即应用到这台设备;深色和浅色可随个人偏好保存" effect="hot">
|
||||
<select value={theme} aria-label="主题" disabled={busy || !settings} onChange={e => {
|
||||
const value = e.target.value as ThemeChoice; store.setUI({ theme: value })
|
||||
if (value === 'dark' || value === 'light') setDraft(previous => ({ ...previous, 'ui.theme': value }))
|
||||
}}><option value="dark">深色</option><option value="light">浅色</option><option value="molv">墨绿</option><option value="system">跟随系统</option></select>
|
||||
</SettingRow>
|
||||
<SettingRow label="界面字号" desc="仅保存在这台设备上;图标和间距不随字号缩放" effect="hot">
|
||||
<select value={fontSize} aria-label="界面字号" onChange={e => { const value = Number(e.target.value); if (isFontSizeChoice(value)) store.setUI({ fontSize: value }) }}>
|
||||
<option value={13}>小</option><option value={14}>标准</option><option value={16}>大</option><option value={18}>特大</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
{!settings && <><EmptyState icon="settings">{error || '读取个人偏好…'}</EmptyState>{error && <Btn onClick={() => load()}>重试</Btn>}</>}
|
||||
{settings && <SettingsFields schema={schema.filter(desc => desc.key !== 'ui.theme')} draft={draft} defaults={flatten(settings.defaults)} disabled={busy} onChange={(key, value) => setDraft(previous => ({ ...previous, [key]: value }))} />}
|
||||
<BorrowPanels />
|
||||
</>}
|
||||
{section === 'project-personal' && <>
|
||||
<p className="ad-hint">当前项目:{projectName || '尚未选择'}。以下偏好仅影响本人;跨会话读取仅访问本人授权的会话,不开放其他成员的私聊、原件或草稿</p>
|
||||
{!projectSettings && <><EmptyState icon="settings">{error || '读取当前项目的个人偏好…'}</EmptyState>{error && <Btn onClick={() => load(true)}>重试</Btn>}</>}
|
||||
{projectSettings && !projectSettings.personalSchema && <EmptyState icon="settings">项目个人偏好接口尚未就绪,请升级服务后重试</EmptyState>}
|
||||
{projectSettings?.personalSchema && personalSchema.length === 0 && <EmptyState icon="settings">当前没有可调整的个人项目偏好</EmptyState>}
|
||||
{projectSettings?.personalSchema && <SettingsFields schema={personalSchema} draft={personalDraft}
|
||||
defaults={{ ...flatten(projectSettings.personalDefaults || {}), ...flatten(projectSettings.personalUser || {}) }} source="本人在当前项目的偏好" disabled={busy}
|
||||
onChange={(key, value) => setPersonalDraft(previous => ({ ...previous, [key]: value }))} />}
|
||||
</>}
|
||||
<Feedback error={error} notice={notice} />
|
||||
</SettingsShell>
|
||||
}
|
||||
|
||||
// 输入框语义(2026-10-12 增量 3):仅秘密字段用密码框;普通文本用 text——
|
||||
// 无条件 type="password" 会把整排设置项当凭据表单(浏览器「password 不在 form 中/
|
||||
// 缺少 username」警告来源之一),也把普通文本无谓遮蔽。
|
||||
export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } {
|
||||
const isSecret = desc.secret || desc.type === 'secret'
|
||||
return isSecret
|
||||
? { type: 'password', autoComplete: 'new-password' }
|
||||
: { type: 'text', autoComplete: 'off' }
|
||||
}
|
||||
|
||||
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
|
||||
if (desc.type === 'boolean') {
|
||||
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
|
||||
}
|
||||
if (desc.type === 'enum') {
|
||||
return <select value={value} onChange={e => onChange(e.target.value)}>
|
||||
{(desc.options ?? []).map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
}
|
||||
const isSecret = desc.secret || desc.type === 'secret'
|
||||
const attrs = settingInputAttrs(desc)
|
||||
return <input type={attrs.type} autoComplete={attrs.autoComplete} style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
|
||||
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
|
||||
onChange={e => onChange(e.target.value)} />
|
||||
}
|
||||
|
||||
// 点路径扁平化/还原。
|
||||
function flat(m: Record<string, any>, prefix: string, out: Record<string, any>) {
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out)
|
||||
else out[key] = v
|
||||
}
|
||||
}
|
||||
export function flatten(m: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
flat(m, '', out)
|
||||
return out
|
||||
}
|
||||
export function unflatten(flatMap: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries(flatMap)) {
|
||||
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
|
||||
const parts = k.split('.')
|
||||
let cur = out
|
||||
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]] ??= {}
|
||||
cur[parts[parts.length - 1]] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,14 @@
|
||||
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
|
||||
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
|
||||
import { EmptyState, Icon } from '@ui'
|
||||
import {
|
||||
ROLE_ACTIONS,
|
||||
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
|
||||
memberAddPayload, memberRemovePayload, memberSetRolePayload,
|
||||
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload,
|
||||
docFinalPayload, docStatusPayload, permCheckPayload, describeSource,
|
||||
actionLabel,
|
||||
rolesListPlaceholder, describeRoleListError,
|
||||
} from '../rdmManagement'
|
||||
|
||||
@@ -29,28 +25,57 @@ interface RoleView {
|
||||
accounts: string[] | null
|
||||
}
|
||||
|
||||
interface RoleActionView { action: string; label: string; scope: 'project' | 'system'; configurable: boolean }
|
||||
interface RoleListView {
|
||||
roles: RoleView[]
|
||||
roleActions?: RoleActionView[]
|
||||
systemActions?: RoleActionView[]
|
||||
canManageRoles?: boolean
|
||||
canAssignRoles?: boolean
|
||||
}
|
||||
|
||||
export function RdmRolesPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
const [roles, setRoles] = useState<RoleView[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [catalog, setCatalog] = useState<RoleListView | null>(null)
|
||||
const request = useRef(0)
|
||||
const [newName, setNewName] = useState('')
|
||||
// 动作编辑器
|
||||
const [editRole, setEditRole] = useState('')
|
||||
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
|
||||
const [editAction, setEditAction] = useState<string>('doc.read')
|
||||
const [editDef, setEditDef] = useState(false)
|
||||
const [editCap, setEditCap] = useState(true)
|
||||
// 绑定
|
||||
const [assignOwner, setAssignOwner] = useState('')
|
||||
|
||||
const refresh = async () => {
|
||||
const seq = ++request.current
|
||||
try {
|
||||
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
|
||||
setErr(''); setRoles(out.roles ?? [])
|
||||
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
|
||||
const out = await api.rdmCommand<RoleListView>('rdm.role.list')
|
||||
if (seq !== request.current) return
|
||||
setErr(''); setRoles(out.roles ?? []); setCatalog(out)
|
||||
} catch (e) {
|
||||
if (seq !== request.current) return
|
||||
setRoles(null); setCatalog(null); setErr(describeRoleListError(String(e)))
|
||||
}
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
useEffect(() => { refresh(); return () => { request.current++ } }, [])
|
||||
|
||||
const projectActions = catalog?.roleActions?.filter(a => a.scope === 'project' && a.configurable && a.action !== 'project.create')
|
||||
?? [...ROLE_ACTIONS.filter(a => a !== 'project.create'), 'audit.read'].map(action => ({ action, label: actionLabel(action), scope: 'project' as const, configurable: false }))
|
||||
const systemActions = catalog?.systemActions?.filter(a => a.scope === 'system')
|
||||
?? [{ action: 'project.create', label: actionLabel('project.create'), scope: 'system' as const, configurable: false }]
|
||||
const canManage = catalog?.canManageRoles === true && Array.isArray(catalog.roleActions)
|
||||
const canAssign = catalog?.canAssignRoles === true
|
||||
const sel = roles?.find(r => r.roleId === editRole)
|
||||
const editCurrent = sel?.actions?.[editAction]
|
||||
const canEditAction = canManage && !sel?.disabled && projectActions.some(a => a.action === editAction && a.configurable)
|
||||
useEffect(() => {
|
||||
setEditDef(!!editCurrent?.[0]); setEditCap(!!editCurrent?.[1])
|
||||
}, [editRole, editAction, roles])
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
@@ -64,22 +89,19 @@ export function RdmRolesPanel() {
|
||||
// rolesListPlaceholder 注释)。
|
||||
const placeholder = rolesListPlaceholder(roles, err)
|
||||
|
||||
const sel = roles?.find(r => r.roleId === editRole)
|
||||
const editCurrent = sel?.actions?.[editAction]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
|
||||
onChange={e => setNewName(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()}
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !canManage || !newName.trim()}
|
||||
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
|
||||
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
|
||||
setNewName('')
|
||||
})}>创建角色</button>
|
||||
</div>
|
||||
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
|
||||
<p className="ad-hint">新角色尚无业务权限,配置后才会生效。项目权限可在角色上限内调整。</p>
|
||||
</div>
|
||||
|
||||
<table className="ad-table">
|
||||
@@ -87,13 +109,13 @@ export function RdmRolesPanel() {
|
||||
<tbody>
|
||||
{(roles ?? []).map(r => (
|
||||
<tr key={r.roleId}>
|
||||
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></td>
|
||||
<td title={r.roleId}>{r.name}</td>
|
||||
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
|
||||
<td className="ad-dim">v{r.version}</td>
|
||||
<td>{Object.keys(r.actions ?? {}).length} 项</td>
|
||||
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
|
||||
<td><button className="ad-mini" disabled={busy}
|
||||
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
|
||||
<td><button className="ad-mini" disabled={busy || !canManage}
|
||||
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号的业务权限停止生效)`,
|
||||
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
|
||||
{r.disabled ? '启用' : '停用'}</button></td>
|
||||
</tr>
|
||||
@@ -110,33 +132,32 @@ export function RdmRolesPanel() {
|
||||
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<select className="lg-input" value={editAction}
|
||||
onChange={e => {
|
||||
setEditAction(e.target.value)
|
||||
const cur = sel?.actions?.[e.target.value]
|
||||
setEditDef(!!cur?.[0]); setEditCap(cur ? cur[1] : true)
|
||||
}}>
|
||||
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
onChange={e => setEditAction(e.target.value)}>
|
||||
{projectActions.map(a => <option key={a.action} value={a.action}>{a.label}</option>)}
|
||||
<optgroup label="系统权限(此处仅供查看)">
|
||||
{systemActions.map(a => <option key={a.action} value={a.action} disabled>{a.label}</option>)}
|
||||
</optgroup>
|
||||
</select>
|
||||
<label className="ad-check"><input type="checkbox" checked={editDef}
|
||||
<label className="ad-check"><input type="checkbox" checked={editDef} disabled={busy || !canEditAction}
|
||||
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
|
||||
<label className="ad-check"><input type="checkbox" checked={editCap}
|
||||
<label className="ad-check"><input type="checkbox" checked={editCap} disabled={busy || !canEditAction}
|
||||
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
|
||||
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !editRole || !canEditAction}
|
||||
onClick={() => run(`已配置 ${sel?.name || '角色'} · ${actionLabel(editAction)}`,
|
||||
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
|
||||
</div>
|
||||
{sel && editCurrent && <p className="ad-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
|
||||
{sel && editCurrent && <p className="ad-hint">当前值:默认{editCurrent[0] ? '允许' : '禁止'},上限{editCurrent[1] ? '允许' : '禁止'}</p>}
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
|
||||
<input className="lg-input" placeholder="成员账号" value={assignOwner}
|
||||
onChange={e => setAssignOwner(e.target.value)} />
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
|
||||
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !canAssign || !assignOwner.trim() || !editRole || sel?.disabled}
|
||||
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${sel?.name || '所选角色'}`,
|
||||
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
|
||||
</div>
|
||||
|
||||
@@ -146,596 +167,6 @@ export function RdmRolesPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
interface MemberRow {
|
||||
owner: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: { roleId: string; name: string } | null
|
||||
}
|
||||
|
||||
export function RdmMembersSection({ project }: { project: Project }) {
|
||||
const projectId = project.id
|
||||
const writeError = projectWorkError(project)
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [picked, setPicked] = useState<string[]>([])
|
||||
const [role, setRole] = useState<'member' | 'owner'>('member')
|
||||
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
|
||||
const rowsRef = useRef<MemberRow[] | null>(null)
|
||||
rowsRef.current = rows
|
||||
|
||||
/** 人员选择器取数适配(P7-A):rdm.account.list 目录(member.manage 生效者)。
|
||||
* query 透传后端(基座升级 AccountSearcher 后全量生效);旧基座忽略 query 时
|
||||
* 对首页做本地包含匹配降级(翻页为浏览模式,不过滤)。 */
|
||||
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const out = await cmd<{ accounts?: { owner: string; name?: string; valid: boolean }[]; next?: string }>(
|
||||
'rdm.account.list', { projectId, cursor, limit, query })
|
||||
const joined = new Set((rowsRef.current ?? []).map(m => m.owner))
|
||||
let items = (out.accounts ?? []).filter(a => a.valid).map(a => ({
|
||||
id: a.owner,
|
||||
title: a.name || a.owner,
|
||||
subtitle: a.owner,
|
||||
badge: joined.has(a.owner) ? '已加入' : undefined,
|
||||
}))
|
||||
if (query !== '' && cursor === '') {
|
||||
const q = query.toLowerCase()
|
||||
items = items.filter(it => it.id.toLowerCase().includes(q) || it.title.toLowerCase().includes(q))
|
||||
}
|
||||
return { items, nextCursor: out.next || undefined }
|
||||
}, [project])
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const out = await cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
setRows(out.members ?? [])
|
||||
} catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { setPicked([]); refresh() }, [project])
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try { await fn(); setNotice(label); await refresh() }
|
||||
catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const pickedId = picked[0] ?? ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<Picker
|
||||
aria-label="选择人员"
|
||||
selected={picked}
|
||||
onSelect={setPicked}
|
||||
fetchPage={fetchPage}
|
||||
placeholder="输入账号或姓名,或浏览候选…"
|
||||
/>
|
||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">项目成员</option>
|
||||
<option value="owner">项目负责人</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedId}
|
||||
onClick={() => run(`已添加 ${pickedId}`, async () => {
|
||||
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role))
|
||||
setPicked([])
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
</div>
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
<td><span className="ad-mono">{m.owner}</span>{m.profRole?.name ? <span className="ad-dim"> · {m.profRole.name}</span> : null}</td>
|
||||
<td className="ad-dim">{m.profRole?.name || '—'}</td>
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
|
||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||
<td>
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
|
||||
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
|
||||
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
|
||||
{m.role === 'owner' ? '降为成员' : '升为负责人'}
|
||||
</button>{' '}
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无成员</td></tr>}
|
||||
{rows === null && <tr><td colSpan={6} className="ad-dim">加载中…</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{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-B:文档清单+选中职责详情;方案 §4)----
|
||||
|
||||
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 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 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([]) })
|
||||
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] })) }))
|
||||
|
||||
return (
|
||||
<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:单成员详情标签化 + 多人矩阵 + 三态覆盖;
|
||||
// 与实际判定同源——UI 不重算,全部读服务端解释) ----
|
||||
|
||||
interface MatrixAction {
|
||||
action: string; label?: string; scope?: string
|
||||
allowed: boolean; source: string; reason?: string
|
||||
}
|
||||
interface MatrixOut {
|
||||
projectId: string; member: string; role: string
|
||||
profRole: { roleId: string; name: string } | null
|
||||
actions: MatrixAction[]
|
||||
}
|
||||
interface MatrixRowOut {
|
||||
member: string; role: string
|
||||
profRole: { roleId: string; name: string } | null
|
||||
actions: MatrixAction[]
|
||||
}
|
||||
interface MatrixBatchOut { projectId: string; rows: MatrixRowOut[] }
|
||||
|
||||
function actionTitle(a: MatrixAction) { return a.label || a.action }
|
||||
|
||||
/** 动作行:中文标签为主,内部标识进 title 技术详情(方案 §5)。 */
|
||||
function ActionDetailTable({ actions, onOverride, canManage }: {
|
||||
actions: MatrixAction[]
|
||||
onOverride?: (action: string, mode: 'allow' | 'deny' | 'default') => void
|
||||
canManage?: boolean
|
||||
}) {
|
||||
const project = actions.filter(a => a.scope !== 'system')
|
||||
const system = actions.filter(a => a.scope === 'system')
|
||||
const overrideOf = (a: MatrixAction): 'allow' | 'deny' | 'default' =>
|
||||
a.source.includes('override') || a.source === 'project' ? (a.allowed ? 'allow' : 'deny') : 'default'
|
||||
return (
|
||||
<>
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th>{canManage ? <th>项目覆盖</th> : null}</tr></thead>
|
||||
<tbody>
|
||||
{project.map(a => (
|
||||
<tr key={a.action}>
|
||||
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
|
||||
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
|
||||
<td className="ad-dim">{describeSource(a.source)}</td>
|
||||
<td className="ad-dim">{a.reason || '—'}</td>
|
||||
{canManage ? (
|
||||
<td>
|
||||
<select className="lg-input" value={overrideOf(a)} style={{ minWidth: 120 }}
|
||||
onChange={e => onOverride?.(a.action, e.target.value as 'allow' | 'deny' | 'default')}>
|
||||
<option value="default">沿用角色默认</option>
|
||||
<option value="allow">项目内允许</option>
|
||||
<option value="deny">项目内禁止</option>
|
||||
</select>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{system.length > 0 && (
|
||||
<Collapsible trigger={`系统范围动作(${system.length} 项,不在项目内配置)`}>
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因</th></tr></thead>
|
||||
<tbody>
|
||||
{system.map(a => (
|
||||
<tr key={a.action}>
|
||||
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
|
||||
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
|
||||
<td className="ad-dim">{describeSource(a.source)}</td>
|
||||
<td className="ad-dim">{a.reason || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Collapsible>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function RdmPermMatrixSection({ project }: { project: Project }) {
|
||||
const projectId = project.id
|
||||
const writeError = projectWorkError(project)
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const me = useStore(s => s.me)
|
||||
const [view, setView] = useState<'member' | 'matrix'>('member')
|
||||
const [member, setMember] = useState('')
|
||||
const [members, setMembers] = useState<MemberRow[] | null>(null)
|
||||
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
|
||||
const [batch, setBatch] = useState<MatrixBatchOut | null>(null)
|
||||
const [expand, setExpand] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const loadMembers = () =>
|
||||
cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
.then(out => {
|
||||
setMembers(out.members ?? [])
|
||||
if (me?.owner) setMember(prev => prev || me.owner)
|
||||
}).catch(e => setErr(String(e)))
|
||||
useEffect(() => { loadMembers() }, [project, me?.owner]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const query = async (m: string) => {
|
||||
if (!m) return
|
||||
setErr(''); setBusy(true); setMatrix(null)
|
||||
try {
|
||||
setMatrix(await cmd<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
const queryBatch = async () => {
|
||||
setErr(''); setBusy(true); setBatch(null)
|
||||
try {
|
||||
setBatch(await cmd<MatrixBatchOut>('rdm.perm.matrix', { projectId }))
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
useEffect(() => { if (view === 'matrix') queryBatch() }, [view]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/** 三态覆盖写回:default=实际撤销(perm.unset);allow/deny=perm.set。 */
|
||||
const override = async (m: string, action: string, mode: 'allow' | 'deny' | 'default') => {
|
||||
if (writeError) { setErr(writeError); return }
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
if (mode === 'default') {
|
||||
await cmd('rdm.perm.unset', { projectId, member: m, action })
|
||||
setNotice(`已恢复默认(撤销 ${action} 的项目覆盖)`)
|
||||
} else {
|
||||
await cmd('rdm.perm.set', { projectId, member: m, action, allow: mode === 'allow' })
|
||||
setNotice(mode === 'allow' ? `已在项目内允许 ${action}` : `已在项目内禁止 ${action}`)
|
||||
}
|
||||
await query(m)
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const canManage = !writeError && !!matrix && matrix.role !== undefined
|
||||
|
||||
return (
|
||||
<div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<select className="lg-input" value={view} onChange={e => setView(e.target.value as 'member' | 'matrix')}>
|
||||
<option value="member">按成员查看(默认自省)</option>
|
||||
<option value="matrix">权限矩阵(成员 × 动作,须管理权)</option>
|
||||
</select>
|
||||
{view === 'member' && (
|
||||
<>
|
||||
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
|
||||
<option value="">选择成员…</option>
|
||||
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
|
||||
</select>
|
||||
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看</Btn>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="ad-hint">普通成员可查看自己的有效权限;查看他人与矩阵须项目管理权。解释与实际判定同源(服务端,非前端推算);系统范围动作折叠为技术详情。</p>
|
||||
</div>
|
||||
|
||||
{view === 'member' && matrix && (
|
||||
<>
|
||||
<div className="ad-row">
|
||||
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目身份 <b>{matrix.role === 'owner' ? '项目负责人' : matrix.role === 'member' ? '项目成员' : matrix.role}</b>
|
||||
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b></>}
|
||||
</span>
|
||||
</div>
|
||||
<ActionDetailTable actions={matrix.actions ?? []}
|
||||
canManage={canManage}
|
||||
onOverride={(action, mode) => override(matrix.member, action, mode)} />
|
||||
</>
|
||||
)}
|
||||
{view === 'member' && !matrix && !err && members !== null && <p className="ad-hint">选择成员后展示各动作的有效权限、来源与第一拒绝原因。</p>}
|
||||
|
||||
{view === 'matrix' && batch && (
|
||||
<table className="ad-table" style={{ overflowX: 'auto', display: 'block' }}>
|
||||
<thead><tr>
|
||||
<th>成员</th>
|
||||
{(batch.rows[0]?.actions ?? []).filter(a => a.scope !== 'system').map(a => (
|
||||
<th key={a.action} title={`内部标识 ${a.action}`}>{actionTitle(a)}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{batch.rows.map(row => (
|
||||
<tr key={row.member}>
|
||||
<td>
|
||||
<button className="ad-mini" onClick={() => setExpand(expand === row.member ? '' : row.member)}>
|
||||
{row.member}
|
||||
</button>
|
||||
<span className="ad-dim"> · {row.role === 'owner' ? '负责人' : '成员'}</span>
|
||||
</td>
|
||||
{row.actions.filter(a => a.scope !== 'system').map(a => (
|
||||
<td key={a.action} title={`${a.label ?? a.action}:${a.allowed ? '允许' : '拒绝'}(${describeSource(a.source)}${a.reason ? ';' + a.reason : ''})`}>
|
||||
{a.allowed ? <span className="ad-chip ad-chip-admin">✓</span> : <span className="ad-chip ad-chip-warn">✗</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{view === 'matrix' && batch && expand && (
|
||||
<div className="settings-group" style={{ marginTop: 12 }}>
|
||||
<h4>{expand} · 单成员详情与三态覆盖</h4>
|
||||
{(() => {
|
||||
const row = batch.rows.find(r => r.member === expand)
|
||||
if (!row) return null
|
||||
return <ActionDetailTable actions={row.actions} canManage={!writeError}
|
||||
onOverride={(action, mode) => override(expand, action, mode)} />
|
||||
})()}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
export { RdmMembersSection } from './RdmMembersSection'
|
||||
export { RdmDutiesSection } from './RdmDutiesSection'
|
||||
export { RdmPermMatrixSection } from './RdmPermMatrixSection'
|
||||
|
||||
@@ -6,7 +6,8 @@ import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { AgentProfile } from '../contracts'
|
||||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||||
import { SettingInput, flatten, scopedDraft, settingsMutation, scopedSchema } from './SettingsFields'
|
||||
import { Feedback, readableError, useRequestFence, validVersion } from './rdmSettingsShared'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import { randomPassword } from '../passwords'
|
||||
@@ -302,47 +303,75 @@ export function PluginWorkspacesPanel() {
|
||||
* SystemSettingsPanel:消费 GET/PUT /api/settings/system。
|
||||
* - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施);
|
||||
* 非受管项属于用户/项目偏好,不在本面板出现。
|
||||
* - 保存后重新 GET 以服务端结果回填(保存成功 ≠ 本地草稿即真相);掩码占位符
|
||||
* 原样传回,由服务端视为未修改。
|
||||
* - 保存只提交变化字段与载入版本,未修改的掩码凭据不回传;显式清除发送空值。
|
||||
* 保存后重新 GET 以服务端结果回填。
|
||||
* - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。
|
||||
*/
|
||||
export function SystemSettingsPanel() {
|
||||
export function SystemSettingsPanel({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void } = {}) {
|
||||
const owner = useStore(s => s.me?.owner)
|
||||
const fence = useRequestFence(owner || '')
|
||||
const [schema, setSchema] = useState<SettingDesc[] | null>(null)
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [initial, setInitial] = useState<Record<string, any>>({})
|
||||
const [defaults, setDefaults] = useState<Record<string, any>>({})
|
||||
const [revision, setRevision] = useState<number | undefined>(undefined)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [forbidden, setForbidden] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(initial)
|
||||
useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false) }, [dirty, onDirtyChange])
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault() }
|
||||
window.addEventListener('beforeunload', beforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', beforeUnload)
|
||||
}, [dirty])
|
||||
|
||||
const load = async () => {
|
||||
const current = fence.begin('load')
|
||||
setErr(''); setForbidden(false)
|
||||
try {
|
||||
const out = await api.getSystemSettings()
|
||||
setSchema(out.schema.filter(d => d.scope === 'system'))
|
||||
setDraft(flatten(out.system))
|
||||
if (!current()) return false
|
||||
const schema = scopedSchema(out.schema, 'system')
|
||||
const values = scopedDraft(out.system, schema)
|
||||
setSchema(schema); setDraft(values); setInitial(values); setDefaults(flatten(out.defaults))
|
||||
setRevision(out.systemRevision)
|
||||
setLoaded(true)
|
||||
return true
|
||||
} catch (e) {
|
||||
if (!current()) return false
|
||||
if (String(e).includes('403')) { setForbidden(true); setLoaded(true) }
|
||||
else setErr(String(e))
|
||||
else { setErr(readableError(e)); setLoaded(true); setSchema(null) }
|
||||
return false
|
||||
}
|
||||
}
|
||||
useEffect(() => { load() }, [])
|
||||
useEffect(() => { fence.invalidate(); setLoaded(false); setSchema(null); setDraft({}); setInitial({}); setNotice(''); setBusy(false); void load() }, [owner])
|
||||
|
||||
const groups: Record<string, SettingDesc[]> = {}
|
||||
for (const d of schema ?? []) (groups[d.group] ??= []).push(d)
|
||||
|
||||
const save = async () => {
|
||||
if (!validVersion(revision) || !schema || busy || !dirty) return
|
||||
const current = fence.begin('save')
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
await api.putSystemSettings(unflatten(draft))
|
||||
setNotice('已保存;按各项标注的生效时机生效(需重启的项在重启后生效)')
|
||||
const mutation = settingsMutation(initial, draft, schema)
|
||||
await api.putSystemSettings(mutation.values, revision, mutation.unset)
|
||||
if (current() && await load()) setNotice('系统配置已保存;需重启的项在服务重启后生效')
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
const message = String(e).includes('409') ? '系统配置已被其他管理员修改;请核对最新配置后重新调整' : `保存结果未能确认:${readableError(e)};请核对最新配置后再保存`
|
||||
await load()
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
if (current()) setErr(message)
|
||||
} finally { if (current()) setBusy(false) }
|
||||
}
|
||||
|
||||
if (!loaded) return <EmptyState icon="settings">加载系统设置…</EmptyState>
|
||||
if (forbidden) return <EmptyState icon="shield">系统设置仅管理员可配置</EmptyState>
|
||||
if (!schema) return <><EmptyState icon="settings">{err || '系统设置未能读取'}</EmptyState><Btn onClick={load}>重试</Btn></>
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -352,17 +381,20 @@ export function SystemSettingsPanel() {
|
||||
{Object.entries(groups).map(([group, descs]) => (
|
||||
<SettingsGroup key={group} title={group === 'models' ? '模型' : group === 'tools' ? '工具服务' : group === 'telemetry' ? '遥测' : group === 'runtime' ? '运行基础设施' : group}>
|
||||
{descs.map(d => (
|
||||
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
|
||||
<SettingInput desc={d} value={draft[d.key]} onChange={v => setDraft(prev => ({ ...prev, [d.key]: v }))} />
|
||||
<SettingRow key={d.key} label={d.label} desc={`${d.description || ''} · ${draft[d.key] === undefined ? '沿用系统默认' : d.secret ? '受保护的系统凭据' : '系统配置'}`} effect={d.effect}>
|
||||
<SettingInput desc={{ ...d, default: defaults[d.key] ?? d.default }} value={draft[d.key]} disabled={busy || !validVersion(revision)} onChange={v => { setNotice(''); setDraft(prev => ({ ...prev, [d.key]: v })) }} />
|
||||
{d.secret || d.type === 'secret' ? <Btn disabled={busy || !validVersion(revision)} onClick={() => {
|
||||
if (window.confirm(`清除「${d.label}」配置?保存后该系统凭据将撤销`)) setDraft(previous => ({ ...previous, [d.key]: '' }))
|
||||
}}>清除配置</Btn> : d.type === 'string' ? <Btn disabled={busy || !validVersion(revision) || draft[d.key] === undefined} onClick={() => setDraft(previous => ({ ...previous, [d.key]: undefined }))}>沿用默认</Btn> : null}
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
))}
|
||||
<div className="ad-row" style={{ marginTop: 12 }}>
|
||||
<Btn variant="primary" icon="check" disabled={busy} onClick={save}>保存系统配置</Btn>
|
||||
<Btn variant="primary" icon="check" disabled={busy || !validVersion(revision) || !dirty} onClick={save}>保存系统配置</Btn>
|
||||
</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>}
|
||||
{!validVersion(revision) && <p className="ad-hint">系统配置版本尚未就绪,暂不能保存</p>}
|
||||
<Feedback error={err} notice={notice} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon, type PickItem } from '@ui'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { useStore } from '../store'
|
||||
|
||||
export interface Person {
|
||||
owner: string; name?: string; valid?: boolean; role?: string; projectRole?: string; joined?: boolean
|
||||
profRole?: { roleId: string; name: string } | null
|
||||
dutyEligibility?: { author: { allowed: boolean; reason?: string }; reviewer: { allowed: boolean; reason?: string }; approver: { allowed: boolean; reason?: string } }
|
||||
addedBy?: string; addedAt?: string
|
||||
}
|
||||
export interface MemberList {
|
||||
members: Person[]; next?: string; count?: number; accountDirectoryAvailable?: boolean
|
||||
canManageMembers?: boolean; canAssignDuties?: boolean; canConfigurePermissions?: boolean
|
||||
}
|
||||
interface RoleOption { roleId: string; name: string }
|
||||
export const projectRoleLabel = (role?: string) => role === 'owner' ? '项目负责人' : role === 'member' ? '项目成员' : '未加入项目'
|
||||
export const personLabel = (person: Person) => person.name || person.owner
|
||||
export function personItem(person: Person, adding = false): PickItem {
|
||||
const disabledReason = person.valid !== true ? '账号有效状态尚未核验' : adding && person.joined ? '已加入,请在成员表中调整项目身份' : undefined
|
||||
return { id: person.owner, title: personLabel(person),
|
||||
subtitle: [person.owner, person.profRole?.name, !adding ? projectRoleLabel(person.role || person.projectRole) : ''].filter(Boolean).join(' · '),
|
||||
badge: person.joined && adding ? '已加入' : undefined, disabled: !!disabledReason, disabledReason }
|
||||
}
|
||||
export const selectedPeople = (ids: string[], people: Person[]) => ids.map(id => personItem(people.find(p => p.owner === id) ?? { owner: id }))
|
||||
|
||||
/** A context change invalidates requests immediately, before effects run. */
|
||||
export class RequestFence {
|
||||
private context: string
|
||||
private epoch = 0
|
||||
private sequences = new Map<string, number>()
|
||||
constructor(context: string) { this.context = context }
|
||||
setContext(context: string) { if (context !== this.context) { this.context = context; this.invalidate() } }
|
||||
invalidate() { this.epoch++; this.sequences.clear() }
|
||||
capture() { const epoch = this.epoch; return () => epoch === this.epoch }
|
||||
cancel(channel: string) { this.sequences.set(channel, (this.sequences.get(channel) ?? 0) + 1) }
|
||||
begin(channel: string) {
|
||||
const epoch = this.epoch
|
||||
const seq = (this.sequences.get(channel) ?? 0) + 1
|
||||
this.sequences.set(channel, seq)
|
||||
return () => epoch === this.epoch && seq === this.sequences.get(channel)
|
||||
}
|
||||
}
|
||||
export function useRequestFence(context: string) {
|
||||
const ref = useRef(new RequestFence(context))
|
||||
ref.current.setContext(context)
|
||||
useEffect(() => () => ref.current.invalidate(), [])
|
||||
return ref.current
|
||||
}
|
||||
export function projectContext(project: Project, owner?: string) {
|
||||
return `${owner || ''}:${project.id}:${project.settings?.['rdm.projectId'] || ''}:${project.settings?.['rdm.state'] || ''}`
|
||||
}
|
||||
|
||||
export function useRoleOptions(project: Project, purpose: 'members' | 'duties' | 'permissions', enabled: boolean) {
|
||||
const owner = useStore(s => s.me?.owner)
|
||||
const context = projectContext(project, owner)
|
||||
const fence = useRequestFence(context)
|
||||
const [roles, setRoles] = useState<RoleOption[]>([])
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
const current = fence.begin('roles')
|
||||
setRoles([]); setError('')
|
||||
if (!enabled) return
|
||||
api.rdmProjectCommand<{ roles: RoleOption[] }>(project, 'rdm.role.options', { purpose })
|
||||
.then(out => { if (current()) { if (!Array.isArray(out.roles)) throw new Error('专业角色选项接口未就绪'); setRoles(out.roles) } })
|
||||
.catch(() => { if (current()) setError('专业角色筛选暂不可用,请重试') })
|
||||
return () => fence.cancel('roles')
|
||||
}, [context, purpose, enabled])
|
||||
return { roles, error }
|
||||
}
|
||||
export function RoleFilter({ roles, value, onChange, disabled = false }: {
|
||||
roles: RoleOption[]; value: string; onChange: (role: string) => void; disabled?: boolean
|
||||
}) {
|
||||
return <select aria-label="专业角色筛选" className="lg-input" value={value} disabled={disabled} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">全部专业角色</option>
|
||||
{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
|
||||
</select>
|
||||
}
|
||||
export function Feedback({ error, notice }: { error?: string; notice?: string }) {
|
||||
return <>{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
|
||||
{error && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{error}</div>}</>
|
||||
}
|
||||
export function readableError(error: unknown): string {
|
||||
const raw = String(error).replace(/^(?:Error|TypeError):\s*/, '')
|
||||
if (raw.includes('unknown or disabled command')) return '该功能尚未装配到当前服务,请升级服务后重新打开'
|
||||
const text = raw.replace(/^\d{3}:\s*/, '')
|
||||
try {
|
||||
const body = JSON.parse(text)
|
||||
if (typeof body?.receipt?.reason === 'string') return readableError(body.receipt.reason.replace(/^(?:rejected|failed):\s*/i, ''))
|
||||
if (typeof body.error === 'string') return readableError(body.error)
|
||||
if (typeof body.message === 'string') return readableError(body.message)
|
||||
if (body && typeof body === 'object') return '操作暂未完成,请重新读取状态后再试'
|
||||
} catch { /* A plain service message is also valid. */ }
|
||||
if (/[\u4e00-\u9fff]/.test(text)) return text
|
||||
if (raw.startsWith('403:') || /denied|forbidden/i.test(raw)) return '当前账号没有权限执行此操作,请联系项目负责人核对'
|
||||
if (raw.startsWith('409:') || /version.*conflict|revision.*mismatch/i.test(raw)) return '数据已经更新,请核对最新结果后重新操作'
|
||||
if (/fetch|network|connection/i.test(raw)) return '网络连接中断,请核对最新状态;写入结果可能尚未确认'
|
||||
return '操作暂未完成,请重新读取状态后再试'
|
||||
}
|
||||
export const validVersion = (version: unknown): version is number => typeof version === 'number' && Number.isSafeInteger(version) && version >= 0
|
||||
+2
-2
@@ -270,8 +270,8 @@ export interface SettingDesc {
|
||||
default?: unknown; options?: string[];
|
||||
effect: 'hot' | 'new-run' | 'restart';
|
||||
secret?: boolean;
|
||||
/** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */
|
||||
scope?: 'system';
|
||||
/** Setting ownership; an omitted scope retains legacy personal compatibility. */
|
||||
scope?: 'system' | 'user' | 'project' | 'project-personal';
|
||||
}
|
||||
|
||||
// ---- 插件 ----
|
||||
|
||||
+28
-8
@@ -26,6 +26,7 @@ export function rdmProjectReadError(project: Project | undefined): string | null
|
||||
const PROJECT_READ_COMMANDS = new Set([
|
||||
'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
|
||||
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
|
||||
'rdm.duty.tree', 'rdm.role.options', 'rdm.account.list', 'rdm.project.member.impact',
|
||||
])
|
||||
|
||||
export function rdmProjectCommandError(project: Project, command: string): string | null {
|
||||
@@ -46,12 +47,14 @@ export const RDM_MEMBER_COMMANDS = [
|
||||
'rdm.project.member.list',
|
||||
'rdm.project.member.add',
|
||||
'rdm.project.member.remove',
|
||||
'rdm.project.member.set-role', 'rdm.project.member.impact', 'rdm.account.list', 'rdm.role.options',
|
||||
] as const
|
||||
|
||||
/** 项目设置「项目权限」页消费的命令面(矩阵解释 + 成员清单)。 */
|
||||
export const RDM_PERM_MATRIX_COMMANDS = [
|
||||
'rdm.perm.check',
|
||||
'rdm.project.member.list',
|
||||
'rdm.perm.matrix', 'rdm.perm.set', 'rdm.perm.unset', 'rdm.role.options',
|
||||
] as const
|
||||
|
||||
/** 项目设置「文档职责 · 整文审签」页消费的命令面。 */
|
||||
@@ -62,6 +65,8 @@ export const RDM_DOC_REVIEW_COMMANDS = [
|
||||
'rdm.review.doc.reject',
|
||||
'rdm.review.doc.final',
|
||||
'rdm.review.doc.status',
|
||||
'rdm.duty.catalog', 'rdm.duty.tree', 'rdm.duty.get', 'rdm.project.member.list',
|
||||
'rdm.role.options', 'rdm.review.assign', 'rdm.review.assign.doc.batch',
|
||||
] as const
|
||||
|
||||
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
|
||||
@@ -87,7 +92,7 @@ export const rdmDocReviewReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
ready(cmds, RDM_PERM_MATRIX_COMMANDS)
|
||||
|
||||
/** RDM 动作清单(12 项,与 RDM perms.AllActions 对齐;角色动作编辑器选项)。 */
|
||||
/** 旧动作目录用于只读占位;可编辑目录及当前权限由服务端返回。 */
|
||||
export const ROLE_ACTIONS = [
|
||||
'project.create', 'project.archive', 'member.manage', 'duty.assign',
|
||||
'stage.advance', 'doc.read', 'doc.write', 'doc.publish', 'review.participate',
|
||||
@@ -109,7 +114,8 @@ export const memberSetRolePayload = (projectId: string, member: string, role: 'o
|
||||
|
||||
export const assignDocDutiesPayload = (
|
||||
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
|
||||
) => ({ projectId, docId, author, reviewers, approver })
|
||||
expectedDutyVersion: number, confirmInvalidation = false,
|
||||
) => ({ projectId, docId, author, reviewers, approver, expectedDutyVersion, confirmInvalidation })
|
||||
|
||||
export const docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId })
|
||||
export const docApprovePayload = (projectId: string, docId: string, note?: string) =>
|
||||
@@ -123,7 +129,7 @@ export const docStatusPayload = (projectId: string, docId: string) => ({ project
|
||||
|
||||
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
|
||||
|
||||
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
|
||||
/** 内部来源码只进入按需技术详情。 */
|
||||
export function describeSource(source: string): string {
|
||||
switch (source) {
|
||||
case 'admin': return '系统管理员'
|
||||
@@ -131,10 +137,26 @@ export function describeSource(source: string): string {
|
||||
case 'override': return '项目内覆盖'
|
||||
case 'relation-default': return '项目关系默认'
|
||||
case 'non-member': return '非项目成员'
|
||||
default: return source
|
||||
case 'scope': return '配置范围不符'
|
||||
case 'error': return '暂时无法核验'
|
||||
case 'cap': case 'cap-denied': return '管理员权限上限'
|
||||
case 'project': return '项目内配置'
|
||||
case 'default': return '角色默认'
|
||||
default: return '来源未提供'
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
'project.create': '创建项目', 'project.archive': '归档项目', 'member.manage': '管理项目成员',
|
||||
'duty.assign': '分配文档职责', 'stage.advance': '推进项目阶段', 'doc.read': '读取文档',
|
||||
'doc.write': '编制与编辑文档', 'doc.publish': '发布受控文档', 'review.participate': '参与文档审签',
|
||||
'trace.edit': '编辑追溯关系', 'knowledge.publish': '发布项目知识', 'project.settings': '配置项目设置',
|
||||
}
|
||||
|
||||
export function actionLabel(action: string, label?: string): string {
|
||||
return ACTION_LABELS[action] || (label && label !== action ? label : '其他项目操作')
|
||||
}
|
||||
|
||||
/** 角色清单表格占位行文案:加载中仅在无错误时成立——rdm.role.list 被拒后
|
||||
* 停留「加载中…」是 2026-10-13 8788 锁死事故的误导性表象(表格永远在转圈)。 */
|
||||
export function rolesListPlaceholder(roles: unknown[] | null, err: string): string {
|
||||
@@ -142,12 +164,10 @@ export function rolesListPlaceholder(roles: unknown[] | null, err: string): stri
|
||||
return err ? '角色清单未加载(见下方提示)' : '加载中…'
|
||||
}
|
||||
|
||||
/** rdm.role.list 错误文案映射:域管理员权限拒绝给人话 + 恢复通道;其余错误
|
||||
* 原样保留(服务端 reason 变化不至于空白)。台账单管理员制见 RDM G1-4/U1。 */
|
||||
/** 角色管理须有独立系统权限;不引导使用已退出此链路的台账管理员移交。 */
|
||||
export function describeRoleListError(raw: string): string {
|
||||
if (raw.includes('不是系统管理员')) {
|
||||
return '当前账号不是 RDM 域管理员(台账单管理员制),无法读取角色清单;'
|
||||
+ '请联系现任管理员经 rdm.perm.transfer 移交,或由部署侧受信通道恢复'
|
||||
return '当前账号无权管理系统专业角色,请联系具有角色管理权限的系统管理员'
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
+8
-3
@@ -11,7 +11,7 @@ import { canFlushOutbox, deriveShellPhase } from './runstate'
|
||||
import { flashElement } from './scrollsync'
|
||||
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
|
||||
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
|
||||
import { rdmAuditReady } from './workspaces'
|
||||
import { rdmAuditReady, rdmExportReady } from './workspaces'
|
||||
import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement'
|
||||
|
||||
type FontSizeChoice = 13 | 14 | 16 | 18
|
||||
@@ -121,6 +121,8 @@ interface AppState {
|
||||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||||
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
|
||||
rdmAuditAvailable: boolean
|
||||
/** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */
|
||||
rdmExportAvailable: boolean
|
||||
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
|
||||
rdmRolesAvailable: boolean
|
||||
rdmMembersAvailable: boolean
|
||||
@@ -194,6 +196,7 @@ let state: AppState = {
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
rdmAuditAvailable: false,
|
||||
rdmExportAvailable: false,
|
||||
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
|
||||
adminSections: [],
|
||||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||
@@ -404,6 +407,7 @@ export function useStore<T>(selector: (s: AppState) => T): T {
|
||||
|
||||
export const store = {
|
||||
getState: () => state,
|
||||
getIdentityEpoch: () => identityEpoch,
|
||||
|
||||
resetIdentity() {
|
||||
identityEpoch++; projectsRequest++; tasksRequest++; meRequest++
|
||||
@@ -424,7 +428,7 @@ export const store = {
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
|
||||
cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv',
|
||||
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
|
||||
rdmAuditAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
|
||||
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
|
||||
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
|
||||
@@ -1167,6 +1171,7 @@ export const store = {
|
||||
if (epoch !== identityEpoch) return
|
||||
set({
|
||||
rdmAuditAvailable: rdmAuditReady(cmds),
|
||||
rdmExportAvailable: rdmExportReady(cmds),
|
||||
rdmRolesAvailable: rdmRolesReady(cmds),
|
||||
rdmMembersAvailable: rdmMembersReady(cmds),
|
||||
rdmDocReviewAvailable: rdmDocReviewReady(cmds),
|
||||
@@ -1174,7 +1179,7 @@ export const store = {
|
||||
})
|
||||
} catch {
|
||||
if (epoch !== identityEpoch) return
|
||||
set({ rdmAuditAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
set({ rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false })
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -512,6 +512,17 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.ad-table tr:last-child td { border-bottom: none; }
|
||||
.rdm-settings .ad-row { flex-wrap: wrap; align-items: center; }
|
||||
.rdm-settings .ad-row .lg-input { flex: 1 1 160px; }
|
||||
.rdm-settings .ad-row .picker { flex: 2 1 220px; min-width: 180px; }
|
||||
.rdm-settings .ui-setting-control .picker { width: 100%; }
|
||||
.rdm-filter-row { margin: 12px 0; }
|
||||
.rdm-table-scroll { overflow-x: auto; margin: 12px 0; }
|
||||
.rdm-table-scroll th { white-space: nowrap; }
|
||||
.rdm-table-scroll td { vertical-align: top; min-width: 100px; }
|
||||
.rdm-table-scroll td:first-child { min-width: 48px; }
|
||||
.rdm-duty-tree { list-style: none; padding-left: 16px; margin: 8px 0; }
|
||||
.rdm-duty-tree li { margin: 8px 0; }
|
||||
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.ad-dim { color: var(--text-dim); }
|
||||
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
|
||||
@@ -627,6 +638,14 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
|
||||
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||||
.rdm-audit-coverage { margin-top: 12px; }
|
||||
.rdm-audit-export { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.rdm-audit-export-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
.rdm-audit-export-kind { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.rdm-audit-export-kind .ui-input { min-width: 180px; height: 32px; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-export-draft { padding: 8px 10px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.rdm-audit-export-draft p { margin: 0 0 8px; }
|
||||
.rdm-audit-export-result { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.rdm-audit-export-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
|
||||
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
|
||||
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
|
||||
|
||||
@@ -30,6 +30,24 @@ export const RDM_AUDIT_COMMANDS = [
|
||||
'rdm.inspect.history',
|
||||
] as const
|
||||
|
||||
/** 概览页导出区单独探测。不并入上面的七命令就绪门。 */
|
||||
export const RDM_EXPORT_COMMANDS = [
|
||||
'rdm.inspect.export.create',
|
||||
'rdm.inspect.export.status',
|
||||
'rdm.inspect.export.download',
|
||||
] as const
|
||||
|
||||
/** 三条导出命令都启用才显示概览导出区。缺一或清单不可用则隐藏。 */
|
||||
export function rdmExportReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
|
||||
if (!Array.isArray(cmds)) return false
|
||||
return RDM_EXPORT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
|
||||
}
|
||||
|
||||
/** 正式报告被完整性门拒绝时,界面先确认再带 draft 重试。 */
|
||||
export function exportNeedsDraft(message: string): boolean {
|
||||
return /不完整|草稿/.test(message)
|
||||
}
|
||||
|
||||
/** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */
|
||||
export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
|
||||
if (!Array.isArray(cmds)) return false
|
||||
|
||||
@@ -86,10 +86,10 @@ describe('project command identity', () => {
|
||||
|
||||
it('sends member and duty commands to the canonical domain project from service metadata', async () => {
|
||||
await api.rdmProjectCommand(ready, 'rdm.project.member.add', memberAddPayload(ready.id, 'bob'))
|
||||
await api.rdmProjectCommand(ready, 'rdm.review.assign.doc', assignDocDutiesPayload(ready.id, 'doc-2', 'alice', ['bob'], 'carol'))
|
||||
await api.rdmProjectCommand(ready, 'rdm.review.assign.doc', assignDocDutiesPayload(ready.id, 'doc-2', 'alice', ['bob'], 'carol', 3))
|
||||
expect(requests.map(r => JSON.parse(String(r.init?.body)).params)).toEqual([
|
||||
{ projectId: 'domain-7', member: 'bob', role: 'member' },
|
||||
{ projectId: 'domain-7', docId: 'doc-2', author: 'alice', reviewers: ['bob'], approver: 'carol' },
|
||||
{ projectId: 'domain-7', docId: 'doc-2', author: 'alice', reviewers: ['bob'], approver: 'carol', expectedDutyVersion: 3, confirmInvalidation: false },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('project work and mutation failures', () => {
|
||||
|
||||
it('checks a denied settings save instead of resolving success', async () => {
|
||||
route = async () => response({ error: 'denied' }, 403)
|
||||
await expect(api.putProjectSettings('host-1', {})).rejects.toThrow(/403/)
|
||||
await expect(api.putProjectSettings('host-1', {}, 3)).rejects.toThrow(/403/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -347,3 +347,17 @@ describe('identity-scoped state', () => {
|
||||
expect(await pending).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('export capability identity', () => {
|
||||
it('drops the old identity export capability before any new command refresh', async () => {
|
||||
const previous = route
|
||||
route = async (path, init) => path === '/api/commands'
|
||||
? response(['rdm.inspect.export.create', 'rdm.inspect.export.status', 'rdm.inspect.export.download']
|
||||
.map(id => ({ id, enabled: true }))) : previous(path, init)
|
||||
await store.refreshPlugins()
|
||||
expect(store.getState().rdmExportAvailable).toBe(true)
|
||||
auth.beginSession()
|
||||
expect(store.getState().rdmExportAvailable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,8 +60,8 @@ describe('命令载荷构造(身份只经认证会话——载荷不含任何
|
||||
expect(memberRemovePayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol' })
|
||||
})
|
||||
it('文档职责与整文审签载荷', () => {
|
||||
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave'))
|
||||
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave' })
|
||||
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave', 7, true))
|
||||
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave', expectedDutyVersion: 7, confirmInvalidation: true })
|
||||
expect(docSubmitPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
|
||||
expect(docApprovePayload('p1', 'd1', '通过')).toEqual({ projectId: 'p1', docId: 'd1', note: '通过' })
|
||||
expect(docRejectPayload('p1', 'd1', '依据不足')).toEqual({ projectId: 'p1', docId: 'd1', reason: '依据不足' })
|
||||
@@ -87,12 +87,12 @@ describe('项目权限矩阵页(rdm.perm.check 消费)', () => {
|
||||
it('矩阵查询载荷(身份只经认证会话)', () => {
|
||||
expect(permCheckPayload('p1', 'bob')).toEqual({ projectId: 'p1', member: 'bob' })
|
||||
})
|
||||
it('来源标签:解释读面的人话映射;未知来源回退原文', () => {
|
||||
it('来源标签:解释读面的人话映射;未知来源不暴露内部代码', () => {
|
||||
expect(describeSource('admin')).toBe('系统管理员')
|
||||
expect(describeSource('prof-role-default')).toContain('角色')
|
||||
expect(describeSource('override')).toContain('覆盖')
|
||||
expect(describeSource('relation-default')).toContain('关系')
|
||||
expect(describeSource('non-member')).toContain('成员')
|
||||
expect(describeSource('weird')).toBe('weird')
|
||||
expect(describeSource('weird')).toBe('来源未提供')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { ActionDetailTable, overrideMode, projectActions, type MatrixAction } from '../src/components/RdmPermMatrixSection'
|
||||
import { DutyTree, dutyValidation, normalizeDuties, documentAssignmentImpact } from '../src/components/RdmDutiesSection'
|
||||
import { personItem, readableError, RequestFence } from '../src/components/rdmSettingsShared'
|
||||
|
||||
const action: MatrixAction = { action: 'doc.write', scope: 'project', allowed: false, defaultAllowed: true,
|
||||
capAllowed: false, override: true, source: 'cap', sourceLabel: '管理员权限上限' }
|
||||
describe('project settings authorization presentation', () => {
|
||||
it('preserves the explicit override even when a cap makes effective permission false', () => {
|
||||
expect(overrideMode(action)).toBe('allow')
|
||||
expect(overrideMode({ ...action, allowed: true, override: false })).toBe('deny')
|
||||
expect(overrideMode({ ...action, override: undefined })).toBe('unavailable')
|
||||
const html = renderToStaticMarkup(<ActionDetailTable actions={[action]} canManage />)
|
||||
expect(html).toMatch(/value="allow"[^>]*disabled/)
|
||||
expect(html).toContain('管理员上限禁止授予')
|
||||
})
|
||||
it('excludes all system actions including misleadingly scoped project creation from every project detail', () => {
|
||||
const actions = [{ ...action, action: 'project.create' }, { ...action, scope: 'system' }, { ...action, scope: undefined }, action]
|
||||
expect(projectActions(actions)).toEqual([action])
|
||||
const html = renderToStaticMarkup(<ActionDetailTable actions={actions} canManage />)
|
||||
expect(html).not.toContain('project.create')
|
||||
expect(html).not.toContain('创建项目')
|
||||
expect(html).toContain('编制与编辑文档')
|
||||
})
|
||||
it('joined or unverified accounts cannot be added through candidates', () => {
|
||||
expect(personItem({ owner: 'bob', valid: true, joined: true }, true)).toMatchObject({ disabled: true, badge: '已加入' })
|
||||
expect(personItem({ owner: 'bob' }, true)).toMatchObject({ disabled: true, disabledReason: '账号有效状态尚未核验' })
|
||||
expect(personItem({ owner: 'bob', name: '李工', valid: true, joined: false }, true)).toMatchObject({ title: '李工', disabled: false })
|
||||
})
|
||||
it('only document nodes can enter document selection; chapters are visible context', () => {
|
||||
const html = renderToStaticMarkup(<DutyTree selected="" onSelect={() => {}} nodes={[
|
||||
{ id: 's', kind: 'section', label: '章节 A', selectable: true, docId: 'mistaken-doc', children: [] },
|
||||
{ id: 'd', kind: 'doc', label: '方案文档', selectable: true, docId: 'real-doc', children: [] },
|
||||
]} />)
|
||||
expect(html).toContain('章节 A · 章节</span>')
|
||||
expect(html).toMatch(/<button[^>]*>方案文档<\/button>/)
|
||||
expect(html).not.toMatch(/<button[^>]*>章节 A/)
|
||||
})
|
||||
it('normalizes frozen domain roles without using project identity as signing authority', () => {
|
||||
expect(normalizeDuties({ Author: 'author', Reviewers: ['reviewer'], Approver: 'approver' })).toEqual({ author: 'author', reviewers: ['reviewer'], approver: 'approver' })
|
||||
expect(dutyValidation({ author: 'alice', reviewers: ['alice'], approver: 'bob' })).toContain('不同成员')
|
||||
})
|
||||
it('distinguishes unavailable capabilities, permission denial and readable service reasons', () => {
|
||||
expect(readableError(new Error('400: unknown or disabled command: rdm.duty.tree'))).toContain('尚未装配')
|
||||
expect(readableError(new Error('403: denied'))).toContain('没有权限')
|
||||
expect(readableError(new Error('409: {"error":"职责版本冲突,请刷新"}'))).toBe('职责版本冲突,请刷新')
|
||||
})
|
||||
it('shows the command rejection reason without exposing its raw receipt envelope', () => {
|
||||
const error = new Error('400: {"receipt":{"commandId":"rdm.review.assign.doc","state":"rejected","reason":"rejected: 职责版本冲突,请刷新"}}')
|
||||
expect(readableError(error)).toBe('职责版本冲突,请刷新')
|
||||
})
|
||||
it('does not warn that unchanged duties or independently assigned chapters will be invalidated', () => {
|
||||
const roles = { author: 'a', reviewers: ['r'], approver: 'p' }
|
||||
const document = { docId: 'doc', title: '方案', assigned: true, roles, active: { round: 2 },
|
||||
sections: [{ sectionId: 'chapter', title: '独立章节', roles, overridden: true, active: { state: 'in-review' } }] }
|
||||
expect(documentAssignmentImpact(document, roles)).toBe(false)
|
||||
expect(documentAssignmentImpact({ ...document, active: null }, { ...roles, author: 'b' })).toBe(false)
|
||||
expect(documentAssignmentImpact({ ...document, active: null, sections: [{ ...document.sections[0], overridden: false }] }, { ...roles, author: 'b' })).toBe(true)
|
||||
})
|
||||
})
|
||||
describe('late response fences used by all settings sections', () => {
|
||||
it('discards pending reads after a context switch before effects can clear the form', async () => {
|
||||
const fence = new RequestFence('alice:project-a')
|
||||
const old = fence.begin('read')
|
||||
const result = Promise.resolve().then(() => old() ? 'private-a' : 'discarded')
|
||||
fence.setContext('bob:project-a')
|
||||
expect(await result).toBe('discarded')
|
||||
expect(fence.begin('read')()).toBe(true)
|
||||
})
|
||||
it('cancels old document reads while preserving an independent current request', () => {
|
||||
const fence = new RequestFence('alice:project-a')
|
||||
const first = fence.begin('detail')
|
||||
const list = fence.begin('catalog')
|
||||
const second = fence.begin('detail')
|
||||
expect(first()).toBe(false); expect(second()).toBe(true); expect(list()).toBe(true)
|
||||
fence.cancel('detail'); expect(second()).toBe(false)
|
||||
fence.invalidate(); expect(list()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { RdmDutiesSection, RdmMembersSection, RdmPermMatrixSection, RdmRolesPanel } from '../src/components/adminRdmSections'
|
||||
import { describeSource } from '../src/rdmManagement'
|
||||
import type { Project } from '../src/contracts'
|
||||
|
||||
vi.mock('../src/store', () => ({
|
||||
useStore: (select: (state: unknown) => unknown) => select({ me: { owner: 'alice', name: 'Alice', isAdmin: true } }),
|
||||
}))
|
||||
|
||||
const project: Project = {
|
||||
id: 'host-project', name: '研发项目', createdAt: '', updatedAt: '',
|
||||
settings: { 'rdm.projectId': 'rdm-project', 'rdm.state': 'ready' },
|
||||
}
|
||||
|
||||
describe('approved project settings interactions', () => {
|
||||
it('offers a professional role filter separate from project identity when adding a member', () => {
|
||||
const html = renderToStaticMarkup(<RdmMembersSection project={project} />)
|
||||
expect(html).toContain('aria-label="专业角色筛选"')
|
||||
expect(html).toContain('项目负责人')
|
||||
expect(html).toContain('账号或姓名')
|
||||
})
|
||||
|
||||
it('offers document ID/title searching and a tree/list view without manual ID assignment', () => {
|
||||
const html = renderToStaticMarkup(<RdmDutiesSection project={project} />)
|
||||
expect(html).toContain('aria-label="搜索文档"')
|
||||
expect(html).toContain('树形')
|
||||
expect(html).toContain('列表')
|
||||
})
|
||||
|
||||
it('keeps the multi-member matrix disabled until caller management capability is known', () => {
|
||||
const html = renderToStaticMarkup(<RdmPermMatrixSection project={project} />)
|
||||
expect(html).toMatch(/<option[^>]*(?:value="matrix"[^>]*disabled|disabled[^>]*value="matrix")[^>]*>/)
|
||||
})
|
||||
|
||||
it('renders readable action names in system role configuration', () => {
|
||||
const html = renderToStaticMarkup(<RdmRolesPanel />)
|
||||
expect(html).toMatch(/value="project.create"[^>]*>创建项目<\/option>/)
|
||||
expect(html).toMatch(/value="duty.assign">分配文档职责<\/option>/)
|
||||
})
|
||||
|
||||
it('never offers the system creation privilege as an editable project role action', () => {
|
||||
const html = renderToStaticMarkup(<RdmRolesPanel />)
|
||||
expect(html).toMatch(/<option[^>]*(?:value="project.create"[^>]*disabled|disabled[^>]*value="project.create")[^>]*>创建项目<\/option>/)
|
||||
})
|
||||
|
||||
it('does not show raw permission source codes when the server adds a known scope denial', () => {
|
||||
expect(describeSource('scope')).toBe('配置范围不符')
|
||||
expect(describeSource('error')).toBe('暂时无法核验')
|
||||
expect(describeSource('unrecognized-source')).toBe('来源未提供')
|
||||
})
|
||||
})
|
||||
@@ -26,10 +26,10 @@ describe('describeRoleListError', () => {
|
||||
const denied = 'Error: 400: {"receipt":{"commandId":"rdm.role.list","state":"rejected",'
|
||||
+ '"reason":"rejected: 权限拒绝:weichen 不是系统管理员(全局动作 project.settings)"}}'
|
||||
|
||||
it('maps the domain-admin denial to a readable message with the transfer channel', () => {
|
||||
it('explains denied system-role authority without suggesting the retired transfer bypass', () => {
|
||||
const got = describeRoleListError(denied)
|
||||
expect(got).toContain('不是 RDM 域管理员')
|
||||
expect(got).toContain('rdm.perm.transfer')
|
||||
expect(got).toContain('当前账号无权管理系统专业角色')
|
||||
expect(got).not.toContain('rdm.perm.transfer')
|
||||
expect(got).not.toContain('"receipt"')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('versioned shared settings API', () => {
|
||||
it('sends the loaded project revision and settings in the strict envelope', async () => {
|
||||
let url: unknown, init: RequestInit | undefined
|
||||
vi.spyOn(auth, 'fetch').mockImplementation(async (path, options) => {
|
||||
url = path; init = options
|
||||
return new Response(JSON.stringify({ ok: true, projectRevision: 8 }))
|
||||
})
|
||||
await api.putProjectSettings('host/id', { deliverables: { style: 'formal' } }, 7)
|
||||
expect(url).toBe('/api/settings/project/host%2Fid')
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ values: { deliverables: { style: 'formal' } }, expectedRevision: 7 })
|
||||
})
|
||||
it('rejects permission and conflict failures for all settings writers', async () => {
|
||||
vi.spyOn(auth, 'fetch').mockResolvedValue(new Response('changed', { status: 409 }))
|
||||
await expect(api.putSystemSettings({}, 3)).rejects.toThrow('409')
|
||||
vi.spyOn(auth, 'fetch').mockResolvedValue(new Response('denied', { status: 403 }))
|
||||
await expect(api.putUserSettings({})).rejects.toThrow('403')
|
||||
})
|
||||
it('sends genuine inheritance removal without guessing a default value', async () => {
|
||||
let init: RequestInit | undefined
|
||||
vi.spyOn(auth, 'fetch').mockImplementation(async (_path, options) => {
|
||||
init = options
|
||||
return new Response(JSON.stringify({ ok: true, projectRevision: 8 }))
|
||||
})
|
||||
await api.putProjectSettings('host/id', {}, 7, ['deliverables.style'])
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ values: {}, expectedRevision: 7, unset: ['deliverables.style'] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { SettingInput } from '../src/components/SettingsModal'
|
||||
import { scopedSchema } from '../src/components/SettingsFields'
|
||||
import { BorrowPanels } from '../src/components/BorrowPanels'
|
||||
import type { SettingDesc } from '../src/contracts'
|
||||
|
||||
vi.mock('../src/store', () => ({ useStore: (select: (state: unknown) => unknown) => select({ ui: {} }) }))
|
||||
const field = (type: SettingDesc['type'], options?: string[]): SettingDesc => ({
|
||||
key: 'deliverables.style', group: 'deliverables', label: '交付风格', type, options, effect: 'new-run',
|
||||
})
|
||||
|
||||
describe('setting editors preserve types and readable values', () => {
|
||||
it('keeps legacy personal plugin settings visible only in personal scopes', () => {
|
||||
const schema: SettingDesc[] = [
|
||||
{ ...field('string'), key: 'plugin.legacy', scope: undefined },
|
||||
{ ...field('string'), key: 'plugin.user', scope: 'user' },
|
||||
{ ...field('string'), key: 'plugin.project-personal', scope: 'project-personal' },
|
||||
{ ...field('string'), key: 'plugin.project', scope: 'project' },
|
||||
{ ...field('string'), key: 'plugin.system', scope: 'system' },
|
||||
]
|
||||
const keys = (scope: string) => scopedSchema(schema, scope).map(desc => desc.key)
|
||||
expect(keys('user')).toEqual(['plugin.legacy', 'plugin.user'])
|
||||
expect(keys('project-personal')).toEqual(['plugin.legacy', 'plugin.user', 'plugin.project-personal'])
|
||||
expect(keys('project')).toEqual(['plugin.project'])
|
||||
expect(keys('system')).toEqual(['plugin.system'])
|
||||
})
|
||||
it('uses a number input for numeric values rather than submitting arbitrary strings', () => {
|
||||
expect(renderToStaticMarkup(<SettingInput desc={field('number')} value={12} onChange={() => {}} />)).toContain('type="number"')
|
||||
})
|
||||
it('shows readable enum names and a distinct inherited choice', () => {
|
||||
const html = renderToStaticMarkup(<SettingInput desc={field('enum', ['modern', 'formal', 'minimal'])} value="" onChange={() => {}} />)
|
||||
expect(html).toContain('沿用默认')
|
||||
expect(html).toContain('>现代</option>')
|
||||
expect(html).toContain('>正式</option>')
|
||||
expect(html).toContain('>简洁</option>')
|
||||
})
|
||||
it('personal supplementary panels no longer offer system credentials', () => {
|
||||
const html = renderToStaticMarkup(<BorrowPanels />)
|
||||
expect(html).not.toContain('新增密钥')
|
||||
expect(html).not.toContain('主模型')
|
||||
expect(html).toContain('条件注入')
|
||||
expect(html).toContain('用户命令')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { personalSettingsMutation, settingsMutation } from '../src/components/SettingsFields'
|
||||
import type { SettingDesc } from '../src/contracts'
|
||||
|
||||
const schema: SettingDesc[] = [
|
||||
{ key: 'tools.limit', group: 'tools', label: '上限', type: 'number', effect: 'new-run', scope: 'system' },
|
||||
{ key: 'models.chat.apiKey', group: 'models', label: '密钥', type: 'secret', secret: true, effect: 'hot', scope: 'system' },
|
||||
]
|
||||
describe('settings merge patch behavior', () => {
|
||||
it('omits unchanged masked credentials and removes a numeric override without storing its default', () => {
|
||||
expect(settingsMutation({ 'tools.limit': 20, 'models.chat.apiKey': '••••••' }, { 'tools.limit': undefined, 'models.chat.apiKey': '••••••' }, schema))
|
||||
.toEqual({ values: {}, unset: ['tools.limit'] })
|
||||
})
|
||||
it('sends explicit secret revocation as an empty string rather than losing it through flattening', () => {
|
||||
expect(settingsMutation({ 'models.chat.apiKey': '••••••' }, { 'models.chat.apiKey': '' }, schema))
|
||||
.toEqual({ values: { models: { chat: { apiKey: '' } } }, unset: [] })
|
||||
})
|
||||
it('does not forward fields outside the visible scoped schema', () => {
|
||||
expect(settingsMutation({}, { 'tools.limit': 0, 'project.create': true }, schema))
|
||||
.toEqual({ values: { tools: { limit: 0 } }, unset: [] })
|
||||
})
|
||||
it('uses explicit null deletion for personal inheritance instead of omitting a merged field', () => {
|
||||
expect(personalSettingsMutation({ 'tools.limit': 20 }, { 'tools.limit': undefined }, schema))
|
||||
.toEqual({ tools: { limit: null } })
|
||||
})
|
||||
})
|
||||
+26
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces'
|
||||
import { buildWorkspaceEntries, rdmAuditReady, rdmExportReady, exportNeedsDraft, RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } from '../src/workspaces'
|
||||
|
||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
|
||||
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
|
||||
@@ -72,4 +72,29 @@ describe('rdmAuditReady', () => {
|
||||
expect(rdmAuditReady(null)).toBe(false)
|
||||
expect(rdmAuditReady(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('导出命令不参与七命令就绪门', () => {
|
||||
expect(rdmAuditReady(all)).toBe(true)
|
||||
const withExportOff = [
|
||||
...all,
|
||||
{ id: 'rdm.inspect.export.create', enabled: false },
|
||||
]
|
||||
expect(rdmAuditReady(withExportOff)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rdmExportReady', () => {
|
||||
const audit = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
|
||||
const exp = RDM_EXPORT_COMMANDS.map(id => ({ id, enabled: true }))
|
||||
|
||||
it('三条导出命令启用才显示导出区', () => {
|
||||
expect(rdmExportReady([...audit, ...exp])).toBe(true)
|
||||
expect(rdmExportReady(audit)).toBe(false)
|
||||
expect(rdmExportReady(exp.filter(c => c.id !== 'rdm.inspect.export.download'))).toBe(false)
|
||||
})
|
||||
|
||||
it('正式报告不完整时才要求草稿确认', () => {
|
||||
expect(exportNeedsDraft('正式报告不完整(合规缺口)——传 draft:true 生成草稿')).toBe(true)
|
||||
expect(exportNeedsDraft('项目不存在')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user