Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e1eb39035 | ||
|
|
eb2d6d32f9 | ||
|
|
40c2afe054 | ||
|
|
42cda9c056 | ||
|
|
15b962694b | ||
|
|
67585c1701 | ||
|
|
2ba275b991 | ||
|
|
e7f9081a93 | ||
|
|
c294835f8d | ||
|
|
880eff74f8 | ||
|
|
b7473fe585 | ||
|
|
dfcea5c988 | ||
|
|
07a03ac4f6 | ||
|
|
d69009e081 | ||
|
|
55a64f40e8 | ||
|
|
b3fa39744d | ||
|
|
23f96fd921 | ||
|
|
f96a591123 | ||
|
|
698fffd929 | ||
|
|
44b964682c | ||
|
|
0655bc37e1 | ||
|
|
9fc7da2ace | ||
|
|
d9bb780384 |
@@ -17,14 +17,21 @@ async function login(page: Page) {
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const gate = page.getByText('修改密码')
|
||||
// 受限设密门(2026-10-13 合同):heading 唯一定位(标题与按钮同名),
|
||||
// 门挂载后登录卡已卸载,fill 不再与登录卡竞态。
|
||||
const gate = page.getByRole('heading', { name: '设置新密码' })
|
||||
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (gated) {
|
||||
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||
await pwInputs.nth(0).fill(PASSWORD)
|
||||
await pwInputs.nth(0).fill(PASSWORD2)
|
||||
await pwInputs.nth(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
// 设密成功后服务端销毁会话:等成功页再回登录(在途点取消会被会话复验拒绝)。
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
} else {
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
|
||||
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
|
||||
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
|
||||
// 新密码登录进工作台;重复兑换同一 Key 被拒。
|
||||
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
||||
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
||||
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const ADMIN = process.env.E2E_ADMIN || 'e2e-admin'
|
||||
const ADMIN_BOOT = process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin#2026'
|
||||
const ADMIN_PW = process.env.E2E_ADMIN_PASSWORD2 || 'E2eAdmin#2026b'
|
||||
const USER = process.env.E2E_LOGIN_KEY_USER || 'e2e-user'
|
||||
const USER_PW = process.env.E2E_LOGIN_KEY_PASSWORD2 || 'E2eUser#2026b'
|
||||
|
||||
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (auth-on instance required)')
|
||||
|
||||
async function passwordLogin(page: Page, owner: string, password: string) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(owner)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(password)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
|
||||
/** 管理员登录;初始密码(must-change)则先完成受限设密再以新密码重登。
|
||||
* 初始密码单次有效:已跑过本用例的环境(boot 已被改掉)自动改用新密码。 */
|
||||
async function adminLogin(page: Page) {
|
||||
await passwordLogin(page, ADMIN, ADMIN_BOOT)
|
||||
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位,
|
||||
// getByText 双命中会触发 strict-mode 抛错被 catch 吞掉、误判 gated=false。
|
||||
let gate = page.getByRole('heading', { name: '设置新密码' })
|
||||
let gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (!gated) {
|
||||
// boot 密码失败(此前运行已完成改密):换正式密码重登。
|
||||
const loginErr = await page.locator('.lg-err').waitFor({ state: 'visible', timeout: 3_000 }).then(() => true).catch(() => false)
|
||||
if (!loginErr) throw new Error('admin login neither gated nor errored')
|
||||
await passwordLogin(page, ADMIN, ADMIN_PW)
|
||||
gate = page.getByRole('heading', { name: '设置新密码' })
|
||||
gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
}
|
||||
if (gated) {
|
||||
const pw = page.locator('.lg-card input[type="password"]')
|
||||
await pw.nth(0).fill(ADMIN_PW)
|
||||
await pw.nth(1).fill(ADMIN_PW)
|
||||
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
// 等设密事务完成再回登录:提交在途时点取消会登出受限会话、
|
||||
// 服务端按会话复验拒绝该次设密(产品正确行为,测试必须同步成功页)。
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
await passwordLogin(page, ADMIN, ADMIN_PW)
|
||||
}
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
|
||||
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
||||
test.setTimeout(120_000)
|
||||
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
|
||||
await adminLogin(page)
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
const zone = page.getByRole('dialog', { name: '系统管理' })
|
||||
await expect(zone).toBeVisible()
|
||||
// 每个用户行都有同款按钮:限定目标用户所在行。
|
||||
await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click()
|
||||
await zone.getByRole('button', { name: '生成 Key', exact: true }).click()
|
||||
const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]')
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 })
|
||||
const key = await keyInput.inputValue()
|
||||
expect(key).toMatch(/^alk_/)
|
||||
await expect(zone.getByText(/单次有效/)).toBeVisible()
|
||||
|
||||
// 2) 管理员退出系统管理与登录态,回到登录页。
|
||||
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
|
||||
await zone.getByRole('button', { name: '返回工作台' }).click()
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。
|
||||
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
|
||||
await page.getByPlaceholder('alk_…').fill(key)
|
||||
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
|
||||
|
||||
// 4) 受限设密门:服务端确认用户名;无当前密码输入框。
|
||||
const gate = page.locator('.lg-card')
|
||||
await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
|
||||
const pw = gate.locator('input[type="password"]')
|
||||
await pw.nth(0).fill(USER_PW)
|
||||
await pw.nth(1).fill(USER_PW)
|
||||
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
|
||||
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
|
||||
|
||||
// 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。
|
||||
await passwordLogin(page, USER, USER_PW)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
|
||||
await page.getByPlaceholder('alk_…').fill(key)
|
||||
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
|
||||
await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
@@ -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$/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Real React/Picker interaction with deterministic HTTP responses; no live account or database.
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>(ok => { resolve = ok })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function dutiesPage(page: Page) {
|
||||
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 commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove',
|
||||
'rdm.duty.catalog', 'rdm.duty.get', 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch',
|
||||
'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final', 'rdm.review.doc.status']
|
||||
const docs = [
|
||||
{ docId: 'doc-A', title: '文档A', typeKey: 'design', sections: 1, assigned: true, author: 'alice', reviewers: ['carol'], approver: 'dave' },
|
||||
{ docId: 'doc-B', title: '文档B', typeKey: 'design', sections: 1, assigned: true, author: 'bob', reviewers: ['carol'], approver: 'dave' },
|
||||
]
|
||||
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/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: {} }
|
||||
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
|
||||
else if (path.startsWith('/api/commands/')) {
|
||||
const command = decodeURIComponent(path.substring('/api/commands/'.length))
|
||||
const params = request.postDataJSON().params as Record<string, unknown>
|
||||
expect(params.projectId).toBe('domain-7')
|
||||
calls.push({ command, params })
|
||||
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: '' })) }
|
||||
else if (command === 'rdm.duty.get') {
|
||||
const doc = docs.find(d => d.docId === params.docId)!
|
||||
payload = { docId: doc.docId, title: doc.title, assigned: doc.assigned,
|
||||
roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [],
|
||||
active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice' } : null }
|
||||
} else if (command === 'rdm.review.doc.status') payload = { submission: submissions[String(params.docId)] }
|
||||
else { writes.push({ command, params }) }
|
||||
body = { payload }
|
||||
} else if (path.endsWith('/cards')) body = { cards: [] }
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: '项目设置', exact: true }).click()
|
||||
await page.getByRole('tab', { name: /文档职责/ }).click()
|
||||
await expect(page.getByRole('button', { name: '文档A', exact: true })).toBeVisible()
|
||||
return { writes, calls, active, submissions,
|
||||
hold(command: string, docId: string) {
|
||||
const pending = { started: deferred(), release: deferred() }
|
||||
holds.set(`${command}:${docId}`, pending)
|
||||
return pending
|
||||
} }
|
||||
}
|
||||
|
||||
async function chooseMember(page: Page, label: string, owner: string) {
|
||||
await page.locator(`.picker[aria-label="${label}"]`).getByRole('combobox').fill(owner)
|
||||
await page.getByRole('option', { name: new RegExp(`^${owner}`) }).click()
|
||||
}
|
||||
|
||||
async function settleResponse(page: Page, command: string, release: () => void) {
|
||||
const response = page.waitForResponse(r => r.url().endsWith(`/api/commands/${command}`))
|
||||
release()
|
||||
await response
|
||||
await page.evaluate(() => new Promise<void>(done => requestAnimationFrame(() => requestAnimationFrame(() => done()))))
|
||||
}
|
||||
|
||||
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()
|
||||
const submit = page.getByRole('button', { name: '整文提交(编制人)', exact: true })
|
||||
await expect(submit).toBeEnabled()
|
||||
await submit.click()
|
||||
await expect(page.getByText('整文已提交审核(全文锁定)', { exact: true })).toBeVisible()
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.review.doc.submit', params: { projectId: 'domain-7', docId: 'doc-A' } }])
|
||||
})
|
||||
|
||||
test('viewing active duties is passive; only a dirty save asks for confirmation', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
fixture.active.add('doc-A')
|
||||
const dialogs: string[] = []
|
||||
let accept = false
|
||||
page.on('dialog', async dialog => { dialogs.push(dialog.message()); accept ? await dialog.accept() : await dialog.dismiss() })
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: /文档A · 当前职责/ })).toBeVisible()
|
||||
const save = page.getByRole('button', { name: '保存当前职责', exact: true })
|
||||
await expect(save).toBeDisabled()
|
||||
expect(dialogs).toEqual([])
|
||||
await chooseMember(page, '编制人', 'bob')
|
||||
await expect(save).toBeEnabled()
|
||||
expect(dialogs).toEqual([])
|
||||
await save.click()
|
||||
expect(dialogs).toHaveLength(1)
|
||||
expect(fixture.writes).toEqual([])
|
||||
accept = true
|
||||
await save.click()
|
||||
await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible()
|
||||
expect(dialogs).toHaveLength(2)
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: {
|
||||
projectId: 'domain-7', docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave',
|
||||
} }])
|
||||
})
|
||||
|
||||
test('a late document detail cannot overwrite another selection or its saved roles', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
const old = fixture.hold('rdm.duty.get', 'doc-A')
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await old.started.promise
|
||||
await page.getByRole('button', { name: '文档B', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
|
||||
await settleResponse(page, 'rdm.duty.get', old.release.resolve)
|
||||
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
|
||||
expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([])
|
||||
await chooseMember(page, '批准人', 'alice')
|
||||
await page.getByRole('button', { name: '保存当前职责', exact: true }).click()
|
||||
await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible()
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: {
|
||||
projectId: 'domain-7', docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'alice',
|
||||
} }])
|
||||
})
|
||||
|
||||
test('a late review status cannot replace the selected document frozen review', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
const submission = (round: number, author: string) => ({ round, state: 'submitted', digest: '', createdBy: author,
|
||||
createdAt: '', duties: { author, reviewers: ['carol'], approver: 'dave' }, reviews: {}, sections: {} })
|
||||
fixture.submissions['doc-A'] = submission(1, 'alice')
|
||||
fixture.submissions['doc-B'] = submission(2, 'bob')
|
||||
const old = fixture.hold('rdm.review.doc.status', 'doc-A')
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await old.started.promise
|
||||
await page.getByRole('button', { name: '文档B', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible()
|
||||
await settleResponse(page, 'rdm.review.doc.status', old.release.resolve)
|
||||
await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: /整文审签 · 第 1 轮/ })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('closing the project panel invalidates a pending detail before its follow-up status read', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
const old = fixture.hold('rdm.duty.get', 'doc-A')
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await old.started.promise
|
||||
await page.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click()
|
||||
await expect(page.getByRole('tab', { name: /文档职责/ })).toHaveCount(0)
|
||||
await settleResponse(page, 'rdm.duty.get', old.release.resolve)
|
||||
expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([])
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
+19
-10
@@ -77,7 +77,7 @@ export function App() {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (authState === 'ok' && (e.metaKey || e.ctrlKey)) {
|
||||
const key = e.key.toLowerCase()
|
||||
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened); return }
|
||||
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened).catch(() => undefined); return }
|
||||
if (key === 'b') { e.preventDefault(); openContext(); return }
|
||||
}
|
||||
workspaceShortcuts(e)
|
||||
@@ -91,11 +91,14 @@ export function App() {
|
||||
try {
|
||||
const r = await auth.fetch('/api/health')
|
||||
if (r.status === 401) { setAuthState('login'); return }
|
||||
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
|
||||
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
|
||||
// key 持有者清 key 回登录页,改走密码登录完成改密。
|
||||
// 已持有有效会话 cookie(或密钥)时直接放行。设密门依据 sessionScope 判定
|
||||
//(2026-10-13 合同):恢复 Key 兑换与 must-change 首登都建立
|
||||
// set_password_only 受限会话,仅看 mustChangePassword 会漏掉已有密码的
|
||||
// 恢复会话。改密接口只认会话登录:API key 凭证进改密门是死路(提交必
|
||||
// 401),key 持有者清 key 回登录页,改走密码登录完成改密。
|
||||
const me = await api.me().catch(() => null)
|
||||
if (me?.mustChangePassword) {
|
||||
const gated = me?.sessionScope === 'set_password_only' || me?.mustChangePassword
|
||||
if (gated) {
|
||||
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
|
||||
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
|
||||
}
|
||||
@@ -111,10 +114,14 @@ export function App() {
|
||||
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
||||
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
||||
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
||||
store.refreshMe()
|
||||
store.refreshProjects()
|
||||
store.refreshProfiles()
|
||||
store.refreshPlugins()
|
||||
let active = true
|
||||
store.refreshMe().then(() => {
|
||||
if (!active) return
|
||||
void store.refreshProjects()
|
||||
void store.refreshProfiles()
|
||||
void store.refreshPlugins()
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [authState])
|
||||
|
||||
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
||||
@@ -173,9 +180,11 @@ export function App() {
|
||||
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
|
||||
}
|
||||
if (authState === 'mustchange') {
|
||||
// 受限会话设密成功后服务端销毁全部会话并返回 reloginRequired=true:
|
||||
// 唯一后继动作是回密码登录页,不得因历史 onDone 误放行进工作台。
|
||||
return <ChangePasswordGate
|
||||
owner={mustChangeOwner}
|
||||
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
|
||||
onDone={() => { api.logout().catch(() => undefined); setAuthState('login') }}
|
||||
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
|
||||
}
|
||||
if (authState !== 'ok') {
|
||||
|
||||
+40
-8
@@ -1,6 +1,7 @@
|
||||
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
|
||||
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
|
||||
import { auth } from './auth'
|
||||
import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement'
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
||||
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
||||
@@ -14,11 +15,16 @@ const BASE = ''
|
||||
* 自身命令面 fail-closed。 */
|
||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
async function checked(res: Response): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`${res.status}: ${text}`)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
await checked(res)
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
@@ -26,7 +32,7 @@ export const api = {
|
||||
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
|
||||
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
|
||||
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
|
||||
|
||||
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
|
||||
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
|
||||
@@ -42,20 +48,37 @@ export const api = {
|
||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }),
|
||||
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
|
||||
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
|
||||
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
logout: () => {
|
||||
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
|
||||
auth.clear()
|
||||
return request
|
||||
},
|
||||
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
}),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
|
||||
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
|
||||
resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
|
||||
// ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)----
|
||||
adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)),
|
||||
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
|
||||
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
|
||||
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
|
||||
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
|
||||
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
|
||||
@@ -116,7 +139,11 @@ export const api = {
|
||||
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) }),
|
||||
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),
|
||||
// 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) }),
|
||||
|
||||
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||
@@ -141,6 +168,11 @@ export const api = {
|
||||
|
||||
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
|
||||
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
|
||||
rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}): Promise<T> => {
|
||||
const error = rdmProjectCommandError(project, id)
|
||||
if (error) throw new Error(error)
|
||||
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
|
||||
},
|
||||
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
|
||||
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
+14
-3
@@ -17,11 +17,22 @@ function pickStorage(): KV {
|
||||
}
|
||||
|
||||
const storage = pickStorage()
|
||||
const identityListeners = new Set<() => void>()
|
||||
function identityChanged() { identityListeners.forEach(listener => listener()) }
|
||||
|
||||
export const auth = {
|
||||
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
||||
setKey(key: string) { storage.setItem(KEY, key.trim()) },
|
||||
clear() { storage.removeItem(KEY) },
|
||||
setKey(key: string) {
|
||||
const next = key.trim()
|
||||
if (next === this.apiKey()) return
|
||||
storage.setItem(KEY, next)
|
||||
identityChanged()
|
||||
},
|
||||
clear() { storage.removeItem(KEY); identityChanged() },
|
||||
onIdentityChange(listener: () => void) {
|
||||
identityListeners.add(listener)
|
||||
return () => { identityListeners.delete(listener) }
|
||||
},
|
||||
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
|
||||
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
|
||||
beginSession() { this.clear() },
|
||||
@@ -33,7 +44,7 @@ export const auth = {
|
||||
headers.set('Authorization', `Bearer ${key}`)
|
||||
return fetch(input, { ...init, headers })
|
||||
},
|
||||
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
|
||||
// 退出登录:立即清客户端投影并回登录门;调用者先经 api.logout 注销服务端会话。
|
||||
logout() {
|
||||
this.clear()
|
||||
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { EmptyState, Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
import { RdmRolesPanel } from './adminRdmSections'
|
||||
import type { AdminZoneSection } from '../api'
|
||||
@@ -31,6 +31,8 @@ const SECTIONS: AdminSectionDef[] = [
|
||||
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
|
||||
{ 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: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||
]
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// 登录与改密(管理员发放制,R2):
|
||||
// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口;
|
||||
// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。
|
||||
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key);
|
||||
// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。
|
||||
import { useState } from 'react'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
import { Icon, BrandMark } from '@ui'
|
||||
|
||||
@@ -28,9 +27,11 @@ function BrandPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */
|
||||
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。
|
||||
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
|
||||
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
|
||||
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
|
||||
const [tab, setTab] = useState<'password' | 'apikey'>('password')
|
||||
const [tab, setTab] = useState<'password' | 'loginkey'>('password')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [key, setKey] = useState('')
|
||||
@@ -41,10 +42,9 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
const me = await api.login(owner.trim(), password)
|
||||
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
|
||||
auth.beginSession()
|
||||
if (me.mustChangePassword) {
|
||||
// 初始/重置密码:先完成强制改密再放行。
|
||||
// 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明;
|
||||
// 受限会话先进设密门,完成前不放行。
|
||||
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
||||
onMustChange(me.owner)
|
||||
return
|
||||
}
|
||||
@@ -56,24 +56,16 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
|
||||
const submitKey = async () => {
|
||||
setErr(''); setBusy(true)
|
||||
auth.setKey(key)
|
||||
try {
|
||||
const r = await auth.fetch('/api/health')
|
||||
if (r.ok) {
|
||||
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
|
||||
// 不放行进死路改密门,引导改走账号密码登录。
|
||||
const me = await api.me().catch(() => null)
|
||||
if (me?.mustChangePassword) {
|
||||
auth.clear()
|
||||
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
|
||||
return
|
||||
}
|
||||
onAuthed(); return
|
||||
// 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。
|
||||
const me = await api.redeemLoginKey(key.trim())
|
||||
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
||||
onMustChange(me.owner)
|
||||
return
|
||||
}
|
||||
throw new Error(String(r.status))
|
||||
} catch {
|
||||
auth.clear()
|
||||
setErr('密钥无效或已撤销,请重试')
|
||||
onAuthed()
|
||||
} catch (e) {
|
||||
setErr(humanRedeemError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
@@ -84,10 +76,10 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">登录</h2>
|
||||
<p className="lg-sub">账号由管理员发放;首次登录需修改初始密码</p>
|
||||
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key</p>
|
||||
<div className="lg-tabs" role="tablist">
|
||||
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'apikey'} className={`lg-tab ${tab === 'apikey' ? 'on' : ''}`} onClick={() => setTab('apikey')}>API 密钥</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
|
||||
</div>
|
||||
{tab === 'password' ? (
|
||||
<>
|
||||
@@ -100,11 +92,11 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="lg-label">API 访问密钥</label>
|
||||
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="ask_…" />
|
||||
<label className="lg-label">一次性登录 Key</label>
|
||||
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '验证中…' : '进入'}</button>
|
||||
<p className="lg-hint">密钥由服务端创建:<code>agent-system apikey create --owner <name></code>。程序化客户端也可直接带 Authorization 头。</p>
|
||||
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
|
||||
<p className="lg-hint">忘记密码时由管理员签发;单次有效、默认 30 分钟内兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
@@ -113,13 +105,15 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
)
|
||||
}
|
||||
|
||||
/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */
|
||||
/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。
|
||||
* 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框;
|
||||
* 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */
|
||||
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
setErr('')
|
||||
@@ -127,8 +121,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.changePassword(current, next)
|
||||
onDone()
|
||||
await api.changePassword('', next)
|
||||
setDone(true)
|
||||
} catch (e) {
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
@@ -138,20 +132,27 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
<div className="lg-wrap">
|
||||
<BrandPanel />
|
||||
<div className="lg-formside">
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">修改密码</h2>
|
||||
<p className="lg-sub">账号 <b>{owner}</b> 使用初始/重置密码首次登录,须设置新密码后继续</p>
|
||||
<label className="lg-label">当前密码(初始/重置密码)</label>
|
||||
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus autoComplete="current-password" />
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoComplete="new-password" />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '设置新密码并进入'}</button>
|
||||
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>返回登录</button>}
|
||||
</form>
|
||||
{done ? (
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); onDone() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">密码已更新</h2>
|
||||
<p className="lg-sub">请使用新密码重新登录账号 <b>{owner}</b></p>
|
||||
<button className="lg-submit" type="submit">返回登录</button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">设置新密码</h2>
|
||||
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续;无需输入当前密码</p>
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
|
||||
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -165,13 +166,23 @@ export function humanLoginError(raw: string): string {
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
|
||||
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
|
||||
export function humanRedeemError(raw: string): string {
|
||||
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
|
||||
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||
export function humanChangePasswordError(raw: string): string {
|
||||
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
||||
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
||||
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
||||
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
|
||||
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return '认证失败,请重新登录'
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, Btn, EmptyState } from '@ui'
|
||||
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
|
||||
import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections'
|
||||
import { rdmProjectReadError } from '../rdmManagement'
|
||||
import { projectActionError, projectWorkError } from '../projectmeta'
|
||||
|
||||
export function ProjectSettingsModal() {
|
||||
const projectId = useStore(s => s.ui.projectSettingsId)
|
||||
@@ -16,21 +18,26 @@ export function ProjectSettingsModal() {
|
||||
const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable)
|
||||
const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable)
|
||||
const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable)
|
||||
const owner = useStore(s => s.me?.owner)
|
||||
const [tab, setTab] = useState('project')
|
||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
setLoaded(false)
|
||||
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(() => undefined)
|
||||
}, [projectId])
|
||||
}).catch(() => { if (active) setError('项目设置未能加载,请关闭后重新打开或联系项目负责人') })
|
||||
return () => { active = false }
|
||||
}, [projectId, owner])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const g: Record<string, SettingDesc[]> = {}
|
||||
@@ -39,15 +46,19 @@ export function ProjectSettingsModal() {
|
||||
}, [schema])
|
||||
|
||||
if (!projectId) return null
|
||||
const rdmError = rdmProjectReadError(project)
|
||||
const writeError = projectWorkError(project)
|
||||
const rdmTab = tab === 'members' || tab === 'duties' || tab === 'matrix'
|
||||
|
||||
const save = async () => {
|
||||
if (!projectId) return
|
||||
if (writeError) { store.toast(writeError); return }
|
||||
try {
|
||||
await api.putProjectSettings(projectId, unflatten(draft))
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 1500)
|
||||
} catch (e) {
|
||||
store.toast(`项目设置保存失败:${e}`)
|
||||
store.toast(`项目设置保存失败:${projectActionError(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,18 +75,20 @@ export function ProjectSettingsModal() {
|
||||
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责 · 整文审签', icon: 'shield' as const }] : []),
|
||||
...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
|
||||
]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||||
footer={saved ? <span style={{ color: 'var(--ok-text)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||||
>
|
||||
<div>
|
||||
{tab === 'members' && <RdmMembersSection projectId={projectId} />}
|
||||
{tab === 'duties' && <RdmDutiesSection projectId={projectId} />}
|
||||
{tab === 'matrix' && <RdmPermMatrixSection projectId={projectId} />}
|
||||
{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">加载中…</EmptyState>
|
||||
<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}>
|
||||
@@ -91,7 +104,7 @@ export function ProjectSettingsModal() {
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
|
||||
<Btn variant="primary" icon="check" disabled={!!writeError} onClick={save}>保存项目设置</Btn>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { api } from '../api'
|
||||
import { Icon } from '@ui'
|
||||
import type { Conversation } from '../contracts'
|
||||
import { relativeTime, filterTasks } from '../tasklist'
|
||||
import { projectStage } from '../projectmeta'
|
||||
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
|
||||
import { buildWorkspaceEntries } from '../workspaces'
|
||||
|
||||
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
|
||||
@@ -68,8 +68,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
|
||||
const createConversation = async (projectId?: string) => {
|
||||
await store.createTask(projectId)
|
||||
onConversationOpen?.()
|
||||
try {
|
||||
await store.createTask(projectId)
|
||||
onConversationOpen?.()
|
||||
} catch { /* store owns the actionable failure toast */ }
|
||||
}
|
||||
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
|
||||
@@ -77,7 +79,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
const pinned = visible.filter(t => t.pinned)
|
||||
const rest = visible.filter(t => !t.pinned)
|
||||
const currentProject = projects.find(p => p.id === currentProjectId)
|
||||
const stage = projectStage(currentProject?.settings)
|
||||
const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings)
|
||||
const workError = currentProject ? projectWorkError(currentProject) : null
|
||||
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
|
||||
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
|
||||
|
||||
@@ -162,8 +165,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
onKeyDown={async e => {
|
||||
if (e.key === 'Enter' && newProjectName.trim()) {
|
||||
await store.createProject(newProjectName.trim())
|
||||
setCreatingProject(false); setNewProjectName('')
|
||||
try {
|
||||
await store.createProject(newProjectName.trim())
|
||||
setCreatingProject(false); setNewProjectName('')
|
||||
} catch { /* keep the input available for a corrected name */ }
|
||||
}
|
||||
if (e.key === 'Escape') setCreatingProject(false)
|
||||
}}
|
||||
@@ -179,7 +184,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
)}
|
||||
|
||||
{rest.length === 0 && pinned.length === 0 && (
|
||||
<div className="tsb-empty">从「新建对话」开始,描述你想完成的工作。</div>
|
||||
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
|
||||
)}
|
||||
{rest.map(t => taskRow(t))}
|
||||
</div>
|
||||
@@ -224,7 +229,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
{projects.map(p => {
|
||||
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||||
const pstage = projectStage(p.settings)
|
||||
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
|
||||
return (
|
||||
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
||||
{p.name}
|
||||
|
||||
+486
-135
@@ -3,14 +3,16 @@
|
||||
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
|
||||
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { Btn, EmptyState, Icon } from '@ui'
|
||||
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
|
||||
import {
|
||||
ROLE_ACTIONS, parseReviewers,
|
||||
ROLE_ACTIONS,
|
||||
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
|
||||
memberAddPayload, memberRemovePayload,
|
||||
memberAddPayload, memberRemovePayload, memberSetRolePayload,
|
||||
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload,
|
||||
docFinalPayload, docStatusPayload, permCheckPayload, describeSource,
|
||||
rolesListPlaceholder, describeRoleListError,
|
||||
@@ -144,25 +146,54 @@ export function RdmRolesPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 项目设置:成员 ----
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
interface MemberRow { owner: string; role: string; addedBy: string; addedAt: string }
|
||||
interface MemberRow {
|
||||
owner: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: { roleId: string; name: string } | null
|
||||
}
|
||||
|
||||
export function RdmMembersSection({ projectId }: { projectId: string }) {
|
||||
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 [member, setMember] = useState('')
|
||||
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 api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
const out = await cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
setRows(out.members ?? [])
|
||||
} catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [projectId])
|
||||
useEffect(() => { setPicked([]); refresh() }, [project])
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
@@ -170,40 +201,57 @@ export function RdmMembersSection({ projectId }: { projectId: string }) {
|
||||
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">
|
||||
<input className="lg-input lg-mono" placeholder="成员账号(owner)" value={member}
|
||||
onChange={e => setMember(e.target.value)} />
|
||||
<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">member(成员)</option>
|
||||
<option value="owner">owner(负责人)</option>
|
||||
<option value="member">项目成员</option>
|
||||
<option value="owner">项目负责人</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !member.trim()}
|
||||
onClick={() => run(`已添加 ${member.trim()}`, async () => {
|
||||
await api.rdmCommand('rdm.project.member.add', memberAddPayload(projectId, member.trim(), role))
|
||||
setMember('')
|
||||
<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>
|
||||
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
</div>
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>账号</th><th>项目角色</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<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 className="ad-mono">{m.owner}</td>
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">OWNER</span> : <span className="ad-chip">MEMBER</span>}</td>
|
||||
<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}
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => api.rdmCommand('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button></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={5} className="ad-dim">暂无成员</td></tr>}
|
||||
{rows === null && <tr><td colSpan={5} className="ad-dim">加载中…</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>}
|
||||
@@ -212,7 +260,7 @@ export function RdmMembersSection({ projectId }: { projectId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 项目设置:文档职责 · 整文审签 ----
|
||||
// ---- 项目设置:文档职责(P7-B:文档清单+选中职责详情;方案 §4)----
|
||||
|
||||
interface DocSubmissionView {
|
||||
round: number
|
||||
@@ -225,87 +273,252 @@ interface DocSubmissionView {
|
||||
sections: Record<string, number>
|
||||
}
|
||||
|
||||
export function RdmDutiesSection({ projectId }: { projectId: string }) {
|
||||
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 [author, setAuthor] = useState('')
|
||||
const [reviewers, setReviewers] = useState('')
|
||||
const [approver, setApprover] = useState('')
|
||||
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown>) => api.rdmCommand<T>(id, params)
|
||||
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 refreshStatus = async (id: string) => {
|
||||
if (!id) return
|
||||
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) { setErr(String(e)); setSub(undefined) }
|
||||
} 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(); setNotice(label); await refreshStatus(docId) }
|
||||
catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
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>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
|
||||
onChange={e => setDocId(e.target.value)} />
|
||||
<Btn variant="ghost" icon="eye" disabled={busy || !docId}
|
||||
onClick={() => refreshStatus(docId)}>查看整文状态</Btn>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{sub === null && <EmptyState icon="inbox">该文档尚无整文提交记录</EmptyState>}
|
||||
{sub && (
|
||||
<div className="settings-group">
|
||||
<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="setting-row"><label>冻结摘要</label><span className="ad-mono ad-dim">{(sub.digest ?? '').slice(0, 16)}…</span></div>
|
||||
<div className="ad-row" style={{ marginTop: 8 }}>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('整文终批完成(各章节已基线)',
|
||||
() => cmd('rdm.review.doc.final', docFinalPayload(projectId, docId)))}>终批(批准人)</button>
|
||||
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('已通过整文审核',
|
||||
() => cmd('rdm.review.doc.approve', docApprovePayload(projectId, docId, '通过')))}>审核通过</button>
|
||||
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('已驳回整文(解锁回编写态)',
|
||||
() => cmd('rdm.review.doc.reject', docRejectPayload(projectId, docId, '退回修改')))}>驳回</button>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{sub === undefined && docId && <p className="ad-hint">输入文档 ID 后查看整文状态。</p>}
|
||||
{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>}
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>分配文档三权(整文)</h4>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
|
||||
onChange={e => setDocId(e.target.value)} />
|
||||
<input className="lg-input lg-mono" placeholder="编制人(author)" value={author}
|
||||
onChange={e => setAuthor(e.target.value)} />
|
||||
<input className="lg-input lg-mono" placeholder="审核人(逗号分隔,≥1 且异于编制/批准)" value={reviewers}
|
||||
onChange={e => setReviewers(e.target.value)} />
|
||||
<input className="lg-input lg-mono" placeholder="批准人(approver)" value={approver}
|
||||
onChange={e => setApprover(e.target.value)} />
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<button className="lg-submit ad-create-btn"
|
||||
disabled={busy || !docId || !author.trim() || !approver.trim() || parseReviewers(reviewers).length === 0}
|
||||
onClick={() => run('已分配文档三权', () => cmd('rdm.review.assign.doc',
|
||||
assignDocDutiesPayload(projectId, docId, author.trim(), parseReviewers(reviewers), approver.trim())))}>分配三权</button>
|
||||
<button className="ad-mini" disabled={busy || !docId}
|
||||
onClick={() => run('整文已提交审核(全文锁定)',
|
||||
() => cmd('rdm.review.doc.submit', docSubmitPayload(projectId, docId)))}>整文提交(编制人)</button>
|
||||
</div>
|
||||
<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>}
|
||||
@@ -313,67 +526,70 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖;
|
||||
// 与实际判定同源——UI 不重算,全部读服务端解释) ----
|
||||
|
||||
// ---- 项目设置:项目权限(矩阵解释读面,与实际判定同源——UI 不重算) ----
|
||||
|
||||
interface MatrixAction { action: string; allowed: boolean; source: string; reason?: string }
|
||||
interface MatrixAction {
|
||||
action: string; label?: string; scope?: string
|
||||
allowed: boolean; source: string; reason?: string
|
||||
}
|
||||
interface MatrixOut {
|
||||
projectId: string; member: string; role: string
|
||||
profRole: { roleId: string; name: string } | null
|
||||
actions: MatrixAction[]
|
||||
}
|
||||
interface MatrixRowOut {
|
||||
member: string; role: string
|
||||
profRole: { roleId: string; name: string } | null
|
||||
actions: MatrixAction[]
|
||||
}
|
||||
interface MatrixBatchOut { projectId: string; rows: MatrixRowOut[] }
|
||||
|
||||
export function RdmPermMatrixSection({ projectId }: { projectId: string }) {
|
||||
const me = useStore(s => s.me)
|
||||
const [member, setMember] = useState('')
|
||||
const [members, setMembers] = useState<MemberRow[] | null>(null)
|
||||
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
.then(out => {
|
||||
setMembers(out.members ?? [])
|
||||
// 默认自省:普通成员只看自己的矩阵;负责人/管理员可切换他人。
|
||||
if (me?.owner) setMember(prev => prev || me.owner)
|
||||
})
|
||||
.catch(e => setErr(String(e)))
|
||||
}, [projectId, me?.owner])
|
||||
|
||||
const query = async (m: string) => {
|
||||
if (!m) return
|
||||
setErr(''); setBusy(true); setMatrix(null)
|
||||
try {
|
||||
setMatrix(await api.rdmCommand<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
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 (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<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>
|
||||
{matrix && (
|
||||
<>
|
||||
<div className="ad-row">
|
||||
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目关系 <b>{matrix.role}</b>
|
||||
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b>({matrix.profRole.roleId})</>}
|
||||
</span>
|
||||
</div>
|
||||
<>
|
||||
<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>
|
||||
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因</th></tr></thead>
|
||||
<tbody>
|
||||
{(matrix.actions ?? []).map(a => (
|
||||
{system.map(a => (
|
||||
<tr key={a.action}>
|
||||
<td className="ad-mono">{a.action}</td>
|
||||
<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>
|
||||
@@ -381,9 +597,144 @@ export function RdmPermMatrixSection({ projectId }: { projectId: string }) {
|
||||
))}
|
||||
</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)} />
|
||||
</>
|
||||
)}
|
||||
{!matrix && !err && members !== null && <p className="ad-hint">选择成员后展示 12 项动作的有效权限与第一拒绝原因。</p>}
|
||||
{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>
|
||||
)
|
||||
|
||||
@@ -6,11 +6,22 @@ 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 type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import { randomPassword } from '../passwords'
|
||||
|
||||
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
|
||||
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制 + 2026-10-13 恢复 Key)----
|
||||
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
|
||||
interface LoginKeyRow { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }
|
||||
|
||||
/** Key 状态徽标:复用既有 chip 体系,不新增颜色。 */
|
||||
function keyStatusChip(status: string) {
|
||||
if (status === 'unused') return <span className="ad-chip ad-chip-warn">待使用</span>
|
||||
if (status === 'consumed') return <span className="ad-chip">已使用</span>
|
||||
if (status === 'expired') return <span className="ad-chip">已过期</span>
|
||||
return <span className="ad-chip">已撤销</span>
|
||||
}
|
||||
|
||||
export function UsersPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
@@ -22,14 +33,24 @@ export function UsersPanel() {
|
||||
const [pw, setPw] = useState('')
|
||||
const [asAdmin, setAsAdmin] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
const [resetPw, setResetPw] = useState('')
|
||||
// 登录 Key 面板:keyFor 打开指定用户的签发/状态视图;issued 明文仅签发响应持有一次。
|
||||
const [keyFor, setKeyFor] = useState<string | null>(null)
|
||||
const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null)
|
||||
const [keys, setKeys] = useState<LoginKeyRow[] | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
const refreshKeys = async (owner: string) => {
|
||||
try { setKeys(await api.adminListLoginKeys(owner)) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const openKeyPanel = async (owner: string) => {
|
||||
setErr(''); setNotice(''); setIssued(null); setKeyFor(owner); setKeys(null)
|
||||
await refreshKeys(owner)
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
setErr(''); setNotice('')
|
||||
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
|
||||
@@ -42,16 +63,27 @@ export function UsersPanel() {
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const reset = async () => {
|
||||
if (!resetFor) return
|
||||
setErr('')
|
||||
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
const issue = async () => {
|
||||
if (!keyFor) return
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
await api.resetAdminUserPassword(resetFor, resetPw)
|
||||
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
|
||||
setResetFor(null); setResetPw('')
|
||||
await refresh()
|
||||
const out = await api.adminIssueLoginKey(keyFor)
|
||||
setIssued({ key: out.key, expiresAt: out.expiresAt })
|
||||
await refreshKeys(keyFor)
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const copyKey = async () => {
|
||||
if (!issued) return
|
||||
try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') }
|
||||
}
|
||||
|
||||
const revoke = async (id: string) => {
|
||||
if (!keyFor) return
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
await api.adminRevokeLoginKey(keyFor, id)
|
||||
await refreshKeys(keyFor)
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
@@ -69,18 +101,41 @@ export function UsersPanel() {
|
||||
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
|
||||
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。</p>
|
||||
</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>}
|
||||
{resetFor ? (
|
||||
{keyFor ? (
|
||||
<div className="ad-reset">
|
||||
<span>重置 <b>{resetFor}</b> 的密码:</span>
|
||||
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
|
||||
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
|
||||
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
|
||||
<div className="ad-row">
|
||||
<span><b>{keyFor}</b> 的一次性登录 Key(忘记密码恢复)</span>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={issue}>{issued ? '重新签发(旧 Key 与受限会话立即失效)' : '生成 Key'}</button>
|
||||
<button type="button" className="ad-mini" onClick={() => { setKeyFor(null); setIssued(null); setKeys(null) }}>关闭</button>
|
||||
</div>
|
||||
{issued && (
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" readOnly value={issued.key} aria-label="一次性登录 Key(仅显示一次)" onFocus={e => e.currentTarget.select()} />
|
||||
<button type="button" className="ad-mini" onClick={copyKey}>复制</button>
|
||||
</div>
|
||||
)}
|
||||
{issued && <p className="ad-hint">明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。</p>}
|
||||
{keys !== null && keys.length > 0 && (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>签发时间</th><th>用途</th><th>状态</th><th>过期时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{keys.map(k => (
|
||||
<tr key={k.id}>
|
||||
<td className="ad-dim">{new Date(k.createdAt).toLocaleString()}</td>
|
||||
<td>{k.purpose === 'recover' ? '恢复' : '激活'}</td>
|
||||
<td>{keyStatusChip(k.status)}</td>
|
||||
<td className="ad-dim">{new Date(k.expiresAt).toLocaleString()}</td>
|
||||
<td>{k.status === 'unused' && <button className="ad-mini" disabled={busy} onClick={() => revoke(k.id)}>撤销</button>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<table className="ad-table">
|
||||
@@ -94,7 +149,7 @@ export function UsersPanel() {
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && (
|
||||
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
|
||||
<button className="ad-mini" onClick={() => openKeyPanel(u.owner)}>生成登录 Key</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -240,3 +295,74 @@ export function PluginWorkspacesPanel() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 模型与服务配置(P2 系统受管项的唯一写入面)----
|
||||
|
||||
/**
|
||||
* SystemSettingsPanel:消费 GET/PUT /api/settings/system。
|
||||
* - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施);
|
||||
* 非受管项属于用户/项目偏好,不在本面板出现。
|
||||
* - 保存后重新 GET 以服务端结果回填(保存成功 ≠ 本地草稿即真相);掩码占位符
|
||||
* 原样传回,由服务端视为未修改。
|
||||
* - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。
|
||||
*/
|
||||
export function SystemSettingsPanel() {
|
||||
const [schema, setSchema] = useState<SettingDesc[] | null>(null)
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [forbidden, setForbidden] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
setErr(''); setForbidden(false)
|
||||
try {
|
||||
const out = await api.getSystemSettings()
|
||||
setSchema(out.schema.filter(d => d.scope === 'system'))
|
||||
setDraft(flatten(out.system))
|
||||
setLoaded(true)
|
||||
} catch (e) {
|
||||
if (String(e).includes('403')) { setForbidden(true); setLoaded(true) }
|
||||
else setErr(String(e))
|
||||
}
|
||||
}
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const groups: Record<string, SettingDesc[]> = {}
|
||||
for (const d of schema ?? []) (groups[d.group] ??= []).push(d)
|
||||
|
||||
const save = async () => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
await api.putSystemSettings(unflatten(draft))
|
||||
setNotice('已保存;按各项标注的生效时机生效(需重启的项在重启后生效)')
|
||||
await load()
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!loaded) return <EmptyState icon="settings">加载系统设置…</EmptyState>
|
||||
if (forbidden) return <EmptyState icon="shield">系统设置仅管理员可配置</EmptyState>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="ad-hint" style={{ marginBottom: 14 }}>
|
||||
模型、密钥与运行基础设施为系统受管配置:仅此面板(管理员)可写,用户与项目设置中的同名项不再生效。
|
||||
</p>
|
||||
{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>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
))}
|
||||
<div className="ad-row" style={{ marginTop: 12 }}>
|
||||
<Btn variant="primary" icon="check" disabled={busy} 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>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -270,6 +270,8 @@ export interface SettingDesc {
|
||||
default?: unknown; options?: string[];
|
||||
effect: 'hot' | 'new-run' | 'restart';
|
||||
secret?: boolean;
|
||||
/** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */
|
||||
scope?: 'system';
|
||||
}
|
||||
|
||||
// ---- 插件 ----
|
||||
|
||||
@@ -3,3 +3,41 @@ export function projectStage(settings?: Record<string, unknown> | null): string
|
||||
const stage = settings?.stage
|
||||
return typeof stage === 'string' && stage.trim() !== '' ? stage.trim() : null
|
||||
}
|
||||
|
||||
/** Service metadata describes readiness; permission is always checked by the server. */
|
||||
export function projectWorkError(project: { settings?: Record<string, unknown> } | undefined): string | null {
|
||||
if (!project) return '项目已不可用,请刷新项目列表或联系项目负责人'
|
||||
const settings = project.settings
|
||||
if (!settings || (!('rdm.state' in settings) && !('rdm.projectId' in settings))) return null
|
||||
switch (settings['rdm.state']) {
|
||||
case 'ready':
|
||||
if (typeof settings['rdm.projectId'] === 'string' && settings['rdm.projectId'].trim()) return null
|
||||
break
|
||||
case 'provisioning': return '项目正在准备,请稍后刷新项目列表再试'
|
||||
case 'failed': return '项目准备失败,请联系项目负责人重试创建'
|
||||
case 'archived': return '项目已归档,请联系项目负责人恢复后再新建对话'
|
||||
case 'closed': return '项目已关闭,请选择其他项目或联系项目负责人'
|
||||
}
|
||||
return '项目尚未绑定,请联系项目负责人完成项目接入'
|
||||
}
|
||||
|
||||
export function projectStateLabel(settings?: Record<string, unknown>): string | null {
|
||||
switch (settings?.['rdm.state']) {
|
||||
case 'provisioning': return '准备中'
|
||||
case 'failed': return '准备失败'
|
||||
case 'unbound': return '未接入'
|
||||
case 'archived': return '已归档'
|
||||
case 'closed': return '已关闭'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
export function projectActionError(error: unknown): string {
|
||||
const raw = String(error)
|
||||
if (/\b401\b/.test(raw)) return '登录已失效,请重新登录后再试'
|
||||
if (/\b403\b/.test(raw)) return '当前账号无法管理此项目,请联系项目负责人'
|
||||
if (/\b404\b/.test(raw)) return '项目已不可用,请刷新项目列表或联系项目负责人'
|
||||
if (/\b409\b/.test(raw)) return '项目名称已被占用或项目状态已变化,请更换名称或刷新后再试'
|
||||
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
|
||||
return raw.replace(/^Error:\s*/, '')
|
||||
}
|
||||
|
||||
@@ -2,6 +2,35 @@
|
||||
// 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与
|
||||
// api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不
|
||||
// 携带任何身份字段(G1-5)。
|
||||
import type { Project } from './contracts'
|
||||
import { projectWorkError } from './projectmeta'
|
||||
|
||||
export function rdmCommandProjectId(project: Project): string {
|
||||
const id = project.settings?.['rdm.projectId']
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
throw new Error('项目尚未绑定,请联系项目负责人完成项目接入')
|
||||
}
|
||||
return id.trim()
|
||||
}
|
||||
|
||||
/** Historical project reads remain available; this does not grant read permission. */
|
||||
export function rdmProjectReadError(project: Project | undefined): string | null {
|
||||
const state = project?.settings?.['rdm.state']
|
||||
if (project && (state === 'ready' || state === 'archived' || state === 'closed')) {
|
||||
try { rdmCommandProjectId(project); return null }
|
||||
catch (e) { return String(e).replace(/^Error:\s*/, '') }
|
||||
}
|
||||
return projectWorkError(project) ?? '项目尚未绑定,请联系项目负责人完成项目接入'
|
||||
}
|
||||
|
||||
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',
|
||||
])
|
||||
|
||||
export function rdmProjectCommandError(project: Project, command: string): string | null {
|
||||
return PROJECT_READ_COMMANDS.has(command) ? rdmProjectReadError(project) : projectWorkError(project)
|
||||
}
|
||||
|
||||
/** 系统管理「RDM 角色权限」分区消费的命令面。 */
|
||||
export const RDM_ROLES_COMMANDS = [
|
||||
@@ -76,6 +105,7 @@ export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ role
|
||||
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
|
||||
({ projectId, member, role })
|
||||
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
|
||||
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
|
||||
|
||||
export const assignDocDutiesPayload = (
|
||||
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
|
||||
|
||||
+149
-33
@@ -2,6 +2,8 @@
|
||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { type AdminZoneSection, api, connectEvents } from './api'
|
||||
import { auth } from './auth'
|
||||
import { projectActionError, projectWorkError } from './projectmeta'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
||||
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
|
||||
@@ -9,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
|
||||
@@ -119,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
|
||||
@@ -192,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,
|
||||
@@ -279,6 +284,10 @@ let sse: { close: () => void } | null = null
|
||||
let projectSse: { close: () => void } | null = null
|
||||
let projectSeq = 0
|
||||
let conversationEpoch = 0
|
||||
let identityEpoch = 0
|
||||
let projectsRequest = 0
|
||||
let tasksRequest = 0
|
||||
let meRequest = 0
|
||||
let streamSeq = 0
|
||||
let detailRequest = 0
|
||||
let detailSeq = 0
|
||||
@@ -398,24 +407,58 @@ export function useStore<T>(selector: (s: AppState) => T): T {
|
||||
|
||||
export const store = {
|
||||
getState: () => state,
|
||||
getIdentityEpoch: () => identityEpoch,
|
||||
|
||||
resetIdentity() {
|
||||
identityEpoch++; projectsRequest++; tasksRequest++; meRequest++
|
||||
sse?.close(); sse = null
|
||||
projectSse?.close(); projectSse = null
|
||||
conversationEpoch++; detailRequest++; cardsRequest++; projectCardsRequest++
|
||||
projectSeq = 0; streamSeq = 0; detailSeq = 0; detailVersion = 0
|
||||
cardVersion = 0; projectCardVersion = 0
|
||||
cardVersions = {}; projectCardVersions = {}; cardPatches = {}; projectCardPatches = {}
|
||||
runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
actionInflight.clear()
|
||||
for (const key of Object.keys(actionFeedbackOwners)) delete actionFeedbackOwners[key]
|
||||
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
|
||||
convRestoreDone = true
|
||||
persistConvId(null)
|
||||
set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
|
||||
cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv',
|
||||
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
|
||||
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 })
|
||||
setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
|
||||
settingsOpen: false, changePwOpen: false, trackerRunId: null, workspaceSurfacePin: null,
|
||||
previewExpanded: false, filesView: 'list', showArchived: false })
|
||||
},
|
||||
|
||||
async refreshProjects() {
|
||||
const epoch = identityEpoch
|
||||
const request = ++projectsRequest
|
||||
try {
|
||||
const projects = await api.listProjects()
|
||||
if (epoch !== identityEpoch || request !== projectsRequest) return
|
||||
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||||
set({ projects, currentProjectId: current?.id ?? null, error: null })
|
||||
if (current) await store.openProject(current.id)
|
||||
} catch (e) {
|
||||
set({ error: String(e) })
|
||||
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
|
||||
}
|
||||
store.refreshTasks()
|
||||
store.refreshMe()
|
||||
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
|
||||
},
|
||||
|
||||
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
|
||||
async refreshTasks(includeArchived = state.ui.showArchived) {
|
||||
const epoch = identityEpoch
|
||||
const request = ++tasksRequest
|
||||
try {
|
||||
const tasks = await api.listTasks(includeArchived)
|
||||
if (epoch !== identityEpoch || request !== tasksRequest) return
|
||||
set({ tasks })
|
||||
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
|
||||
if (!convRestoreDone) {
|
||||
@@ -429,14 +472,35 @@ export const store = {
|
||||
},
|
||||
|
||||
async refreshMe() {
|
||||
try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ }
|
||||
const epoch = identityEpoch
|
||||
const request = ++meRequest
|
||||
try {
|
||||
const me = await api.me()
|
||||
if (epoch !== identityEpoch || request !== meRequest) return
|
||||
const changed = state.me !== null && state.me.owner !== me.owner
|
||||
if (changed) store.resetIdentity()
|
||||
set({ me })
|
||||
if (changed) {
|
||||
await store.refreshProjects()
|
||||
void store.refreshProfiles()
|
||||
void store.refreshPlugins()
|
||||
}
|
||||
} catch { /* 身份不可用时侧栏只显示通用入口 */ }
|
||||
},
|
||||
|
||||
async createProject(name: string) {
|
||||
const p = await api.createProject(name)
|
||||
await store.refreshProjects()
|
||||
store.toast(`项目「${p.name}」已创建`)
|
||||
return p
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const p = await api.createProject(name)
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
await store.refreshProjects()
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
store.toast(`项目「${p.name}」已创建`)
|
||||
return p
|
||||
} catch (e) {
|
||||
if (epoch === identityEpoch) store.toast(`项目创建失败:${projectActionError(e)}`)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
async openProject(projectId: string) {
|
||||
@@ -465,28 +529,43 @@ export const store = {
|
||||
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
|
||||
* 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */
|
||||
async createTask(projectId?: string) {
|
||||
let pid = projectId ?? state.currentProjectId
|
||||
if (!pid && state.projects.length > 0) pid = state.projects[0].id
|
||||
if (!pid) {
|
||||
const p = await store.createProject('默认项目')
|
||||
pid = p.id
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
let pid = projectId ?? state.currentProjectId
|
||||
if (!pid && state.projects.length > 0) pid = state.projects[0].id
|
||||
if (!pid) {
|
||||
const p = await store.createProject('默认项目')
|
||||
pid = p.id
|
||||
}
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
const error = projectWorkError(state.projects.find(p => p.id === pid))
|
||||
if (error) throw new Error(error)
|
||||
if (pid !== state.currentProjectId) await store.openProject(pid)
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
const c = await api.createConversation(pid)
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
await store.refreshTasks()
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
await store.openTask(c.id)
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
} catch (e) {
|
||||
if (epoch === identityEpoch) store.toast(`对话创建失败:${projectActionError(e)}`)
|
||||
throw e
|
||||
}
|
||||
if (pid !== state.currentProjectId) await store.openProject(pid)
|
||||
const c = await api.createConversation(pid)
|
||||
await store.refreshTasks()
|
||||
await store.openTask(c.id)
|
||||
},
|
||||
|
||||
/** 兼容旧入口(⌘/命令总线) */
|
||||
async createConversation(title?: string) {
|
||||
if (title) store.toast('已在当前项目创建任务')
|
||||
await store.createTask()
|
||||
if (title) store.toast('已在当前项目创建任务')
|
||||
},
|
||||
|
||||
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
|
||||
async openTask(convId: string) {
|
||||
const epoch = identityEpoch
|
||||
const t = state.tasks.find(x => x.id === convId)
|
||||
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
|
||||
if (epoch !== identityEpoch) return
|
||||
await store.openConversation(convId)
|
||||
},
|
||||
|
||||
@@ -996,8 +1075,9 @@ export const store = {
|
||||
const convId = state.currentConvId
|
||||
const pid = state.currentProjectId
|
||||
const epoch = conversationEpoch
|
||||
const identity = identityEpoch
|
||||
const startedVersion = card.conversationId === state.currentConvId ? cardVersion : projectCardVersion
|
||||
const sameContext = () => state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
|
||||
const sameContext = () => identity === identityEpoch && state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
|
||||
actionInflight.add(card.id)
|
||||
const feedbackOwner = startActionFeedback(card.id)
|
||||
try {
|
||||
@@ -1035,57 +1115,81 @@ export const store = {
|
||||
else await store.loadProjectCards()
|
||||
}
|
||||
} finally {
|
||||
actionInflight.delete(card.id)
|
||||
if (identity === identityEpoch) actionInflight.delete(card.id)
|
||||
}
|
||||
},
|
||||
|
||||
async cardPin(card: ActivityCard, pinned: boolean) {
|
||||
try { store.upsertCard((await api.cardPin(card.id, pinned)).card) } catch (e) { store.toast(`置顶失败:${e}`) }
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const out = await api.cardPin(card.id, pinned)
|
||||
if (epoch === identityEpoch) store.upsertCard(out.card)
|
||||
} catch (e) { if (epoch === identityEpoch) store.toast(`置顶失败:${e}`) }
|
||||
},
|
||||
|
||||
async cardDismiss(card: ActivityCard) {
|
||||
try { store.upsertCard((await api.cardDismiss(card.id)).card) } catch (e) { store.toast(`关闭失败:${e}`) }
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const out = await api.cardDismiss(card.id)
|
||||
if (epoch === identityEpoch) store.upsertCard(out.card)
|
||||
} catch (e) { if (epoch === identityEpoch) store.toast(`关闭失败:${e}`) }
|
||||
},
|
||||
|
||||
async refreshArtifacts(projectId: string) {
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const artifacts = await api.listArtifacts(projectId)
|
||||
if (state.currentProjectId !== projectId) return
|
||||
if (epoch !== identityEpoch || state.currentProjectId !== projectId) return
|
||||
set({ artifacts })
|
||||
} catch { /* 项目删除竞态等,静默 */ }
|
||||
},
|
||||
|
||||
async refreshProfiles() {
|
||||
try { set({ profiles: await api.listProfiles() }) } catch { /* ignore */ }
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const profiles = await api.listProfiles()
|
||||
if (epoch === identityEpoch) set({ profiles })
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
|
||||
async refreshPlugins() {
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const list = await api.listPlugins() as { panels?: PluginPanel[] }[]
|
||||
if (epoch !== identityEpoch) return
|
||||
const panels = list.flatMap(p => p.panels ?? [])
|
||||
set({ pluginPanels: panels })
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const ws = await api.listWorkspaces()
|
||||
if (epoch !== identityEpoch) return
|
||||
set({ serverWorkspaces: ws ?? [] })
|
||||
} catch { /* ignore */ }
|
||||
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
|
||||
try {
|
||||
const cmds = await api.listCommands()
|
||||
if (epoch !== identityEpoch) return
|
||||
set({
|
||||
rdmAuditAvailable: rdmAuditReady(cmds),
|
||||
rdmExportAvailable: rdmExportReady(cmds),
|
||||
rdmRolesAvailable: rdmRolesReady(cmds),
|
||||
rdmMembersAvailable: rdmMembersReady(cmds),
|
||||
rdmDocReviewAvailable: rdmDocReviewReady(cmds),
|
||||
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
|
||||
})
|
||||
} catch { set({ rdmAuditAvailable: false }) }
|
||||
} catch {
|
||||
if (epoch !== identityEpoch) return
|
||||
set({ rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false })
|
||||
}
|
||||
try {
|
||||
const sections = await api.adminZoneSections()
|
||||
if (epoch !== identityEpoch) return
|
||||
set({ adminSections: sections ?? [] })
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const settings = await api.getSettings()
|
||||
if (epoch !== identityEpoch) return
|
||||
const runtime = settings.user.runtime
|
||||
const mock = !!runtime && typeof runtime === 'object' && (runtime as { mock?: unknown }).mock === true
|
||||
set({ runtimeMock: mock })
|
||||
@@ -1094,15 +1198,16 @@ export const store = {
|
||||
|
||||
async uploadAttachment(file: File): Promise<string | null> {
|
||||
const pid = state.currentProjectId
|
||||
const epoch = identityEpoch
|
||||
if (!pid) return null
|
||||
try {
|
||||
const up = await api.upload(pid, file)
|
||||
if (state.currentProjectId !== pid) return null
|
||||
if (epoch !== identityEpoch || state.currentProjectId !== pid) return null
|
||||
store.toast(`已收到材料:${up.filename}${up.parsed ? ' · 已读取内容' : ''}`)
|
||||
await store.refreshArtifacts(pid)
|
||||
return up.artifactId
|
||||
return epoch === identityEpoch && state.currentProjectId === pid ? up.artifactId : null
|
||||
} catch (e) {
|
||||
if (state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
|
||||
if (epoch === identityEpoch && state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
|
||||
return null
|
||||
}
|
||||
},
|
||||
@@ -1140,9 +1245,10 @@ export const store = {
|
||||
|
||||
connectProject() {
|
||||
const pid = store.getState().currentProjectId
|
||||
const epoch = identityEpoch
|
||||
if (!pid) return
|
||||
projectSse?.close()
|
||||
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
|
||||
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (epoch === identityEpoch && state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
|
||||
},
|
||||
|
||||
applyProjectEvent(e: StreamEvent) {
|
||||
@@ -1183,8 +1289,10 @@ export const store = {
|
||||
},
|
||||
|
||||
async cardToProject(card: ActivityCard) {
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const out = await api.cardToProject(card.id)
|
||||
if (epoch !== identityEpoch) return
|
||||
store.upsertCard(out.origin) // 会话视图:原卡 dismissed 留痕
|
||||
const list = store.getState().projectCards
|
||||
const i = list.findIndex(c => c.id === out.card.id)
|
||||
@@ -1192,17 +1300,23 @@ export const store = {
|
||||
store.toast('已留档到项目收件箱')
|
||||
store.setInboxScope('project')
|
||||
} catch (e) {
|
||||
store.toast(`留档失败:${e}`)
|
||||
if (epoch === identityEpoch) store.toast(`留档失败:${e}`)
|
||||
}
|
||||
},
|
||||
|
||||
async tidyCards() {
|
||||
const epoch = identityEpoch
|
||||
// U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。
|
||||
const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger')
|
||||
for (const c of done) {
|
||||
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
|
||||
if (epoch !== identityEpoch) return
|
||||
try {
|
||||
const out = await api.cardDismiss(c.id)
|
||||
if (epoch !== identityEpoch) return
|
||||
store.upsertCard(out.card)
|
||||
} catch { /* 单卡失败不阻断 */ }
|
||||
}
|
||||
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
|
||||
if (epoch === identityEpoch && done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
|
||||
},
|
||||
closeWorkspace() { setUI({ activeWorkspace: null }) },
|
||||
/** 铃铛:打开右栏并切到待处理。哪一侧有待办就看哪一侧。 */
|
||||
@@ -1300,6 +1414,8 @@ export const store = {
|
||||
},
|
||||
}
|
||||
|
||||
auth.onIdentityChange(() => store.resetIdentity())
|
||||
|
||||
function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null {
|
||||
if (state.runs.find(r => r.id === _runId)?.kind === 'subagent') return state.live
|
||||
if (state.live && state.live.runId !== _runId && !isTerminal(state.runs.find(r => r.id === state.live?.runId)?.status)) return state.live
|
||||
|
||||
@@ -627,6 +627,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
|
||||
|
||||
+18
-1
@@ -1,7 +1,7 @@
|
||||
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
|
||||
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
|
||||
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
|
||||
|
||||
describe('humanLoginError(登录)', () => {
|
||||
it('401 统一为用户名或密码不正确', () => {
|
||||
@@ -30,3 +30,20 @@ describe('humanChangePasswordError(改密门)', () => {
|
||||
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
|
||||
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
|
||||
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
|
||||
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
|
||||
})
|
||||
it('未知错误剥状态码前缀透传', () => {
|
||||
expect(humanRedeemError('400: bad')).toBe('bad')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanChangePasswordError(登录恢复新增文案)', () => {
|
||||
it('并发凭据变化给出重新登录指引', () => {
|
||||
expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import { store } from '../src/store'
|
||||
import type { ActivityCard, Conversation, Project } from '../src/contracts'
|
||||
import { assignDocDutiesPayload, memberAddPayload } from '../src/rdmManagement'
|
||||
|
||||
const project = (id: string, settings?: Project['settings']): Project => ({
|
||||
id, name: id, settings, createdAt: '', updatedAt: '',
|
||||
})
|
||||
const ready = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })
|
||||
const task = (id: string): Conversation => ({ id, projectId: 'host-1', title: id, createdAt: '', updatedAt: '' })
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(ok => { resolve = ok })
|
||||
return { promise, resolve }
|
||||
}
|
||||
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
|
||||
let projects: Project[]
|
||||
let tasks: Conversation[]
|
||||
let requests: { path: string; init?: RequestInit }[]
|
||||
let route: (path: string, init?: RequestInit) => Promise<Response>
|
||||
|
||||
beforeEach(async () => {
|
||||
projects = []; tasks = []; requests = []
|
||||
const saved = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { saved.set(key, value) },
|
||||
removeItem: (key: string) => { saved.delete(key) } })
|
||||
route = async (path, init) => {
|
||||
if (path === '/api/projects' && !init?.method) return response(projects)
|
||||
if (path.startsWith('/api/conversations') && !init?.method) return response(tasks)
|
||||
if (path === '/api/me') return response({ owner: 'alice', name: 'Alice', auth: true })
|
||||
if (path.endsWith('/artifacts')) return response([])
|
||||
if (path.endsWith('/cards')) return response({ cards: [] })
|
||||
if (path.endsWith('/surfaces')) return response([])
|
||||
if (['/api/profiles', '/api/plugins', '/api/workspaces', '/api/commands', '/api/admin-zone/sections'].includes(path)) return response([])
|
||||
if (path === '/api/settings') return response({ schema: [], defaults: {}, user: {}, project: {} })
|
||||
if (path.startsWith('/api/commands/')) return response({ payload: { ok: true } })
|
||||
return response({})
|
||||
}
|
||||
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
|
||||
requests.push({ path, init })
|
||||
return route(path, init)
|
||||
})
|
||||
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
|
||||
auth.beginSession()
|
||||
await store.refreshProjects()
|
||||
await store.refreshTasks()
|
||||
store.setUI({ lastProjectId: null })
|
||||
requests = []
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
describe('project command identity', () => {
|
||||
it.each(['archived', 'closed'])('preserves canonical member, permission and duty reads for a %s project', async state => {
|
||||
const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
|
||||
const commands = ['rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
|
||||
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get']
|
||||
for (const command of commands) await api.rdmProjectCommand(historical, command, { docId: 'doc-2' })
|
||||
expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
|
||||
{ path: '/api/commands/rdm.project.member.list', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.perm.check', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.perm.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.review.doc.status', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.duty.catalog', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.duty.get', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['archived', 'closed'])('still blocks project and document mutations for a %s project', async state => {
|
||||
const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
|
||||
for (const command of ['rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.setRole',
|
||||
'rdm.review.assign.doc', 'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final']) {
|
||||
await expect(api.rdmProjectCommand(historical, command)).rejects.toThrow()
|
||||
}
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['rdm.perm.matrix', 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.project.member.setRole'])('uses the same canonical boundary for %s', async command => {
|
||||
await api.rdmProjectCommand(ready, command, { projectId: 'another-host', member: 'bob' })
|
||||
expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
|
||||
{ path: `/api/commands/${command}`, params: { projectId: 'domain-7', member: 'bob' } },
|
||||
])
|
||||
})
|
||||
|
||||
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'))
|
||||
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' },
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
project('host-1', { 'rdm.state': 'unbound' }),
|
||||
project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'failed' }),
|
||||
project('host-1', { 'rdm.projectId': 'domain-7' }),
|
||||
project('host-1'),
|
||||
])('does not invent a domain ID or send commands for an unready binding: %j', async p => {
|
||||
await expect(api.rdmProjectCommand(p, 'rdm.project.member.list')).rejects.toThrow()
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('project work and mutation failures', () => {
|
||||
it('keeps private history readable for an archived project while refusing new work', async () => {
|
||||
projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'archived' })]
|
||||
tasks = [task('private-history')]
|
||||
const previous = route
|
||||
route = async (path, init) => {
|
||||
if (path === '/api/conversations/private-history') return response({ conversation: tasks[0],
|
||||
messages: [{ id: 'own-message', conversationId: 'private-history', role: 'user', parts: [{ type: 'text', text: 'My history' }], createdAt: '' }],
|
||||
runs: [], activeHitl: [], lastSeq: 0 })
|
||||
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
|
||||
return previous(path, init)
|
||||
}
|
||||
await store.refreshProjects()
|
||||
await store.openTask('private-history')
|
||||
await expect(store.createTask()).rejects.toThrow()
|
||||
expect(store.getState().currentConvId).toBe('private-history')
|
||||
expect(store.getState().messages.map(m => m.id)).toEqual(['own-message'])
|
||||
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||
})
|
||||
|
||||
it.each([ready, project('host-1')])('creates private work with the host ID in ready RDM and ordinary base mode: %j', async p => {
|
||||
projects = [p]
|
||||
await store.refreshProjects()
|
||||
const previous = route
|
||||
route = async (path, init) => {
|
||||
if (path === '/api/projects/host-1/conversations' && init?.method === 'POST') {
|
||||
tasks = [task('new-private')]
|
||||
return response(tasks[0])
|
||||
}
|
||||
if (path === '/api/conversations/new-private') {
|
||||
return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 })
|
||||
}
|
||||
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
|
||||
return previous(path, init)
|
||||
}
|
||||
await store.createTask()
|
||||
expect(store.getState().currentConvId).toBe('new-private')
|
||||
expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/projects/host-1/conversations'])
|
||||
})
|
||||
|
||||
it.each(['provisioning', 'failed', 'unbound', 'archived', 'closed'])('does not create a fallback project or conversation for a selected %s project', async state => {
|
||||
projects = [project('host-1', { 'rdm.state': state }), project('host-2')]
|
||||
await store.refreshProjects()
|
||||
requests = []
|
||||
await expect(store.createTask()).rejects.toThrow()
|
||||
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||
expect(store.getState().currentProjectId).toBe('host-1')
|
||||
expect(store.getState().toast).toMatch(/项目.*(稍后|负责人|恢复|关闭)/)
|
||||
})
|
||||
|
||||
it('does not bypass a selected project absent from the current list', async () => {
|
||||
await store.openProject('revoked-project')
|
||||
requests = []
|
||||
await expect(store.createTask()).rejects.toThrow()
|
||||
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['delete', 'rename'])('rejects a denied %s instead of resolving success', async action => {
|
||||
route = async () => response({ error: 'project administration required' }, 403)
|
||||
const mutation = action === 'delete' ? api.deleteProject('host-1') : api.renameProject('host-1', 'renamed')
|
||||
await expect(mutation).rejects.toThrow(/403/)
|
||||
})
|
||||
|
||||
it('retains selection and gives an actionable message when project creation is rejected', async () => {
|
||||
projects = [ready]
|
||||
await store.refreshProjects()
|
||||
route = async () => response({ error: 'name reserved' }, 409)
|
||||
await expect(store.createProject('Taken')).rejects.toThrow()
|
||||
expect(store.getState().currentProjectId).toBe('host-1')
|
||||
expect(store.getState().toast).toMatch(/名称.*(其他|更换)/)
|
||||
expect(store.getState().toast).not.toContain('已创建')
|
||||
})
|
||||
|
||||
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/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('identity-scoped state', () => {
|
||||
it('resets state on a successful cookie login, without relying on a component callback', async () => {
|
||||
projects = [ready]; tasks = [task('alice-private')]
|
||||
await store.refreshProjects(); await store.refreshTasks()
|
||||
route = async () => response({ owner: 'bob', name: 'Bob', isAdmin: false, mustChangePassword: false, passwordSet: true, sessionScope: 'normal' })
|
||||
await api.login('bob', 'password-for-test')
|
||||
expect(store.getState().projects).toEqual([])
|
||||
expect(store.getState().tasks).toEqual([])
|
||||
})
|
||||
|
||||
it('clears old state as soon as logout starts, even while the server response is pending', async () => {
|
||||
projects = [ready]; tasks = [task('alice-private')]
|
||||
await store.refreshProjects(); await store.refreshTasks()
|
||||
const pending = deferred<Response>()
|
||||
route = async () => pending.promise
|
||||
const logout = api.logout()
|
||||
expect(store.getState().projects).toEqual([])
|
||||
expect(store.getState().tasks).toEqual([])
|
||||
pending.resolve(response({ ok: true }))
|
||||
await logout
|
||||
})
|
||||
|
||||
it('clears the previous account when the authenticated owner changes during identity refresh', async () => {
|
||||
await store.refreshMe()
|
||||
projects = [ready]; tasks = [task('alice-private')]
|
||||
await store.refreshProjects(); await store.refreshTasks()
|
||||
const previous = route
|
||||
projects = []; tasks = []
|
||||
route = (path, init) => path === '/api/me' ? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true })) : previous(path, init)
|
||||
await store.refreshMe()
|
||||
expect(store.getState()).toMatchObject({ me: { owner: 'bob' }, projects: [], tasks: [], currentProjectId: null, currentConvId: null })
|
||||
})
|
||||
|
||||
it('clears projects, private tasks, previews and persisted selections on a new session', async () => {
|
||||
projects = [ready]; tasks = [task('alice-private')]
|
||||
await store.refreshProjects(); await store.refreshTasks()
|
||||
store.openSurface('surface-alice')
|
||||
store.setUI({ projectSettingsId: 'host-1', adminZoneOpen: true, activeWorkspace: 'private-workspace' })
|
||||
localStorage.setItem('as.conv', 'alice-private')
|
||||
auth.beginSession()
|
||||
expect(store.getState()).toMatchObject({ projects: [], tasks: [], me: null, currentProjectId: null, currentConvId: null, artifacts: [], previewTabs: {}, previewActive: {} })
|
||||
expect(store.getState().ui).toMatchObject({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null })
|
||||
expect(localStorage.getItem('as.conv')).toBeNull()
|
||||
expect(JSON.parse(localStorage.getItem('as.ui') ?? '{}').lastProjectId).toBeNull()
|
||||
})
|
||||
|
||||
it('drops old project responses after switching accounts even when IDs are shared', async () => {
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects' ? old.promise : previous(path, init)
|
||||
const pending = store.refreshProjects()
|
||||
auth.beginSession()
|
||||
projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })]
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response([project('alice-only')]))
|
||||
await pending
|
||||
expect(store.getState().projects.map(p => p.id)).toEqual(['host-1'])
|
||||
expect(store.getState().currentProjectId).toBe('host-1')
|
||||
})
|
||||
|
||||
it('drops an old private task list after logout and a new session', async () => {
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/conversations' ? old.promise : previous(path, init)
|
||||
const pending = store.refreshTasks()
|
||||
auth.logout()
|
||||
auth.beginSession()
|
||||
tasks = [task('bob-private')]
|
||||
route = previous
|
||||
await store.refreshTasks()
|
||||
old.resolve(response([task('alice-private')]))
|
||||
await pending
|
||||
expect(store.getState().tasks.map(t => t.id)).toEqual(['bob-private'])
|
||||
})
|
||||
|
||||
it('drops an old artifact response when the next account selects the same project', async () => {
|
||||
await store.openProject('host-1')
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path.endsWith('/artifacts') ? old.promise : previous(path, init)
|
||||
const pending = store.refreshArtifacts('host-1')
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
await store.openProject('host-1')
|
||||
old.resolve(response([{ id: 'alice-artifact', projectId: 'host-1', owner: 'alice', name: 'Alice private', kind: 'file', size: 10, createdAt: '', updatedAt: '' }]))
|
||||
await pending
|
||||
expect(store.getState().artifacts).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['action', 'pin', 'dismiss', 'to-project', 'tidy'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => {
|
||||
projects = [ready]
|
||||
await store.refreshProjects()
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path.startsWith('/api/cards/') ? old.promise : previous(path, init)
|
||||
const card: ActivityCard = { id: 'alice-card', projectId: 'host-1', kind: 'progress', status: 'active', title: 'Alice private work', createdAt: '', updatedAt: '' }
|
||||
if (operation === 'tidy') store.upsertCard({ ...card, status: 'done' })
|
||||
const pending = operation === 'action' ? store.cardAction(card, 'finish')
|
||||
: operation === 'pin' ? store.cardPin(card, true)
|
||||
: operation === 'dismiss' ? store.cardDismiss(card)
|
||||
: operation === 'tidy' ? store.tidyCards() : store.cardToProject(card)
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response({ card: { ...card, status: 'done' }, origin: { ...card, status: 'dismissed' } }))
|
||||
await pending
|
||||
expect(store.getState().projectCards).toEqual([])
|
||||
expect(store.getState().cards).toEqual([])
|
||||
})
|
||||
|
||||
it('does not reopen a previous account conversation after its project load finishes late', async () => {
|
||||
projects = [ready]; tasks = [task('alice-private')]
|
||||
await store.refreshProjects()
|
||||
await store.openProject('another-project')
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects/host-1/artifacts' ? old.promise : previous(path, init)
|
||||
const pending = store.openTask('alice-private')
|
||||
auth.beginSession()
|
||||
tasks = []
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response([]))
|
||||
await pending
|
||||
expect(store.getState().currentConvId).toBeNull()
|
||||
expect(requests.map(r => r.path)).not.toContain('/api/conversations/alice-private')
|
||||
})
|
||||
|
||||
it('does not resolve old task creation as success after a new login', async () => {
|
||||
projects = [ready]
|
||||
await store.refreshProjects()
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects/host-1/conversations' ? old.promise : previous(path, init)
|
||||
const pending = store.createTask()
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response(task('alice-created')))
|
||||
await expect(pending).rejects.toThrow(/登录状态/)
|
||||
expect(store.getState().currentConvId).toBeNull()
|
||||
})
|
||||
|
||||
it('does not return a previous account attachment ID when artifact refresh finishes after login', async () => {
|
||||
projects = [ready]
|
||||
await store.refreshProjects()
|
||||
const old = deferred<Response>()
|
||||
const started = deferred<void>()
|
||||
const previous = route
|
||||
route = (path, init) => {
|
||||
if (path.endsWith('/uploads')) return Promise.resolve(response({ artifactId: 'alice-upload', filename: 'alice.txt', size: 3 }))
|
||||
if (path.endsWith('/artifacts')) { started.resolve(); return old.promise }
|
||||
return previous(path, init)
|
||||
}
|
||||
const pending = store.uploadAttachment(new File(['abc'], 'alice.txt'))
|
||||
await started.promise
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response([]))
|
||||
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)
|
||||
})
|
||||
})
|
||||
+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