Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f366ecc8d | ||
|
|
a5becea652 | ||
|
|
0a052f445b | ||
|
|
04a25f5fad | ||
|
|
09c6ac41e0 | ||
|
|
5313cfad4e | ||
|
|
4e1eb39035 | ||
|
|
53ceadf51e | ||
|
|
6106db250c | ||
|
|
2bc1e51456 | ||
|
|
eb2d6d32f9 | ||
|
|
40c2afe054 | ||
|
|
42cda9c056 | ||
|
|
15b962694b | ||
|
|
67585c1701 | ||
|
|
2ba275b991 | ||
|
|
e7f9081a93 | ||
|
|
c294835f8d | ||
|
|
880eff74f8 | ||
|
|
b7473fe585 | ||
|
|
dfcea5c988 | ||
|
|
07a03ac4f6 | ||
|
|
d69009e081 | ||
|
|
55a64f40e8 | ||
|
|
b3fa39744d | ||
|
|
23f96fd921 | ||
|
|
f96a591123 | ||
|
|
698fffd929 | ||
|
|
44b964682c | ||
|
|
0655bc37e1 | ||
|
|
9fc7da2ace | ||
|
|
dcd2699933 | ||
|
|
0159f009fa | ||
|
|
43d1933b8c | ||
|
|
91a832f93e | ||
|
|
37c668dbad | ||
|
|
e6eecb2bfa | ||
|
|
ac668d11d3 | ||
|
|
411a79d9e2 | ||
|
|
d9bb780384 | ||
|
|
4965de269a | ||
|
|
b7194d54d5 | ||
|
|
f752a7bcec | ||
|
|
e3332badca | ||
|
|
3a5fbe6e42 | ||
|
|
fca832e3b0 | ||
|
|
81b984c752 | ||
|
|
c4ebcf5404 | ||
|
|
11464242c0 | ||
|
|
809f28d48f | ||
|
|
336ec94763 | ||
|
|
0ae27749cf | ||
|
|
531b683a92 | ||
|
|
0f52634380 | ||
|
|
1edd872c55 | ||
|
|
a45a4d8652 | ||
|
|
378ec24bbd | ||
|
|
cc233fec90 | ||
|
|
8db7093a9d | ||
|
|
034bd5f724 | ||
|
|
83a062b7f8 | ||
|
|
fdaf89de9d | ||
|
|
986ebdbb61 | ||
|
|
e058adcf72 | ||
|
|
d01a3ebd81 | ||
|
|
fac99939fa | ||
|
|
c1225bb880 | ||
|
|
57fabf1a95 | ||
|
|
360d6406e4 | ||
|
|
25e76c5e01 | ||
|
|
41f6ce8760 | ||
|
|
0f860c0743 | ||
|
|
fdedaa1123 | ||
|
|
4ab507b1b6 | ||
|
|
01b853fd0e | ||
|
|
7acfef0a1f | ||
|
|
c22120d7fe | ||
|
|
15d8a4093b | ||
|
|
d2d81e119c | ||
|
|
fb846bed14 | ||
|
|
566fec9f56 | ||
|
|
98751916da | ||
|
|
ed9845f8e4 | ||
|
|
a2ebc7fb84 | ||
|
|
367aeefff4 | ||
|
|
65d3b8ef02 | ||
|
|
ed7e3126e7 | ||
|
|
aaced8796b | ||
|
|
d0d9876b18 | ||
|
|
2c56054861 | ||
|
|
c2878c04ea | ||
|
|
b80153dbf8 | ||
|
|
39dc8c456c | ||
|
|
482ba885e1 | ||
|
|
09310cb753 | ||
|
|
fcf40ff75f |
@@ -6,3 +6,6 @@ dist/
|
||||
|
||||
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
|
||||
.mimosa
|
||||
# Playwright 运行工件(e2e/ 用例输出;详工件在 Dev .tmp/e2e/<ts>/frontend/)
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// 增量 3 验收(2026-10-12 诊断报告):改密弹窗表单语义——<form onSubmit> 承载提交
|
||||
//(Enter 可提交)、autocomplete 三段标注(username/current-password/new-password)、
|
||||
// 只读用户名关联、错误文案复用 humanChangePasswordError(401 不再一刀切)。
|
||||
// 驱动:scripts/e2e.sh 第 4 阶段(E2E_PLAIN_BASE=1)——表单语义与命令面无关,
|
||||
// 任意 auth-on 服务可验,普通基座段顺路覆盖;RDM 组合主跑(第 3 阶段)本文件自跳过。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||
|
||||
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
// 受限设密门(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(PASSWORD2)
|
||||
await pwInputs.nth(1).fill(PASSWORD2)
|
||||
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()
|
||||
}
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('改密弹窗:form 提交、autocomplete/用户名关联、错误分类文案', async ({ page }) => {
|
||||
await login(page)
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '修改密码…' }).click()
|
||||
const dlg = page.getByRole('dialog', { name: '修改密码' })
|
||||
await expect(dlg).toBeVisible()
|
||||
|
||||
// 用户名只读关联 + 三段 autocomplete。
|
||||
const username = dlg.locator('input[autocomplete="username"]')
|
||||
await expect(username).toHaveValue(OWNER)
|
||||
// 本仓 @playwright/test 无 toBeReadOnly:以 readonly 属性断言只读关联。
|
||||
await expect(username).toHaveAttribute('readonly', '')
|
||||
await expect(dlg.locator('input[autocomplete="current-password"]')).toHaveCount(1)
|
||||
await expect(dlg.locator('input[autocomplete="new-password"]')).toHaveCount(2)
|
||||
|
||||
// 表单语义:填错当前密码后按 Enter(不点按钮)提交——证明提交走 <form onSubmit>。
|
||||
await dlg.locator('input[autocomplete="current-password"]').fill('definitely-wrong-pass')
|
||||
await dlg.locator('input[autocomplete="new-password"]').first().fill('ANewPass#2026x')
|
||||
await dlg.locator('input[autocomplete="new-password"]').nth(1).fill('ANewPass#2026x')
|
||||
await dlg.locator('input[autocomplete="current-password"]').press('Enter')
|
||||
// 后端 401 "current password incorrect" → humanChangePasswordError 的分类文案。
|
||||
await expect(dlg.getByText('当前密码不正确')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
// 双入口对拍的浏览器侧说明。CI 门禁在 backend command_pairs_test.go:
|
||||
// 新内置命令未写入 testdata/command-pairs.json 时 go test 失败。
|
||||
// 安装 Playwright 后可用 npx playwright test e2e/command-pairs.spec.ts 做人工抽查。
|
||||
// 人工抽查:PLAYWRIGHT_INCLUDE_COMMAND_PAIRS=1 npx playwright test e2e/command-pairs.playwright.ts
|
||||
// (scripts/e2e.sh 驱动的验收跑 auth-on 种子环境,本用例面向免登录演示环境,默认跳过。)
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('command menu and chat share builtin.help', async ({ page }) => {
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to run')
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL || !process.env.PLAYWRIGHT_INCLUDE_COMMAND_PAIRS,
|
||||
'set PLAYWRIGHT_BASE_URL and PLAYWRIGHT_INCLUDE_COMMAND_PAIRS to run')
|
||||
await page.goto(process.env.PLAYWRIGHT_BASE_URL!)
|
||||
await expect(page.getByText('帮助')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
// 增量 2 验收(2026-10-12 诊断报告):普通基座(无 rdm.inspect.* 命令面)不展示
|
||||
// 「追溯审计」入口,且不发出任何 rdm.* 命令请求——消除「入口可达、进入即 400」。
|
||||
// 驱动:scripts/e2e.sh 第 4 阶段——RDM 组合验收跑完后,同一数据目录换基座 CLI
|
||||
//(agent-system serve,同源同 pin)服务,同一 preview 指向它,只跑本文件。
|
||||
// 环境契约与 rdm-audit.playwright.ts 一致(e2e.sh 注入)。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||
|
||||
// 仅第 4 阶段(普通基座)启用:RDM 组合主跑(第 3 阶段)扫全目录时本文件自跳过,
|
||||
// 否则「入口隐藏」断言会跑在入口本就可见的 RDM 组合上。
|
||||
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
|
||||
|
||||
// 与 rdm-audit spec 同一账号:若数据库仍是初始密码则先过强制改密门。
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const gate = page.getByText('修改密码')
|
||||
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(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
} else {
|
||||
// 已改密(RDM 阶段完成):改用新密码重登。
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
}
|
||||
|
||||
test('普通基座:命令面无 rdm.inspect.* → 审计入口隐藏、无 rdm 请求', async ({ page }) => {
|
||||
const rdmRequests: string[] = []
|
||||
page.on('request', r => {
|
||||
if (r.url().includes('/api/commands/rdm.')) rdmRequests.push(r.url())
|
||||
})
|
||||
await login(page)
|
||||
// 登录成功标志:侧栏常驻「代理」与「文件」体系工具(均为无条件内置)。
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator('.tsb-tool', { hasText: '文件' })).toBeVisible()
|
||||
// 能力探测(GET /api/commands)返回普通基座清单后,审计入口保持隐藏。
|
||||
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
|
||||
await page.waitForTimeout(500)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
|
||||
// 入口不存在则不应有任何 rdm.* 命令请求(原缺陷路径是进入即 400)。
|
||||
expect(rdmRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
// U4 运行验收:真实浏览器走通项目追溯审计工作区(RdmAuditWorkspace 四入口)。
|
||||
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
|
||||
// 与 vite preview,并经 /api/commands 造种子(主项目已 ingest、隔离项目零数据)。
|
||||
// 环境契约(e2e.sh 注入;缺省值与 e2e.sh 保持一致):
|
||||
// PLAYWRIGHT_BASE_URL 前端入口(默认 http://127.0.0.1:4173)
|
||||
// E2E_OWNER/E2E_PASSWORD 登录账号(域管理员+基座管理员)
|
||||
// E2E_PROJECT_MAIN 已 ingest 的主项目名
|
||||
// E2E_PROJECT_ISOLATED 零数据隔离项目名
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
|
||||
const ISOLATED = process.env.E2E_PROJECT_ISOLATED || '隔离检查项目'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
// 首个用例用初始密码(触发强制改密门),后续用例用改后的新密码。
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const gate = page.getByText('修改密码')
|
||||
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(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
} else {
|
||||
// 已改密:改用新密码重登。
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
// 登录成功后侧栏体系工具出现「追溯审计」入口。
|
||||
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
async function openAudit(page: Page) {
|
||||
// 展开态侧栏底部的体系工具条(tsb-tool,name=标题)。
|
||||
await page.locator('.tsb-tool', { hasText: '追溯审计' }).click()
|
||||
await expect(page.getByText('追溯审计').first()).toBeVisible()
|
||||
await expect(page.locator('.rdm-audit-project')).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe.serial('U4 项目追溯审计工作区', () => {
|
||||
// 卡片按 label 精确定位(「章节」与「空缺章节」等子串互不干扰)。
|
||||
const card = (page: Page, label: string) =>
|
||||
page.locator('.rdm-audit-card').filter({ has: page.locator('.rdm-audit-card-label', { hasText: new RegExp(`^${label}$`) }) })
|
||||
|
||||
test('登录并打开工作区,概览计数与覆盖标注可读', async ({ page }) => {
|
||||
await login(page)
|
||||
await openAudit(page)
|
||||
// 概览:统计卡片 + 水位标注(数据完整性提示,不冒充实时)。
|
||||
await expect(page.locator('.rdm-audit-card')).toHaveCount(9)
|
||||
await expect(page.getByText(/截至水位 \d+/)).toBeVisible()
|
||||
// 主项目应有切片与章节(种子 structure 装载 + ingest 保证)。
|
||||
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
|
||||
await expect(card(page, '章节')).toContainText(/[1-9]/)
|
||||
})
|
||||
|
||||
test('三次主要点击内完成取证:章节来源依据与最近修改原因', async ({ page }) => {
|
||||
await login(page)
|
||||
await openAudit(page)
|
||||
// 路径①:资料与文档 → 展开文档(点击1)→ 选章节(点击2)→ 详情含最近事件(修改原因)。
|
||||
// docs 树是层级结构:第一层是文档,章节在展开后的第二层。
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
await page.locator('.rdm-audit-tree-node').first().click()
|
||||
const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node')
|
||||
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: 'section' }) }).first()
|
||||
await expect(sectionNode).toBeVisible()
|
||||
await sectionNode.click()
|
||||
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
|
||||
await expect(page.getByText('最近事件')).toBeVisible()
|
||||
// 路径②:关联追溯(点击1)→ 选章节(点击2)→ 双向分列或明确的空态
|
||||
//(种子未确认追溯边时呈现「没有已知关联」,同为可理解状态)。
|
||||
await page.getByRole('tab', { name: '关联追溯' }).click()
|
||||
await expect(page.locator('.rdm-audit-pick button')).not.toHaveCount(0)
|
||||
await page.locator('.rdm-audit-pick button').first().click()
|
||||
await expect(page.getByText('← 返回')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(/来源依据(\d+)/).or(page.getByText('没有已知关联')),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('操作记录按操作归组,历史模式可见水位', async ({ page }) => {
|
||||
await login(page)
|
||||
await openAudit(page)
|
||||
await page.getByRole('tab', { name: '操作记录' }).click()
|
||||
// 事件以单行或操作组呈现;种子项目必有 ingest 事件。
|
||||
await expect(
|
||||
page.locator('.rdm-audit-event').or(page.locator('.rdm-audit-opgroup')),
|
||||
).not.toHaveCount(0)
|
||||
// 历史模式:返回当前切换明确,无对象时输入提示可理解。
|
||||
await page.getByRole('button', { name: '历史模式' }).click()
|
||||
await expect(page.getByText(/截至 seq \d+/)).toBeVisible()
|
||||
await page.getByRole('button', { name: '← 返回当前' }).click()
|
||||
await expect(page.getByRole('button', { name: '历史模式' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('项目切换无串页:隔离项目显示空态,切回主项目数据一致', async ({ page }) => {
|
||||
await login(page)
|
||||
await openAudit(page)
|
||||
// 切到隔离项目:概览计数应为 0 且无残留「加载中…」。
|
||||
await page.locator('.rdm-audit-project').selectOption({ label: ISOLATED })
|
||||
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
|
||||
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText('0')
|
||||
// 空态:文档树与时间线给出可理解提示。
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
await expect(page.getByText('暂无文档')).toBeVisible()
|
||||
await page.getByRole('tab', { name: '操作记录' }).click()
|
||||
await expect(page.getByText('暂无事件')).toBeVisible()
|
||||
// 立即切回主项目:标题与请求一致,数据回到非零(过期响应被丢弃,无串页)。
|
||||
await page.getByRole('tab', { name: '概览' }).click()
|
||||
await page.locator('.rdm-audit-project').selectOption({ label: MAIN })
|
||||
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
|
||||
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
|
||||
await expect(page.locator('.rdm-audit-project')).toHaveValue(
|
||||
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,272 @@
|
||||
// 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, options: { canAssign?: boolean; missingVersion?: boolean } = {}) {
|
||||
const writes: { command: string; params: Record<string, unknown> }[] = []
|
||||
const calls: { command: string; params: Record<string, unknown> }[] = []
|
||||
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
|
||||
const active = new Set<string>()
|
||||
const submissions: Record<string, unknown> = { 'doc-A': null, 'doc-B': null }
|
||||
const canAssign = options.canAssign !== false
|
||||
const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove',
|
||||
'rdm.project.member.set-role', 'rdm.project.member.impact', 'rdm.account.list', 'rdm.role.options',
|
||||
'rdm.duty.catalog', 'rdm.duty.tree', 'rdm.duty.get', 'rdm.review.assign', 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch',
|
||||
'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final', 'rdm.review.doc.status']
|
||||
const docs = [
|
||||
{ docId: 'doc-A', title: '文档A', typeKey: 'design', typeLabel: '设计方案', sections: 1, dutyVersion: 3, assigned: true, author: 'alice', reviewers: ['carol'], approver: 'dave' },
|
||||
{ docId: 'doc-B', title: '文档B', typeKey: 'design', typeLabel: '设计方案', sections: 1, dutyVersion: 7, assigned: true, author: 'bob', reviewers: ['carol'], approver: 'dave' },
|
||||
]
|
||||
const members = ['alice', 'bob', 'carol', 'dave'].map(owner => ({ owner, role: owner === 'alice' ? 'owner' : 'member', addedBy: '', addedAt: '', valid: true,
|
||||
profRole: { roleId: owner === 'bob' ? 'systems' : 'software', name: owner === 'bob' ? '系统工程师' : '软件工程师' },
|
||||
dutyEligibility: { author: { allowed: true }, reviewer: { allowed: true }, approver: { allowed: true } } }))
|
||||
const filteredDocs = (params: Record<string, unknown>) => docs.filter(document => !params.query || (params.match === 'exact'
|
||||
? document.docId === params.query || document.title === params.query : `${document.docId} ${document.title}`.includes(String(params.query))))
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: false }
|
||||
else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '',
|
||||
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
|
||||
else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} }
|
||||
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: filteredDocs(params), next: '', count: filteredDocs(params).length, total: docs.length, canAssignDuties: canAssign,
|
||||
filters: { stages: [], typeKeys: [{ value: 'design', label: '设计方案' }], assignments: [{ value: 'assigned', label: '已分配' }, { value: 'unassigned', label: '未分配' }], statuses: [] } }
|
||||
else if (command === 'rdm.duty.tree') payload = { nodes: filteredDocs(params).map(document => ({ id: document.docId, kind: 'doc', label: document.title, selectable: true, docId: document.docId,
|
||||
children: [{ id: document.docId + '-section', kind: 'section', label: '方案章节', selectable: false, children: [] }] })), next: '', canAssignDuties: canAssign }
|
||||
else if (command === 'rdm.role.options') payload = { roles: [{ roleId: 'systems', name: '系统工程师' }, { roleId: 'software', name: '软件工程师' }] }
|
||||
else if (command === 'rdm.project.member.list') payload = { members: members.filter(person => (!params.query || person.owner.includes(String(params.query))) && (!params.roleId || person.profRole.roleId === params.roleId)),
|
||||
next: '', accountDirectoryAvailable: true, canManageMembers: true, canAssignDuties: canAssign, canConfigurePermissions: false }
|
||||
else if (command === 'rdm.duty.get') {
|
||||
const doc = docs.find(d => d.docId === params.docId)!
|
||||
payload = { docId: doc.docId, title: doc.title, assigned: doc.assigned,
|
||||
roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [], canAssignDuties: canAssign,
|
||||
...(options.missingVersion ? {} : { dutyVersion: doc.dutyVersion }),
|
||||
active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice', duties: { Author: doc.author, Reviewers: doc.reviewers, Approver: doc.approver } } : null }
|
||||
} else if (command === 'rdm.review.doc.status') payload = { submission: submissions[String(params.docId)] }
|
||||
else {
|
||||
writes.push({ command, params })
|
||||
if (command === 'rdm.review.doc.submit') {
|
||||
const document = docs.find(document => document.docId === params.docId)!
|
||||
active.add(document.docId)
|
||||
submissions[document.docId] = { round: 1, state: 'submitted', createdBy: 'alice',
|
||||
duties: { Author: document.author, Reviewers: document.reviewers, Approver: document.approver }, reviews: {} }
|
||||
}
|
||||
if (command === 'rdm.review.assign.doc' || command === 'rdm.review.assign.doc.batch') {
|
||||
const entries = command === 'rdm.review.assign.doc' ? [params] : params.docs as Record<string, unknown>[]
|
||||
for (const entry of entries) {
|
||||
const document = docs.find(document => document.docId === entry.docId)!
|
||||
expect(entry.expectedDutyVersion).toBe(document.dutyVersion)
|
||||
const changed = entry.author !== document.author || entry.approver !== document.approver
|
||||
|| JSON.stringify(entry.reviewers) !== JSON.stringify(document.reviewers)
|
||||
if (changed && active.has(document.docId)) expect(entry.confirmInvalidation || params.confirmInvalidation).toBe(true)
|
||||
if (changed) {
|
||||
document.author = String(entry.author); document.reviewers = entry.reviewers as string[]; document.approver = String(entry.approver); document.dutyVersion++
|
||||
active.delete(document.docId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
body = { payload }
|
||||
} else if (path.endsWith('/cards')) body = { cards: [] }
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
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()
|
||||
await expect(submit).toBeDisabled()
|
||||
await expect(page.getByRole('heading', { name: '整文审签 · 第 1 轮 · 审核中', exact: true })).toBeVisible()
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.review.doc.submit', params: { projectId: 'domain-7', docId: 'doc-A' } }])
|
||||
})
|
||||
|
||||
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', expectedDutyVersion: 3, confirmInvalidation: true,
|
||||
} }])
|
||||
})
|
||||
|
||||
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', expectedDutyVersion: 7, confirmInvalidation: false,
|
||||
} }])
|
||||
})
|
||||
|
||||
test('document ID and title searches share list and tree selection without making chapters selectable', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
await page.getByLabel('文档匹配方式', { exact: true }).selectOption('exact')
|
||||
await page.getByRole('textbox', { name: '搜索文档', exact: true }).fill('doc-B')
|
||||
await expect(page.getByRole('button', { name: '文档A', exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: '文档B', exact: true })).toBeVisible()
|
||||
await page.getByLabel('文档浏览方式', { exact: true }).selectOption('tree')
|
||||
await page.getByRole('button', { name: '文档B', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '展开 文档B', exact: true }).click()
|
||||
await expect(page.getByText('方案章节 · 章节', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '方案章节', exact: true })).toHaveCount(0)
|
||||
expect(fixture.writes).toEqual([])
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.duty.tree' && call.params.query === 'doc-B' && call.params.match === 'exact')).toBe(true)
|
||||
})
|
||||
|
||||
test('professional role filtering keeps duty candidates within project membership and selected labels stay visible', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await page.getByLabel('专业角色筛选', { exact: true }).selectOption('systems')
|
||||
await page.locator('.picker[aria-label="编制人"]').getByRole('combobox').focus()
|
||||
await expect(page.getByRole('option', { name: /^bob/ })).toBeVisible()
|
||||
await expect(page.getByRole('option', { name: /^carol/ })).toHaveCount(0)
|
||||
await page.getByRole('option', { name: /^bob/ }).click()
|
||||
await expect(page.locator('.picker[aria-label="编制人"] .picker-selected')).toContainText('bob')
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.project.member.list' && call.params.roleId === 'systems')).toBe(true)
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.account.list')).toBe(false)
|
||||
})
|
||||
|
||||
test('missing version or caller assignment capability keeps duty editors read only', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page, { canAssign: false, missingVersion: true })
|
||||
await page.getByRole('button', { name: '文档A', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '保存当前职责', exact: true })).toBeDisabled()
|
||||
await expect(page.locator('.picker[aria-label="编制人"]').getByRole('combobox')).toBeDisabled()
|
||||
await expect(page.getByText('职责版本尚未就绪,只能查看;请升级项目服务后重新读取', { exact: true })).toBeVisible()
|
||||
expect(fixture.writes).toEqual([])
|
||||
})
|
||||
|
||||
test('batch preview carries each document version and explicitly confirms active review invalidation', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
fixture.active.add('doc-A')
|
||||
const prompts: string[] = []
|
||||
page.on('dialog', async dialog => { prompts.push(dialog.message()); await dialog.accept() })
|
||||
await page.getByRole('checkbox', { name: '选择 文档A', exact: true }).check()
|
||||
await page.getByRole('checkbox', { name: '选择 文档B', exact: true }).check()
|
||||
await chooseMember(page, '批量编制人', 'bob')
|
||||
await chooseMember(page, '批量审核人', 'carol')
|
||||
await chooseMember(page, '批量批准人', 'dave')
|
||||
await page.getByRole('button', { name: '预览并分配 2 份文档', exact: true }).click()
|
||||
await expect(page.getByText('批量职责已保存,已重新读取目录', { exact: true })).toBeVisible()
|
||||
expect(prompts).toHaveLength(1)
|
||||
expect(prompts[0]).toContain('第 1 轮将失效')
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc.batch', params: { projectId: 'domain-7', confirmInvalidation: true,
|
||||
docs: [
|
||||
{ docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave', expectedDutyVersion: 3, confirmInvalidation: true },
|
||||
{ docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'dave', expectedDutyVersion: 7, confirmInvalidation: true },
|
||||
] } }])
|
||||
})
|
||||
|
||||
test('a batch keeping existing duties does not claim its active review will be invalidated', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
fixture.active.add('doc-A')
|
||||
const prompts: string[] = []
|
||||
page.on('dialog', async dialog => { prompts.push(dialog.message()); await dialog.accept() })
|
||||
await page.getByRole('checkbox', { name: '选择 文档A', exact: true }).check()
|
||||
await chooseMember(page, '批量编制人', 'alice')
|
||||
await chooseMember(page, '批量审核人', 'carol')
|
||||
await chooseMember(page, '批量批准人', 'dave')
|
||||
await page.getByRole('button', { name: '预览并分配 1 份文档', exact: true }).click()
|
||||
await expect(page.getByText('批量职责已保存,已重新读取目录', { exact: true })).toBeVisible()
|
||||
expect(prompts[0]).not.toContain('失效')
|
||||
expect(fixture.writes[0].params.confirmInvalidation).toBe(false)
|
||||
expect(fixture.active.has('doc-A')).toBe(true)
|
||||
})
|
||||
|
||||
test('a late review status cannot replace the selected document frozen review', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
const submission = (round: number, author: string) => ({ round, state: 'submitted', digest: '', createdBy: author,
|
||||
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')
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
// Deterministic HTTP contract QA of the real React settings surfaces. Live domain QA is separate.
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
|
||||
|
||||
async function settingsPage(page: Page, canManage = true) {
|
||||
const calls: { command: string; params: Record<string, unknown> }[] = []
|
||||
const writes: { command: string; params: Record<string, unknown> }[] = []
|
||||
const settingWrites: { path: string; body: unknown }[] = []
|
||||
const people = [
|
||||
{ owner: 'alice', name: '项目负责人', valid: true, joined: true, role: 'owner', projectRole: 'owner', profRole: { roleId: 'systems', name: '系统工程师' } },
|
||||
{ owner: 'bob', name: '李工', valid: true, joined: false, role: 'member', projectRole: 'member', profRole: { roleId: 'systems', name: '系统工程师' } },
|
||||
{ owner: 'carol', name: '陈工', valid: true, joined: true, role: 'member', projectRole: 'member', profRole: { roleId: 'software', name: '软件工程师' } },
|
||||
{ owner: 'unknown', name: '待核验', joined: false, profRole: null },
|
||||
]
|
||||
const schema = [
|
||||
{ key: 'deliverables.style', group: 'deliverables', label: '项目交付风格', type: 'enum', options: ['modern', 'formal', 'minimal'], default: 'modern', effect: 'new-run', scope: 'project' },
|
||||
{ key: 'models.chat.model', group: 'models', label: '主模型', type: 'string', effect: 'new-run', scope: 'system' },
|
||||
]
|
||||
let projectRevision = 4
|
||||
let projectValues: Record<string, unknown> = { deliverables: { style: 'formal' } }
|
||||
const overrides = new Map<string, boolean | null>([['alice:doc.read', false], ['alice:doc.write', true]])
|
||||
const permissions = (member: string) => [
|
||||
{ action: 'project.create', label: '创建项目', scope: 'project', allowed: true, defaultAllowed: true, capAllowed: true, override: null, source: 'default' },
|
||||
{ action: 'doc.read', label: '读取文档', scope: 'project', allowed: overrides.get(member + ':doc.read') ?? true,
|
||||
defaultAllowed: true, capAllowed: true, override: overrides.get(member + ':doc.read') ?? null, source: overrides.has(member + ':doc.read') ? 'override' : 'prof-role-default' },
|
||||
{ action: 'doc.write', label: '编制与编辑文档', scope: 'project', allowed: false, defaultAllowed: true, capAllowed: false, override: overrides.get(member + ':doc.write') ?? null, source: 'cap', reason: '管理员上限禁止' },
|
||||
]
|
||||
const matches = (person: typeof people[number], params: Record<string, unknown>) => {
|
||||
const query = String(params.query || '')
|
||||
return (!params.roleId || person.profRole?.roleId === params.roleId) && (!params.projectRole || person.projectRole === params.projectRole)
|
||||
&& (!query || (params.match === 'exact' ? person.owner === query || person.name === query : `${person.owner} ${person.name}`.includes(query)))
|
||||
}
|
||||
const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.set-role', 'rdm.project.member.impact',
|
||||
'rdm.account.list', 'rdm.role.options', 'rdm.perm.check', 'rdm.perm.matrix', 'rdm.perm.set', 'rdm.perm.unset']
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const path = url.pathname
|
||||
let body: unknown = []
|
||||
if (path === '/api/me') body = { owner: 'alice', name: '项目负责人', auth: true, isAdmin: false }
|
||||
else if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/projects') body = [{ id: 'host-1', name: '项目配置验收', createdAt: '', updatedAt: '', settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
|
||||
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
|
||||
else if (path === '/api/settings') body = url.searchParams.has('project') ? { schema, defaults: {}, user: {}, project: projectValues, projectRevision, projectCanConfigure: canManage,
|
||||
personalSchema: [{ key: 'crossSession.read', group: 'crossSession', label: '跨会话读取', type: 'boolean', default: false, effect: 'new-run', scope: 'project-personal' }], personalDefaults: {}, personalUser: {}, personalProject: {} }
|
||||
: { schema: [{ key: 'ui.instructions', group: 'ui', label: '个人偏好', type: 'string', effect: 'new-run', scope: 'user' }], defaults: {}, user: {}, project: {} }
|
||||
else if (path.startsWith('/api/settings/project/') && request.method() === 'PUT') {
|
||||
const patch = request.postDataJSON()
|
||||
settingWrites.push({ path, body: patch })
|
||||
expect(patch.expectedRevision).toBe(projectRevision)
|
||||
if (patch.values.deliverables) projectValues = patch.values
|
||||
if (patch.unset?.includes('deliverables.style')) projectValues = {}
|
||||
projectRevision++
|
||||
body = { ok: true, projectRevision }
|
||||
} else if (path.startsWith('/api/commands/')) {
|
||||
const command = path.substring('/api/commands/'.length)
|
||||
const params = request.postDataJSON().params as Record<string, unknown>
|
||||
expect(params.projectId).toBe('domain-7')
|
||||
expect(params).not.toHaveProperty('actor')
|
||||
calls.push({ command, params })
|
||||
let payload: unknown = {}
|
||||
if (command === 'rdm.role.options') payload = { roles: [{ roleId: 'systems', name: '系统工程师' }, { roleId: 'software', name: '软件工程师' }] }
|
||||
else if (command === 'rdm.account.list') payload = { accounts: people.filter(person => matches(person, params)), next: '' }
|
||||
else if (command === 'rdm.project.member.list') payload = { members: people.filter(person => person.joined && matches(person, params)), next: '',
|
||||
accountDirectoryAvailable: true, canManageMembers: canManage, canAssignDuties: false, canConfigurePermissions: canManage }
|
||||
else if (command === 'rdm.project.member.impact') payload = { member: params.member, canRemove: params.member !== 'carol', lastOwner: false,
|
||||
documents: params.member === 'carol' ? [{ docId: 'private-doc', label: '受控文档', sectionId: 'chapter-1', activeRound: 2 }] : [], reasons: params.member === 'carol' ? ['请先交接文档职责'] : [] }
|
||||
else if (command === 'rdm.perm.check') {
|
||||
const person = people.find(person => person.owner === params.member)!
|
||||
payload = { projectId: 'domain-7', member: person.owner, name: person.name, role: person.role, profRole: person.profRole, actions: permissions(person.owner), canConfigurePermissions: canManage }
|
||||
} else if (command === 'rdm.perm.matrix') payload = { projectId: 'domain-7', canConfigurePermissions: canManage,
|
||||
rows: people.filter(person => person.joined && matches(person, params)).map(person => ({ member: person.owner, name: person.name, role: person.role, profRole: person.profRole, actions: permissions(person.owner) })), next: '' }
|
||||
else {
|
||||
writes.push({ command, params })
|
||||
if (command === 'rdm.project.member.add') { const person = people.find(person => person.owner === params.member)!; person.joined = true; person.role = String(params.role); person.projectRole = person.role }
|
||||
if (command === 'rdm.project.member.set-role') { const person = people.find(person => person.owner === params.member)!; person.role = String(params.role); person.projectRole = person.role }
|
||||
if (command === 'rdm.perm.unset') overrides.delete(`${params.member}:${params.action}`)
|
||||
if (command === 'rdm.perm.set') overrides.set(`${params.member}:${params.action}`, params.allow === true)
|
||||
}
|
||||
body = { payload }
|
||||
} else if (path.endsWith('/cards')) body = { cards: [] }
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: '项目设置', exact: true }).click()
|
||||
return { calls, writes, settingWrites }
|
||||
}
|
||||
|
||||
test('shared settings exclude system controls and restore inheritance with an explicit versioned removal', async ({ page }) => {
|
||||
const fixture = await settingsPage(page)
|
||||
await expect(page.getByLabel('项目交付风格', { exact: true })).toHaveValue('formal')
|
||||
await expect(page.getByLabel('主模型', { exact: true })).toHaveCount(0)
|
||||
await page.getByLabel('项目交付风格', { exact: true }).selectOption('')
|
||||
await page.getByRole('button', { name: '保存项目设置', exact: true }).click()
|
||||
await expect(page.getByText('已保存共享项目配置;按各项标注的生效时机应用', { exact: true })).toBeVisible()
|
||||
expect(fixture.settingWrites).toEqual([{ path: '/api/settings/project/host-1', body: { values: {}, expectedRevision: 4, unset: ['deliverables.style'] } }])
|
||||
})
|
||||
|
||||
test('members can be found by name and professional role; joined accounts cannot silently change identity', async ({ page }) => {
|
||||
const fixture = await settingsPage(page)
|
||||
await page.getByRole('tab', { name: '项目成员', exact: true }).click()
|
||||
await page.getByLabel('专业角色筛选', { exact: true }).selectOption('systems')
|
||||
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').fill('李工')
|
||||
await page.getByRole('option', { name: /^李工/ }).click()
|
||||
await page.getByRole('button', { name: '添加成员', exact: true }).click()
|
||||
await expect(page.getByRole('row').filter({ hasText: '李工' })).toContainText('项目成员')
|
||||
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').fill('李工')
|
||||
await expect(page.getByRole('option', { name: /^李工/ })).toHaveAttribute('aria-disabled', 'true')
|
||||
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').press('ArrowDown')
|
||||
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').press('Enter')
|
||||
await expect(page.getByRole('button', { name: '添加成员', exact: true })).toBeDisabled()
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.project.member.add', params: { projectId: 'domain-7', member: 'bob', role: 'member' } }])
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.account.list' && call.params.query === '李工' && call.params.roleId === 'systems')).toBe(true)
|
||||
})
|
||||
|
||||
test('member removal shows scoped duty impacts and blocks affected members before any write', async ({ page }) => {
|
||||
const fixture = await settingsPage(page)
|
||||
await page.getByRole('tab', { name: '项目成员', exact: true }).click()
|
||||
await page.getByRole('row').filter({ hasText: '陈工' }).getByRole('button', { name: '移除', exact: true }).click()
|
||||
await expect(page.getByText('受控文档', { exact: false })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '确认移除', exact: true })).toBeDisabled()
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.project.member.impact' && call.params.member === 'carol')).toBe(true)
|
||||
expect(fixture.writes).toEqual([])
|
||||
})
|
||||
|
||||
test('ordinary members see their own permissions immediately and cannot open management views', async ({ page }) => {
|
||||
const fixture = await settingsPage(page, false)
|
||||
await page.getByRole('tab', { name: '项目权限', exact: true }).click()
|
||||
await expect(page.getByRole('cell', { name: '读取文档', exact: true })).toBeVisible()
|
||||
await expect(page.locator('option[value="matrix"]')).toBeDisabled()
|
||||
await expect(page.locator('.picker[aria-label="选择权限成员"]').getByRole('combobox')).toBeDisabled()
|
||||
await expect(page.getByLabel('读取文档项目覆盖', { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByText('创建项目', { exact: true })).toHaveCount(0)
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.perm.check' && call.params.member === 'alice')).toBe(true)
|
||||
expect(fixture.writes).toEqual([])
|
||||
})
|
||||
|
||||
test('permission matrix filters members and cap-bound overrides use actual stored values and removal', async ({ page }) => {
|
||||
const fixture = await settingsPage(page)
|
||||
await page.getByRole('tab', { name: '项目权限', exact: true }).click()
|
||||
await expect(page.getByLabel('编制与编辑文档项目覆盖', { exact: true })).toHaveValue('allow')
|
||||
await expect(page.getByLabel('编制与编辑文档项目覆盖', { exact: true }).locator('option[value="allow"]')).toBeDisabled()
|
||||
await page.getByLabel('读取文档项目覆盖', { exact: true }).selectOption('default')
|
||||
await expect(page.getByLabel('读取文档项目覆盖', { exact: true })).toHaveValue('default')
|
||||
expect(fixture.writes).toEqual([{ command: 'rdm.perm.unset', params: { projectId: 'domain-7', member: 'alice', action: 'doc.read' } }])
|
||||
await page.getByLabel('权限查看方式', { exact: true }).selectOption('matrix')
|
||||
await page.getByLabel('专业角色筛选', { exact: true }).selectOption('software')
|
||||
await expect(page.getByRole('row').filter({ hasText: '陈工' })).toBeVisible()
|
||||
await expect(page.getByRole('row').filter({ hasText: '项目负责人' })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '陈工 · 读取文档 · 允许', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: '陈工 · 权限依据', exact: true })).toBeVisible()
|
||||
await expect(page.getByText('创建项目', { exact: true })).toHaveCount(0)
|
||||
expect(fixture.calls.some(call => call.command === 'rdm.perm.matrix' && call.params.roleId === 'software')).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import type { Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'requires a served frontend bundle')
|
||||
|
||||
const conversation: Conversation = {
|
||||
id: 'surface-reload', projectId: 'surface-project', title: '页面恢复回归',
|
||||
createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z',
|
||||
}
|
||||
const snapshots: SurfaceSnapshot[] = ['page-1', 'page-2'].map((surfaceId, i) => ({
|
||||
identity: { surfaceId, owner: 'fixture', pluginId: 'generative-ui', conversationId: conversation.id },
|
||||
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1,
|
||||
spec: { title: `恢复页面${i + 1}`, nodes: [{ id: 'body', type: 'markdown', markdown: `恢复正文${i + 1}` }] },
|
||||
}))
|
||||
const metadata: SurfaceMeta[] = snapshots.map(s => ({
|
||||
surfaceId: s.identity.surfaceId, title: s.spec.title, pluginId: s.identity.pluginId,
|
||||
revision: s.revision, conversationId: conversation.id, updatedAt: conversation.updatedAt,
|
||||
}))
|
||||
|
||||
test('saved-page reload keeps the app and restores both full snapshots', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', e => errors.push(e.message))
|
||||
await page.addInitScript(({ id }) => {
|
||||
localStorage.setItem('as.conv', id)
|
||||
localStorage.setItem('as.ui', JSON.stringify({ rightTab: 'files', filesView: 'preview', rightCollapsed: false }))
|
||||
}, { id: conversation.id })
|
||||
|
||||
// Replay the two distinct server contracts through the real app. Intercept all
|
||||
// API requests so this regression can run against a live bundle without writes.
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only regression' })
|
||||
if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' })
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { status: 'ok' }
|
||||
else if (path === '/api/me') body = { owner: 'fixture', name: 'fixture', auth: true, authVia: 'session' }
|
||||
else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '恢复项目', createdAt: '', updatedAt: '' }]
|
||||
else if (path === '/api/conversations' || path === `/api/projects/${conversation.projectId}/conversations`) body = [conversation]
|
||||
else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }
|
||||
else if (path === `/api/conversations/${conversation.id}/surfaces` || path === `/api/projects/${conversation.projectId}/surfaces`) body = metadata
|
||||
else if (path.startsWith('/api/surfaces/')) {
|
||||
body = snapshots.find(s => s.identity.surfaceId === path.split('/').at(-1))
|
||||
} else if (path.startsWith('/api/settings')) body = { schema: [], defaults: {}, user: {}, project: {} }
|
||||
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1')
|
||||
await expect(page.locator('.preview-pane')).toContainText('恢复正文1')
|
||||
await page.reload()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1')
|
||||
await page.getByRole('button', { name: '返回文件列表' }).click()
|
||||
await expect(page.locator('[data-surface-row]')).toHaveCount(2)
|
||||
await page.locator('[data-surface-row="page-2"]').click()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面2')
|
||||
await expect(page.locator('.preview-pane')).toContainText('恢复正文2')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
// U7 浏览器点验(补录):R1–R8 用户可见流程——在实际 8788 入口(React 内嵌组合
|
||||
// 产物)以已激活账号登录,点验体系工具入口、审计工作区、项目设置三页签(成员/
|
||||
// 文档职责·整文审签/项目权限矩阵)与系统管理 RDM 角色分区。
|
||||
// 驱动:PLAYWRIGHT_BASE_URL=http://127.0.0.1:8788 + 已激活账号(无 must-change 门)。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'u7-creator'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'U7-Final-creator!7'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (drives against the live 8788 entry)')
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
|
||||
test('U7 浏览器点验:登录后工作台与审计入口(R1)', async ({ page }) => {
|
||||
await login(page)
|
||||
// 工作台主体出现(侧栏/会话区之一可见即视为进入)。
|
||||
await expect(page.locator('body')).toContainText(/会话|代理|项目|工作台/, { timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('U7 浏览器点验:追溯审计工作区可进入(R1/R7)', async ({ page }) => {
|
||||
await login(page)
|
||||
const audit = page.locator('.tsb-tool', { hasText: '追溯审计' })
|
||||
await expect(audit).toBeVisible({ timeout: 15_000 })
|
||||
await audit.click()
|
||||
await expect(page.locator('body')).toContainText(/概览|覆盖|追溯/, { timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('U7 浏览器点验:项目设置三页签(R2/R4/R5)', async ({ page }) => {
|
||||
await login(page)
|
||||
// 侧栏「项目设置」按钮(currentProject 就绪后出现;最多等 15s)。
|
||||
const btn = page.locator('.tsb-tool', { hasText: '项目设置' })
|
||||
await expect(btn).toBeVisible({ timeout: 15_000 })
|
||||
await btn.click()
|
||||
await page.waitForTimeout(1000)
|
||||
const body = await page.locator('body').innerText().catch(() => '')
|
||||
const tabs = ['成员', '文档职责', '项目权限'].filter(t => body.includes(t))
|
||||
test.info().annotations.push({ type: 'note', description: `project-settings tabs visible: ${tabs.join(',')}` })
|
||||
expect(tabs.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('U7 浏览器点验:系统管理 RDM 角色分区(R3)', async ({ page }) => {
|
||||
await login(page)
|
||||
await page.waitForTimeout(1200)
|
||||
const admin = page.locator('button[title*="系统管理"], button:has-text("系统管理")').first()
|
||||
const visible = await admin.isVisible().catch(() => false)
|
||||
test.info().annotations.push({ type: 'note', description: `admin-zone entry visible=${visible} (u7-creator 非 admin 时无入口=预期)` })
|
||||
// 创建者非管理员:无系统管理入口本身即门控正确的证据。
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
const OWNER = process.env.E2E_OWNER || 'u7-creator'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'U7-Final-creator!7'
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL)
|
||||
test('u7 工作台+审计截图', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
await page.goto('/')
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await page.waitForTimeout(1200)
|
||||
await page.screenshot({ path: 'test-results/u7-01-workbench.png' })
|
||||
const audit = page.locator('.tsb-tool', { hasText: '追溯审计' })
|
||||
await expect(audit).toBeVisible({ timeout: 15_000 })
|
||||
await audit.click()
|
||||
await page.waitForTimeout(1500)
|
||||
await page.screenshot({ path: 'test-results/u7-02-audit.png' })
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
const OWNER = process.env.E2E_OWNER || 'u7-creator'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'U7-Final-creator!7'
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL)
|
||||
test('u7 项目设置截图', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
await page.goto('/')
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const settings = page.locator('.tsb-tool', { hasText: '项目设置' })
|
||||
await expect(settings).toBeVisible({ timeout: 20_000 })
|
||||
await settings.click()
|
||||
await page.waitForTimeout(1500)
|
||||
await page.screenshot({ path: 'test-results/u7-03-project-settings.png' })
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test, expect, type Page, type APIRequestContext, type Response } from '@playwright/test'
|
||||
import type { Artifact, Message, Part, Run, UploadInfo } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.WORK_COMPANION_LIVE_BROWSER, 'Requires the task-owned backend, SQLite data and MockRunner')
|
||||
|
||||
async function openRealConversation(page: Page, request: APIRequestContext) {
|
||||
const projectResponse = await request.post('/api/projects', { data: { name: '工作台验收 ' + Date.now() } })
|
||||
expect(projectResponse.ok()).toBe(true)
|
||||
const project = await projectResponse.json()
|
||||
const conversationResponse = await request.post(`/api/projects/${project.id}/conversations`, { data: { title: '真实接口验收' } })
|
||||
expect(conversationResponse.ok()).toBe(true)
|
||||
const conversation = await conversationResponse.json()
|
||||
await page.addInitScript(({ projectId, conversationId }) => {
|
||||
localStorage.setItem('as.conv', conversationId)
|
||||
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', rightCollapsed: true, lastProjectId: projectId }))
|
||||
}, { projectId: project.id, conversationId: conversation.id })
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.chat-title')).toHaveText('真实接口验收')
|
||||
return { project, conversation }
|
||||
}
|
||||
|
||||
test('real upload/send/reload preserves three actual attachments', async ({ page, request }, testInfo) => {
|
||||
const { project, conversation } = await openRealConversation(page, request)
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
const uploadResponses: Response[] = []
|
||||
const uploadPath = `/api/projects/${project.id}/uploads`
|
||||
const observeUpload = (response: Response) => {
|
||||
if (response.request().method() === 'POST' && new URL(response.url()).pathname === uploadPath) uploadResponses.push(response)
|
||||
}
|
||||
page.on('response', observeUpload)
|
||||
await page.locator('input[type=file]').first().setInputFiles([
|
||||
{ name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议决定:先明确交付日期') },
|
||||
{ name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求:整理材料并生成摘要') },
|
||||
{ name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审意见:补充验收标准') },
|
||||
])
|
||||
await expect(page.locator('.attach-chip')).toHaveCount(3)
|
||||
page.off('response', observeUpload)
|
||||
expect(uploadResponses).toHaveLength(3)
|
||||
const uploads: UploadInfo[] = await Promise.all(uploadResponses.map(async response => {
|
||||
expect(response.ok()).toBe(true)
|
||||
return response.json()
|
||||
}))
|
||||
expect(uploads.map(upload => upload.filename)).toEqual(['会议纪要.txt', '需求说明.txt', '评审意见.txt'])
|
||||
const attachmentIds = uploads.map(upload => upload.artifactId)
|
||||
for (const id of attachmentIds) expect(id).toEqual(expect.stringMatching(/\S+/))
|
||||
expect(new Set(attachmentIds).size).toBe(3)
|
||||
const artifactsResponse = await request.get(`/api/projects/${project.id}/artifacts`)
|
||||
expect(artifactsResponse.ok()).toBe(true)
|
||||
const artifacts: Artifact[] = await artifactsResponse.json()
|
||||
expect(artifacts).toHaveLength(3)
|
||||
expect(artifacts.map(artifact => artifact.id).sort()).toEqual([...attachmentIds].sort())
|
||||
const expectedParts: Part[] = uploads.map(upload => {
|
||||
const artifact = artifacts.find(artifact => artifact.id === upload.artifactId)!
|
||||
expect(artifact.name).toBe(upload.filename)
|
||||
return { type: 'artifact_ref', artifact: { artifactId: artifact.id, name: artifact.name, kind: artifact.kind } }
|
||||
})
|
||||
const messagePath = `/api/conversations/${conversation.id}/messages`
|
||||
const [sendRequest, sendResponse] = await Promise.all([
|
||||
page.waitForRequest(request => request.method() === 'POST' && new URL(request.url()).pathname === messagePath),
|
||||
page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === messagePath),
|
||||
page.getByRole('button', { name: '发送', exact: true }).click(),
|
||||
])
|
||||
const sent = sendRequest.postDataJSON()
|
||||
expect(sent.text).toBe('')
|
||||
expect(sent.attachmentIds).toEqual(attachmentIds)
|
||||
expect(sendResponse.ok()).toBe(true)
|
||||
const accepted: { message: Message; run: Run } = await sendResponse.json()
|
||||
expect(accepted.message.role).toBe('user')
|
||||
expect(accepted.message.conversationId).toBe(conversation.id)
|
||||
expect(accepted.message.parts).toEqual(expectedParts)
|
||||
expect(accepted.run.kind).toBe('conversation')
|
||||
await expect.poll(async () => {
|
||||
const response = await request.get(`/api/conversations/${conversation.id}`)
|
||||
expect(response.ok()).toBe(true)
|
||||
const current: { runs: Run[] } = await response.json()
|
||||
return current.runs.some(run => run.id === accepted.run.id && run.kind === 'conversation' && run.status === 'completed')
|
||||
}).toBe(true)
|
||||
async function persistedMessage() {
|
||||
const response = await request.get(`/api/conversations/${conversation.id}`)
|
||||
expect(response.ok()).toBe(true)
|
||||
const current: { messages: Message[] } = await response.json()
|
||||
const users = current.messages.filter(message => message.role === 'user')
|
||||
expect(users).toHaveLength(1)
|
||||
expect(users[0].id).toBe(accepted.message.id)
|
||||
expect(users[0].conversationId).toBe(conversation.id)
|
||||
expect(users[0].parts).toEqual(expectedParts)
|
||||
return users[0]
|
||||
}
|
||||
const beforeReload = await persistedMessage()
|
||||
await page.reload()
|
||||
await expect(page.locator('.chat-title')).toHaveText('真实接口验收')
|
||||
const afterReload = await persistedMessage()
|
||||
expect(afterReload).toEqual(beforeReload)
|
||||
const association = { projectId: project.id, conversationId: conversation.id, runId: accepted.run.id,
|
||||
uploads: uploads.map(({ artifactId, filename }) => ({ artifactId, filename })),
|
||||
sent: { text: sent.text, attachmentIds: sent.attachmentIds }, beforeReload, afterReload }
|
||||
await testInfo.attach('real-material-associations', { body: Buffer.from(JSON.stringify(association, null, 2)), contentType: 'application/json' })
|
||||
console.log('real-material-associations', JSON.stringify(association))
|
||||
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
||||
await expect(page.locator('[data-artifact-row]')).toHaveCount(3)
|
||||
await page.locator('[data-artifact-row]').first().click()
|
||||
await expect(page.locator('.preview-pane-name')).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('real background card and HITL decision survive query reconciliation', async ({ page, request }) => {
|
||||
const { conversation } = await openRealConversation(page, request)
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true })
|
||||
// Backend's deterministic runner exercises the real run/card/event lifecycle;
|
||||
// this is not a live-model quality or stage-findings test.
|
||||
await input.fill('#background')
|
||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||
await expect.poll(async () => {
|
||||
const out = await request.get(`/api/conversations/${conversation.id}/cards`).then(r => r.json())
|
||||
return out.cards?.some((card: { kind: string; status: string }) => card.kind === 'progress' && ['done', 'failed'].includes(card.status))
|
||||
}, { timeout: 15000 }).toBe(true)
|
||||
await expect(page.locator('[data-conversation-work]')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '发送', exact: true })).toBeVisible()
|
||||
await input.fill('#hitl')
|
||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||
await expect(page.locator('.hitl-card')).toBeVisible()
|
||||
await page.locator('.hitl-card').getByRole('button', { name: /Markdown 文档/ }).click()
|
||||
await expect(page.locator('.hitl-card')).toHaveCount(0)
|
||||
await expect.poll(async () => {
|
||||
const current = await request.get(`/api/conversations/${conversation.id}`).then(r => r.json())
|
||||
return current.activeHitl.length
|
||||
}).toBe(0)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import type { Artifact, Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
|
||||
|
||||
const stamp = '2026-10-12T10:00:00Z'
|
||||
const conversation: Conversation = {
|
||||
id: 'navigation-conversation', projectId: 'navigation-project', title: '继续阅读材料',
|
||||
createdAt: stamp, updatedAt: stamp,
|
||||
}
|
||||
const artifact: Artifact = {
|
||||
id: 'navigation-document', projectId: conversation.projectId, name: '材料摘要.md', kind: 'markdown',
|
||||
size: 64, createdAt: stamp, updatedAt: stamp,
|
||||
}
|
||||
const snapshot: SurfaceSnapshot = {
|
||||
identity: { surfaceId: 'saved-navigation-page', owner: 'reader', pluginId: 'generative-ui', conversationId: conversation.id },
|
||||
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1,
|
||||
spec: { title: '已保存的材料页面', nodes: [
|
||||
{ id: 'summary', type: 'markdown', markdown: '已保存的材料范围' },
|
||||
{ id: 'source', type: 'doc-ref', docId: artifact.id },
|
||||
] },
|
||||
}
|
||||
const metadata: SurfaceMeta = {
|
||||
surfaceId: snapshot.identity.surfaceId, title: snapshot.spec.title, pluginId: snapshot.identity.pluginId,
|
||||
revision: snapshot.revision, conversationId: conversation.id, updatedAt: stamp,
|
||||
}
|
||||
|
||||
// Delay the external list contract, while running the real store, App and panes.
|
||||
// The saved selection is restored by openConversation rather than test-only state injection.
|
||||
async function fixture(page: Page) {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
let release!: () => void
|
||||
const hydration = new Promise<void>(resolve => { release = resolve })
|
||||
await page.addInitScript(({ conversationId }) => {
|
||||
localStorage.setItem('as.conv', conversationId)
|
||||
localStorage.setItem('as.ui', JSON.stringify({
|
||||
theme: 'light', rightTab: 'files', filesView: 'preview', rightCollapsed: true,
|
||||
}))
|
||||
}, { conversationId: conversation.id })
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only navigation fixture' })
|
||||
if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' })
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/me') body = { owner: 'reader', name: '材料阅读者', auth: true, authVia: 'session', isAdmin: false }
|
||||
else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '材料项目', createdAt: stamp, updatedAt: stamp }]
|
||||
else if (path === '/api/conversations') body = [conversation]
|
||||
else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }
|
||||
else if (path === `/api/conversations/${conversation.id}/surfaces`) { await hydration; body = [metadata] }
|
||||
else if (path === `/api/surfaces/${snapshot.identity.surfaceId}`) body = snapshot
|
||||
else if (path === `/api/conversations/${conversation.id}/cards`) body = { cards: [] }
|
||||
else if (path === `/api/projects/${conversation.projectId}/artifacts`) body = [artifact]
|
||||
else if (path === `/api/artifacts/${artifact.id}/content`) {
|
||||
return route.fulfill({ contentType: 'text/markdown', body: '# 材料摘要\n\n原始材料的阅读范围' })
|
||||
} else if (path === '/api/settings') body = { user: {}, project: {}, effective: {}, schema: [] }
|
||||
else if (path === '/api/workspaces') body = [{ id: 'navigation-materials', title: '材料工作区',
|
||||
body: [{ type: 'doc-ref', docId: artifact.id }] }]
|
||||
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
const listRequest = page.waitForRequest(request => new URL(request.url()).pathname === `/api/conversations/${conversation.id}/surfaces`)
|
||||
await page.goto('/')
|
||||
await listRequest
|
||||
await expect(page.locator('.chat-title')).toHaveText(conversation.title)
|
||||
return { release, errors }
|
||||
}
|
||||
|
||||
for (const width of [736, 390]) {
|
||||
test(`keeps context and focus when saved Surface hydration arrives at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const state = await fixture(page)
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
const search = page.getByRole('textbox', { name: '搜索本项目会话', exact: true })
|
||||
await search.fill('材料')
|
||||
await expect(search).toBeFocused()
|
||||
state.release()
|
||||
// The real hidden preview proves both restored tab selection and snapshot hydration finished.
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'context')
|
||||
await expect(page.locator('.left-nav')).toBeVisible()
|
||||
await expect(page.locator('.main')).toBeHidden()
|
||||
await expect(page.locator('.right-panel')).toBeHidden()
|
||||
await expect(search).toHaveValue('材料')
|
||||
await expect(search).toBeFocused()
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test(`preserves explicit work, Surface, document and return navigation at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const state = await fixture(page)
|
||||
state.release()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'work')
|
||||
await expect(page.locator('.right-panel')).toBeVisible()
|
||||
await expect(page.locator('.left-nav')).toBeHidden()
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files')
|
||||
await page.getByRole('button', { name: '返回文件列表', exact: true }).click()
|
||||
await page.locator(`[data-surface-row="${snapshot.identity.surfaceId}"]`).click()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||
// Surface doc-ref uses the existing document bridge, which opens the same preview.
|
||||
await page.locator('.preview-pane').getByRole('button', { name: artifact.id, exact: true }).click()
|
||||
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||
await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围')
|
||||
await page.getByRole('button', { name: '展开预览', exact: true }).click()
|
||||
await expect(page.locator('.right-panel')).toHaveClass(/expanded/)
|
||||
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation')
|
||||
await expect(page.locator('.main')).toBeVisible()
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect(page.locator('.main')).toBeVisible()
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation')
|
||||
await expect(page.locator('.left-nav')).toBeHidden()
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test(`opens a workspace document while context overlays the selected files pane at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const state = await fixture(page)
|
||||
state.release()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await page.locator('.left-nav').getByRole('button', { name: /材料工作区/ }).click()
|
||||
const workspace = page.getByRole('dialog', { name: '材料工作区', exact: true })
|
||||
await workspace.getByRole('button', { name: artifact.name, exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files')
|
||||
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||
await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围')
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import type { ActivityCard, Artifact, Conversation, StreamEvent } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
|
||||
|
||||
const stamp = '2026-10-12T10:00:00Z'
|
||||
const project = { id: 'p1', name: '产品升级计划', createdAt: stamp, updatedAt: stamp }
|
||||
const conversations: Conversation[] = [
|
||||
{ id: 'c1', projectId: 'p1', title: '整理项目材料', createdAt: stamp, updatedAt: stamp },
|
||||
{ id: 'c2', projectId: 'p1', title: '讨论本周工作', createdAt: stamp, updatedAt: stamp },
|
||||
]
|
||||
const artifact: Artifact = { id: 'doc1', projectId: 'p1', name: '材料整理摘要.md', kind: 'markdown', size: 64, createdAt: stamp, updatedAt: stamp }
|
||||
function work(id: string, status: ActivityCard['status'], over: Partial<ActivityCard> = {}): ActivityCard {
|
||||
return { id, status, projectId: 'p1', conversationId: 'c1', kind: 'progress', title: '整理项目材料', createdAt: stamp, updatedAt: stamp, ...over }
|
||||
}
|
||||
const progress = work('work1', 'active', { body: [{ type: 'kv', kv: [
|
||||
['状态', '进行中'], ['正在做', '核对会议结论'], ['当前发现', '两份材料的时间安排需要核对'], ['剩余', '评审意见仍待核查'], ['材料键', 'private-material-key'],
|
||||
] }] })
|
||||
const confirmation = work('confirm1', 'action_required', { kind: 'confirm', title: '生成项目草稿',
|
||||
body: [{ type: 'markdown', markdown: '依据三份材料新增一份草稿,保留原件,不对外发送' }],
|
||||
actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }],
|
||||
})
|
||||
|
||||
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14) {
|
||||
let cards = [...initialCards]
|
||||
let actionCount = 0
|
||||
let releaseAction: (() => void) | undefined
|
||||
let holdAction = false
|
||||
let sent: { text: string; attachmentIds: string[] } | undefined
|
||||
let uploadNumber = 0
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.addInitScript(({ fontSize }) => {
|
||||
localStorage.setItem('as.conv', 'c1')
|
||||
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
|
||||
const streams: { url: string; emit: (event: unknown) => void }[] = []
|
||||
class TestEventSource {
|
||||
onopen: (() => void) | null = null
|
||||
onerror = null
|
||||
onmessage: ((event: { data: string }) => void) | null = null
|
||||
listeners: Record<string, ((event: { data: string }) => void)[]> = {}
|
||||
constructor(public url: string) { streams.push(this); queueMicrotask(() => this.onopen?.()) }
|
||||
addEventListener(type: string, callback: (event: { data: string }) => void) { (this.listeners[type] ??= []).push(callback) }
|
||||
emit(event: unknown) {
|
||||
const value = event as { type: string }
|
||||
for (const callback of this.listeners[value.type] ?? []) callback({ data: JSON.stringify(event) })
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
Object.assign(window, { EventSource: TestEventSource, __workStreams: streams })
|
||||
}, { fontSize })
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const path = url.pathname
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/me') body = { owner: 'reader', name: '林老师', auth: false, isAdmin: false }
|
||||
else if (path === '/api/projects') body = [project]
|
||||
else if (path === '/api/conversations') body = conversations
|
||||
else if (path === '/api/projects/p1/conversations' && request.method() === 'POST') body = conversations[1]
|
||||
else if (/^\/api\/conversations\/c[12]$/.test(path)) {
|
||||
const id = path.split('/').at(-1)!
|
||||
body = { conversation: conversations.find(c => c.id === id), messages: id === 'c1' ? [
|
||||
{ id: 'm1', conversationId: id, role: 'user', parts: [{ type: 'text', text: '请整理这三份材料,看看需要优先处理什么' }], createdAt: stamp },
|
||||
{ id: 'm2', conversationId: id, role: 'assistant', parts: [{ type: 'text', text: '先核对时间安排,再补齐验收条件。其他内容仍在整理。' }], createdAt: stamp },
|
||||
] : [], runs: [], activeHitl: [], lastSeq: 10 }
|
||||
} else if (path === '/api/conversations/c1/cards') body = { cards }
|
||||
else if (path === '/api/conversations/c2/cards') body = { cards: [] }
|
||||
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||
else if (path === '/api/projects/p1/artifacts') body = [artifact]
|
||||
else if (path === '/api/artifacts/doc1/content') {
|
||||
await route.fulfill({ status: 200, contentType: 'text/markdown', body: '# 材料整理摘要\n\n已整理两份材料,评审意见仍待核对' }); return
|
||||
} else if (path === '/api/settings') body = { user: { runtime: { mock: false } }, project: {}, effective: {}, schema: [] }
|
||||
else if (path === '/api/profiles') body = [{ id: 'default', name: '工作助手', model: {}, tools: { allow: [] }, context: { maxHistoryMessages: 20 }, hitl: { askOnAmbiguity: true }, systemPrompt: '' }]
|
||||
else if (path === '/api/projects/p1/uploads') body = { artifactId: 'upload' + ++uploadNumber, filename: '材料.txt', size: 16 }
|
||||
else if (/\/messages$/.test(path) && request.method() === 'POST') {
|
||||
sent = request.postDataJSON()
|
||||
body = { message: { id: 'sent', conversationId: 'c1', role: 'user', parts: [{ type: 'text', text: sent?.text ?? '' }], createdAt: stamp }, run: { id: 'run1', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'running', createdAt: stamp } }
|
||||
} else if (path === '/api/cards/confirm1/actions/confirm') {
|
||||
actionCount++
|
||||
if (holdAction) await new Promise<void>(resolve => { releaseAction = resolve })
|
||||
cards = cards.map(card => card.id === 'confirm1' ? { ...card, status: 'resolved' } : card)
|
||||
body = { card: cards.find(card => card.id === 'confirm1') }
|
||||
}
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.chat-title')).toHaveText('整理项目材料')
|
||||
if (initialCards.length) await expect(page.locator('[data-conversation-work]')).toBeVisible()
|
||||
return { errors, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
|
||||
async emit(type: StreamEvent['type'], payload: unknown, seq: number) {
|
||||
await page.evaluate(({ type, payload, seq }) => {
|
||||
const win = window as typeof window & { __workStreams: { url: string; emit: (e: unknown) => void }[] }
|
||||
const stream = win.__workStreams.findLast(s => s.url.includes('conversationId=c1'))
|
||||
if (!stream) throw Error('conversation stream unavailable')
|
||||
stream.emit({ type, payload, seq, conversationId: 'c1', ts: '2026-10-12T10:00:00Z', v: 1 })
|
||||
}, { type, payload, seq })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('keeps real summary and details accessible with right pane closed', async ({ page }) => {
|
||||
const state = await fixture(page)
|
||||
await expect(page.locator('.right-panel')).toBeHidden()
|
||||
await expect(page.getByText('核对会议结论', { exact: true }).first()).toBeVisible()
|
||||
await expect(page.getByText('private-material-key')).toHaveCount(0)
|
||||
await page.locator('[data-conversation-work]').getByRole('button', { name: '查看依据与范围', exact: true }).click()
|
||||
await page.locator('[data-conversation-work]').getByRole('button', { name: '查看执行记录', exact: true }).click()
|
||||
await expect(page.locator('[data-conversation-work]').getByText('private-material-key')).toBeVisible()
|
||||
await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12)
|
||||
await state.emit('card.status', { cardId: 'work1', status: 'active' }, 11)
|
||||
await expect(page.locator('[data-conversation-work] .acard-status')).toHaveText('已完成')
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps one expanded ordinary summary when the desktop work pane opens', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 })
|
||||
const state = await fixture(page)
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
await expect(page.locator('.right-panel .acard')).toHaveCount(1)
|
||||
await expect(page.locator('[data-conversation-work] .acard')).toHaveCount(0)
|
||||
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: /整理项目材料 · 查看摘要/ })).toBeVisible()
|
||||
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('preserves the selected material, physical scroll and focus when background work finishes', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 })
|
||||
const tabbed = work('work1', 'active', { body: [...progress.body!, {
|
||||
type: 'tabs', id: 'materials', tabs: { tabs: [
|
||||
{ id: 'first', label: '第一份材料', nodes: [{ type: 'markdown', markdown: '第一份材料的范围' }] },
|
||||
{ id: 'second', label: '第二份材料', nodes: [{ type: 'markdown', markdown: Array.from({ length: 70 }, (_, i) => `待核对事项 ${i + 1}`).join('\n\n') }] },
|
||||
] },
|
||||
}] })
|
||||
const state = await fixture(page, [tabbed, work('peer', 'active', { body: [] })])
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
const card = page.locator('.right-panel [data-card-id="work1"]')
|
||||
await card.getByRole('button', { name: '查看依据与范围', exact: true }).click()
|
||||
const tab = card.getByRole('tab', { name: '第二份材料', exact: true })
|
||||
await tab.click()
|
||||
await tab.focus()
|
||||
const details = card.locator('.acard-detail-body')
|
||||
await expect.poll(() => details.evaluate(element => element.clientHeight)).toBe(420)
|
||||
const physical = await details.evaluate(element => {
|
||||
Object.assign(window, { __workReading: element })
|
||||
element.scrollTop = 137
|
||||
return { height: element.clientHeight, scrollHeight: element.scrollHeight, top: element.scrollTop }
|
||||
})
|
||||
expect(physical.scrollHeight).toBeGreaterThan(physical.height)
|
||||
expect(physical.top).toBe(137)
|
||||
await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12)
|
||||
await expect(card.locator('.acard-status')).toHaveText('已完成')
|
||||
await expect(tab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(await details.evaluate(element => ({
|
||||
retained: (window as typeof window & { __workReading: Element }).__workReading === element,
|
||||
top: element.scrollTop,
|
||||
}))).toEqual({ retained: true, top: 137 })
|
||||
expect(await tab.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('shares submitting feedback between conversation and work confirmations', async ({ page }) => {
|
||||
const state = await fixture(page, [confirmation])
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
state.hold()
|
||||
await page.locator('[data-conversation-work]').getByRole('button', { name: '确认生成', exact: true }).click()
|
||||
await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
|
||||
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: '暂不生成', exact: true })).toBeDisabled()
|
||||
expect(state.actionCount).toBe(1)
|
||||
state.release()
|
||||
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('opens the one file preview and preserves reading during background updates', async ({ page }) => {
|
||||
const result = work('result1', 'done', { kind: 'result', title: '材料摘要已准备好', body: [{ type: 'doc-ref', docId: 'doc1' }] })
|
||||
const state = await fixture(page, [result])
|
||||
await page.locator('[data-conversation-work]').getByText(artifact.name, { exact: true }).click()
|
||||
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||
await expect(page.locator('.preview-pane-body')).toContainText('评审意见仍待核对')
|
||||
await state.emit('surface.updated', { identity: { surfaceId: 'new-page', owner: 'reader', pluginId: 'generative-ui', conversationId: 'c1' }, revision: 1, activationEpoch: 0, compat: { schemaVersion: 1, catalogVersion: '1' }, spec: { title: '新任务页面', nodes: [] } }, 11)
|
||||
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||
await page.getByRole('button', { name: '展开预览', exact: true }).click()
|
||||
await expect(page.locator('.main')).toBeHidden()
|
||||
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||
await expect(page.locator('.main')).toBeVisible()
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('sends three attachments without text and states foreground queueing truthfully', async ({ page }) => {
|
||||
const state = await fixture(page, [])
|
||||
await page.locator('input[type=file]').first().setInputFiles([
|
||||
{ name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议内容') },
|
||||
{ name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求内容') },
|
||||
{ name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审内容') },
|
||||
])
|
||||
await expect(page.locator('.attach-chip')).toHaveCount(3)
|
||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||
await expect.poll(() => state.sent?.attachmentIds.length).toBe(3)
|
||||
expect(state.sent?.text).toBe('')
|
||||
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('请优先核对时间安排')
|
||||
await expect(page.getByRole('button', { name: '加入队列', exact: true })).toBeEnabled()
|
||||
await page.getByRole('button', { name: '加入队列', exact: true }).click()
|
||||
await expect(page.getByText('请优先核对时间安排', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.input-status')).toContainText('排队')
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
for (const width of [1440, 1024, 736, 390, 320]) {
|
||||
test(`keeps navigation and key actions usable at ${width}px with large UI type`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 960 })
|
||||
const state = await fixture(page, [progress], 18)
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
await expect(page.locator('.right-panel')).toBeVisible()
|
||||
await expect(page.locator('.right-panel').getByText('评审意见仍待核查')).toBeVisible()
|
||||
if (width < 1024) {
|
||||
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||
await expect(page.locator('.main')).toBeVisible()
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '新建对话', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||
}
|
||||
const layout = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }))
|
||||
expect(layout.scroll).toBeLessThanOrEqual(layout.width)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
}
|
||||
Generated
+46
@@ -19,6 +19,7 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/node": "^22.20.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
@@ -1212,6 +1213,22 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
|
||||
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.64.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2749,6 +2766,35 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
|
||||
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
|
||||
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
|
||||
+3
-1
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"docx-preview": "^0.4.1",
|
||||
@@ -21,6 +22,7 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/node": "^22.20.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// U4 运行验收(Playwright):真实浏览器走通追溯审计工作区四入口。
|
||||
// 由 Dev 仓 scripts/e2e.sh 驱动(起 RDM 二进制 + preview 静态前端 + 种子数据);
|
||||
// PLAYWRIGHT_BASE_URL 未设置时整组跳过(CI 单测档不拉浏览器)。
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
const base = process.env.PLAYWRIGHT_BASE_URL || ''
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'e2e',
|
||||
testMatch: /.*(spec|playwright)\.ts$/,
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: base,
|
||||
locale: 'zh-CN',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
})
|
||||
+112
-56
@@ -16,6 +16,8 @@ import { SettingsModal } from './components/SettingsModal'
|
||||
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
|
||||
import { TopBar } from './components/TopBar'
|
||||
import { workspaceEntries } from './components/Workspaces'
|
||||
import { selectWorkbenchLayout } from './workbenchLayout'
|
||||
import './companion-shell.css'
|
||||
|
||||
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
|
||||
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
|
||||
@@ -24,38 +26,79 @@ const RIGHT_MIN = 280, RIGHT_MAX = 560
|
||||
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => workspaceShortcuts(e)
|
||||
window.addEventListener('keydown', h, true)
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}, [])
|
||||
|
||||
const ui = useStore(s => s.ui)
|
||||
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
||||
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
|
||||
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
||||
const previewOpenIntent = useStore(s => s.previewOpenIntent)
|
||||
const rightTab = useStore(s => s.ui.rightTab)
|
||||
const toast = useStore(s => s.toast)
|
||||
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
||||
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
|
||||
const filesWide = rightTab === 'files' && previewOpen
|
||||
const rightPanelWidth = filesWide ? ui.previewWidth : ui.rightWidth
|
||||
const previewExpanded = filesWide && ui.previewExpanded
|
||||
const filesWide = rightTab === 'files' && ui.filesView === 'preview' && previewOpen
|
||||
const [viewportWidth, setViewportWidth] = useState(() => typeof window === 'undefined' ? 1440 : window.innerWidth)
|
||||
const [contextOpen, setContextOpen] = useState(false)
|
||||
const layout = selectWorkbenchLayout({ ...ui, width: viewportWidth, contextOpen, previewOpen })
|
||||
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
||||
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
|
||||
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
|
||||
const [mustChangeOwner, setMustChangeOwner] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setViewportWidth(window.innerWidth)
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [])
|
||||
|
||||
// Explicit openings leave mobile context even if pane values match; metadata restores keep the reading choice.
|
||||
useEffect(() => { setContextOpen(false) }, [ui.rightCollapsed, rightTab, ui.filesView, ui.previewExpanded, previewOpenIntent])
|
||||
useEffect(() => { if (!layout.compact) setContextOpen(false) }, [layout.compact])
|
||||
|
||||
const returnToConversation = () => {
|
||||
setContextOpen(false)
|
||||
store.setUI(layout.compact ? { rightCollapsed: true, previewExpanded: false } : { previewExpanded: false })
|
||||
}
|
||||
const openContext = () => {
|
||||
if (!layout.compact) store.toggleLeft()
|
||||
else if (contextOpen) returnToConversation()
|
||||
else setContextOpen(true)
|
||||
}
|
||||
const openWork = () => {
|
||||
setContextOpen(false)
|
||||
store.openInbox()
|
||||
store.setUI({ inboxFilter: 'all', previewExpanded: false })
|
||||
}
|
||||
const openPending = () => { setContextOpen(false); store.focusPending() }
|
||||
const openFiles = () => { setContextOpen(false); store.openFiles() }
|
||||
const conversationOpened = () => { if (layout.compact) returnToConversation() }
|
||||
|
||||
// Keep conversation/context shortcuts available even when their pane is hidden.
|
||||
useEffect(() => {
|
||||
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).catch(() => undefined); return }
|
||||
if (key === 'b') { e.preventDefault(); openContext(); return }
|
||||
}
|
||||
workspaceShortcuts(e)
|
||||
}
|
||||
window.addEventListener('keydown', h, true)
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}, [authState, layout.compact, contextOpen])
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
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
|
||||
}
|
||||
@@ -68,9 +111,17 @@ export function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (authState !== 'ok') return
|
||||
store.refreshProjects()
|
||||
store.refreshProfiles()
|
||||
store.refreshPlugins()
|
||||
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
||||
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
||||
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
||||
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)
|
||||
@@ -78,10 +129,10 @@ export function App() {
|
||||
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
|
||||
const wide = side === 'right' && rightTab === 'files' && previewOpen
|
||||
const wide = side === 'right' && filesWide
|
||||
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
||||
drag.current = {
|
||||
side, startX: e.clientX, startW: ui[key], overshoot: false,
|
||||
side, startX: e.clientX, startW: side === 'left' ? layout.leftWidth : layout.rightWidth, overshoot: false,
|
||||
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
|
||||
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
|
||||
key,
|
||||
@@ -111,15 +162,29 @@ export function App() {
|
||||
window.addEventListener('mouseup', up)
|
||||
}
|
||||
|
||||
const onDividerKey = (side: 'left' | 'right') => (e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
e.preventDefault()
|
||||
const wide = side === 'right' && filesWide
|
||||
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
||||
const min = side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN
|
||||
const max = side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX
|
||||
const current = side === 'left' ? layout.leftWidth : layout.rightWidth
|
||||
const delta = (e.key === 'ArrowRight' ? 16 : -16) * (side === 'left' ? 1 : -1)
|
||||
store.setUI({ [key]: Math.min(max, Math.max(min, current + delta)) })
|
||||
}
|
||||
|
||||
if (authState === 'login') {
|
||||
return <LoginPage
|
||||
onAuthed={() => setAuthState('ok')}
|
||||
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') {
|
||||
@@ -128,22 +193,20 @@ export function App() {
|
||||
|
||||
const tools = workspaceEntries()
|
||||
return (
|
||||
<div className="app">
|
||||
<TopBar />
|
||||
<div className="app-body">
|
||||
{!ui.leftCollapsed ? (
|
||||
<>
|
||||
<div className="left-nav" style={{ width: ui.leftWidth }}>
|
||||
<TaskSidebar />
|
||||
<div className={`app companion-app${layout.compact ? ' compact' : ''}`}>
|
||||
<TopBar compact={layout.compact} activePane={layout.pane} showReturn={layout.previewExpanded}
|
||||
onContext={openContext} onConversation={returnToConversation} onWork={openWork} onFiles={openFiles} />
|
||||
<div className="app-body" data-active-pane={layout.pane}>
|
||||
<aside className={`left-nav${layout.contextCollapsed ? ' collapsed' : ''}`} aria-label="上下文"
|
||||
hidden={!layout.showContext} style={{ width: layout.leftWidth }}>
|
||||
<div className="shell-context-content" hidden={layout.contextCollapsed}>
|
||||
<TaskSidebar onCollapse={layout.compact ? returnToConversation : store.toggleLeft}
|
||||
onConversationOpen={conversationOpened} onFilesOpen={openFiles} onWorkOpen={openPending} />
|
||||
</div>
|
||||
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
|
||||
</>
|
||||
) : (
|
||||
<div className="left-nav collapsed" style={{ width: 52 }}>
|
||||
<div className="icon-only">
|
||||
<div className="icon-only" hidden={!layout.contextCollapsed}>
|
||||
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||||
<button title="新建任务(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
|
||||
<button title="收件箱" onClick={() => store.focusPending()}><Icon name="inbox" />
|
||||
<button title="新建对话(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
|
||||
<button title="工作" onClick={openWork}><Icon name="inbox" />
|
||||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||||
</button>
|
||||
{tools.map(entry => (
|
||||
@@ -151,31 +214,24 @@ export function App() {
|
||||
))}
|
||||
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
{layout.leftDivider && <div className="divider" role="separator" tabIndex={0} aria-label="调整上下文宽度" aria-orientation="vertical"
|
||||
aria-valuemin={LEFT_MIN} aria-valuemax={LEFT_MAX} aria-valuenow={Math.round(layout.leftWidth)}
|
||||
onKeyDown={onDividerKey('left')} onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft}
|
||||
title="拖动或用方向键调整宽度;双击收起" />}
|
||||
|
||||
<div className="main" hidden={previewExpanded}>
|
||||
<Conversation />
|
||||
</div>
|
||||
<main className="main" hidden={!layout.showConversation} aria-label="对话">
|
||||
<Conversation />
|
||||
</main>
|
||||
|
||||
{!ui.rightCollapsed ? (
|
||||
<>
|
||||
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
|
||||
<div className={`right-panel mk-panel-grow${previewExpanded ? ' expanded' : ''}`}
|
||||
style={previewExpanded ? undefined : { width: rightPanelWidth }}>
|
||||
<Workbench />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="right-panel collapsed" style={{ width: 44 }}>
|
||||
<div className="icon-only">
|
||||
<button title="展开右栏" onClick={store.toggleRight}>◧
|
||||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||||
</button>
|
||||
<button title="文件(搜索、预览、编辑、下载)" onClick={() => store.openFiles()}><Icon name="folder" size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{layout.rightDivider && <div className="divider" role="separator" tabIndex={0} aria-label="调整工作或文件宽度" aria-orientation="vertical"
|
||||
aria-valuemin={filesWide ? PREVIEW_MIN : RIGHT_MIN} aria-valuemax={filesWide ? PREVIEW_MAX : RIGHT_MAX}
|
||||
aria-valuenow={Math.round(layout.rightWidth)} onKeyDown={onDividerKey('right')}
|
||||
onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动或用方向键调整宽度;双击收起" />}
|
||||
<aside className={`right-panel mk-panel-grow${layout.previewExpanded ? ' expanded' : ''}`}
|
||||
aria-label={rightTab === 'files' ? '文件' : '工作'} hidden={!layout.showRight} style={{ width: layout.rightWidth }}>
|
||||
<Workbench />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Workspaces />
|
||||
|
||||
+80
-15
@@ -1,19 +1,37 @@
|
||||
// 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,
|
||||
ActivityCard,
|
||||
ActivityCard, SurfaceMeta, SurfaceSnapshot,
|
||||
} from './contracts'
|
||||
|
||||
const BASE = ''
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
/** 管理区声明分区(WP3-I3/KTD4,backend 专属端点类型):编译插件经
|
||||
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
|
||||
* 自身命令面 fail-closed。 */
|
||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||
|
||||
export interface SettingsView {
|
||||
schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown>
|
||||
projectRevision?: number; projectCanConfigure?: boolean
|
||||
personalSchema?: SettingDesc[]; personalDefaults?: Record<string, unknown>
|
||||
personalUser?: Record<string, unknown>; personalProject?: Record<string, unknown>
|
||||
}
|
||||
|
||||
async function checked(res: Response): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -21,7 +39,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 含归档。
|
||||
@@ -30,22 +48,44 @@ export const api = {
|
||||
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||||
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||||
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
||||
/** 会话内页面目录:扁平 SurfaceMeta(无 identity/spec),不可直接写入 surfaces 投影。 */
|
||||
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
|
||||
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
|
||||
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; 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)),
|
||||
@@ -103,15 +143,20 @@ export const api = {
|
||||
saveProfile: (p: AgentProfile) => auth.fetch(`${BASE}/api/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }),
|
||||
deleteProfile: (id: string) => auth.fetch(`${BASE}/api/profiles/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r =>
|
||||
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
|
||||
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
||||
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
||||
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + encodeURIComponent(projectId) : ''}`).then(r => json<SettingsView>(r)),
|
||||
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
|
||||
putProjectSettings: (projectId: string, values: Record<string, unknown>, expectedRevision: number, unset?: string[]) => auth.fetch(`${BASE}/api/settings/project/${encodeURIComponent(projectId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values, expectedRevision, ...(unset?.length ? { unset } : {}) }) }).then(r => json<{ ok: boolean; projectRevision: number }>(r)),
|
||||
putPersonalProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${encodeURIComponent(projectId)}/personal`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
|
||||
// P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。
|
||||
getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r =>
|
||||
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown>; systemRevision?: number }>(r)),
|
||||
putSystemSettings: (values: Record<string, unknown>, expectedRevision: number, unset?: string[]) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values, expectedRevision, ...(unset?.length ? { unset } : {}) }) }).then(checked),
|
||||
|
||||
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||
adminZoneSections: () => auth.fetch(`${BASE}/api/admin-zone/sections`).then(r => r.json()) as Promise<AdminZoneSection[]>,
|
||||
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
|
||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string; enabled?: boolean }[]>,
|
||||
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
@@ -127,6 +172,26 @@ export const api = {
|
||||
savePromptCommand: (body: { name: string; title: string; template: string }) => auth.fetch(`${BASE}/api/prompt-commands`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
|
||||
deletePromptCommand: (id: string) => auth.fetch(`${BASE}/api/prompt-commands/${id}`, { method: 'DELETE' }),
|
||||
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
|
||||
|
||||
// ---- 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' },
|
||||
body: JSON.stringify({ params }),
|
||||
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
|
||||
.then(out => {
|
||||
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
|
||||
if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝')
|
||||
if (envelope?.card) throw new Error('高危命令已生成确认卡——请在对话中确认执行')
|
||||
if (envelope && envelope.payload !== undefined) return envelope.payload
|
||||
return out as T
|
||||
}),
|
||||
}
|
||||
|
||||
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { groupArtifactsByName } from './artifactGrouping'
|
||||
import type { Artifact } from './contracts'
|
||||
|
||||
// 2026-10-11 实测事故:一次 run 内 register_artifact 三次 → 文件面板三份同名
|
||||
// 转写报告并列。修复语义(用户裁定):存储保留版本、界面按名称分组去重。
|
||||
|
||||
function art(id: string, name: string, updatedAt: string, size = 40000): Artifact {
|
||||
return { id, projectId: 'p1', name, kind: 'document', size, createdAt: updatedAt, updatedAt }
|
||||
}
|
||||
|
||||
describe('groupArtifactsByName', () => {
|
||||
it('同名 3 行归一组:latest 为最新,versions 新→旧', () => {
|
||||
const g = groupArtifactsByName([
|
||||
art('a1', '报告.docx', '2026-10-11T15:22:20Z'),
|
||||
art('a2', '报告.docx', '2026-10-11T15:24:06Z'),
|
||||
art('a3', '报告.docx', '2026-10-11T15:23:38Z'),
|
||||
art('b1', '其他.md', '2026-10-11T15:20:00Z', 100),
|
||||
])
|
||||
expect(g).toHaveLength(2)
|
||||
const grp = g.find(x => x.latest.name === '报告.docx')!
|
||||
expect(grp.latest.id).toBe('a2') // 最新 15:24:06
|
||||
expect(grp.versions).toHaveLength(3)
|
||||
expect(grp.versions.map(v => v.id)).toEqual(['a2', 'a3', 'a1'])
|
||||
expect(g.find(x => x.latest.name === '其他.md')!.versions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('不同名互不影响;空列表返回空', () => {
|
||||
const g = groupArtifactsByName([art('x', 'a.md', 'T1'), art('y', 'b.md', 'T2')])
|
||||
expect(g).toHaveLength(2)
|
||||
expect(groupArtifactsByName([])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
// 同名工件版本分组(2026-10-11 实测事故的修复语义,用户裁定:存储保留版本、
|
||||
// 界面去重):组内按 updated_at 新→旧,latest 为面板显示行,其余为历史版本。
|
||||
import type { Artifact } from './contracts'
|
||||
|
||||
export interface ArtifactGroup {
|
||||
latest: Artifact
|
||||
versions: Artifact[]
|
||||
}
|
||||
|
||||
export function groupArtifactsByName(list: Artifact[]): ArtifactGroup[] {
|
||||
const byName = new Map<string, Artifact[]>()
|
||||
for (const a of list) {
|
||||
const arr = byName.get(a.name) ?? []
|
||||
arr.push(a)
|
||||
byName.set(a.name, arr)
|
||||
}
|
||||
const out: ArtifactGroup[] = []
|
||||
for (const versions of byName.values()) {
|
||||
versions.sort((x, y) => (x.updatedAt < y.updatedAt ? 1 : x.updatedAt > y.updatedAt ? -1 : 0))
|
||||
out.push({ latest: versions[0], versions })
|
||||
}
|
||||
return out
|
||||
}
|
||||
+17
-3
@@ -17,11 +17,25 @@ 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() },
|
||||
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
|
||||
fetch(input: string, init?: RequestInit): Promise<Response> {
|
||||
const key = this.apiKey()
|
||||
@@ -30,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 环境(测试)忽略 */ }
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
.work-companion-chat .chat-header {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
.work-companion-chat .chat-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.work-companion-chat .chat-header > .mk-col-root { position: relative; }
|
||||
.work-companion-chat .chat-header > .mk-col-root > .mk-col {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
width: min(28rem, calc(100vw - 40px));
|
||||
z-index: 30;
|
||||
}
|
||||
.companion-settings-trigger, .companion-operation-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-ui-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.companion-settings-trigger:hover, .companion-operation-trigger:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.companion-settings-body {
|
||||
padding: 16px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.companion-setting { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-bottom: 12px; color: var(--text); font-size: var(--fs-ui-caption); }
|
||||
.companion-setting > span:first-child { flex: 1; min-width: 8rem; }
|
||||
.companion-setting select { max-width: 100%; padding: 4px 8px; background: var(--bg-panel); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-caption); }
|
||||
.companion-setting .session-note { flex: 1 1 100%; min-width: 0; width: 100%; }
|
||||
.companion-setting-note { flex: 1 1 100%; margin: 0; color: var(--text-dim); font-size: var(--fs-ui-sm); line-height: 1.6; }
|
||||
.work-companion-chat .chat-body { padding: 24px clamp(16px, 5vw, 64px); }
|
||||
.work-companion-chat .input-bar { padding: 8px clamp(16px, 5vw, 64px) 16px; }
|
||||
.work-companion-chat .input-row { flex-direction: column; align-items: stretch; padding: 12px; box-shadow: none; }
|
||||
.work-companion-chat .input-row textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 4px; line-height: 1.6; font-size: var(--fs-ui-base); }
|
||||
.companion-compose-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
|
||||
.companion-compose-spacer { flex: 1; }
|
||||
.companion-add-material { position: relative; display: inline-flex; align-items: center; gap: 4px; padding: 4px; cursor: pointer; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); }
|
||||
.companion-add-material:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.companion-add-material:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.companion-file-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); }
|
||||
.companion-input-hint { font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.companion-operation-trigger { margin-bottom: 8px; }
|
||||
.companion-operation-body { padding: 12px; margin-bottom: 12px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); }
|
||||
.companion-operation-body .companion-setting { margin-bottom: 8px; }
|
||||
.work-companion-chat .input-status { flex-wrap: wrap; gap: 4px 8px; padding: 8px 0; line-height: 1.5; }
|
||||
.work-companion-chat .input-status-note { color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.companion-reconnect, .companion-upload-status, .companion-action-feedback { padding: 8px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); line-height: 1.5; }
|
||||
.companion-upload-error { padding: 8px 0; color: var(--err-text); font-size: var(--fs-ui-caption); overflow-wrap: anywhere; }
|
||||
.companion-remove-material { display: inline-flex; align-items: center; justify-content: center; padding: 4px; color: var(--text-dim); background: transparent; border: 0; border-radius: var(--radius-xs); cursor: pointer; }
|
||||
.companion-remove-material:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.work-companion-chat .attach-chip { max-width: 100%; overflow-wrap: anywhere; }
|
||||
.companion-empty { min-height: 240px; height: 100%; text-align: center; line-height: 1.7; }
|
||||
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); }
|
||||
.companion-examples { display: grid; gap: 8px; width: min(100%, 32rem); margin-top: 12px; }
|
||||
.companion-example { padding: 12px 16px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
|
||||
.companion-example:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.conversation-work { margin: 24px 0 16px; min-width: 0; }
|
||||
.conversation-work-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 12px; color: var(--text-dim); font-size: var(--fs-ui-caption); }
|
||||
.conversation-work-heading > span:first-child { color: var(--text); font-weight: 600; }
|
||||
.conversation-work-current, .conversation-work-history { display: grid; gap: 12px; min-width: 0; }
|
||||
.conversation-work-more { padding: 12px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); }
|
||||
.conversation-work .acard { margin: 0; }
|
||||
.work-companion-chat .live-bar { flex-wrap: wrap; }
|
||||
.work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
@media (max-width: 540px) {
|
||||
.work-companion-chat .chat-header { padding: 12px 16px; gap: 8px; }
|
||||
.companion-input-hint { display: none; }
|
||||
.companion-empty { min-height: 200px; }
|
||||
.work-companion-chat .hitl-choices { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
.work-companion-chat .msg.user .bubble { background: var(--bg-active); }
|
||||
.companion-message-copy { padding: 4px 8px; border: 0; background: transparent; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.companion-message-copy:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.companion-message-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; }
|
||||
.work-companion-chat .msg-tools { gap: 4px; }
|
||||
@@ -0,0 +1,130 @@
|
||||
/* Responsive host chrome; the canonical theme and UI type scale stay in @ui. */
|
||||
.app.companion-app {
|
||||
height: 100dvh;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.companion-app .app-body { min-width: 0; overflow: hidden; }
|
||||
.companion-app .left-nav,
|
||||
.companion-app .main,
|
||||
.companion-app .right-panel { min-height: 0; max-width: 100%; overflow: hidden; }
|
||||
.companion-app .left-nav { transition: none; }
|
||||
.companion-app .left-nav[hidden],
|
||||
.companion-app .right-panel[hidden],
|
||||
.companion-app .shell-context-content[hidden],
|
||||
.companion-app .icon-only[hidden] { display: none !important; }
|
||||
|
||||
.shell-context-content { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
|
||||
.companion-app .companion-topbar { height: auto; min-height: 44px; gap: 8px; padding: 4px 12px; }
|
||||
.topbar-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.companion-topbar .topbar-name { font-size: var(--fs-ui-caption); white-space: nowrap; }
|
||||
.companion-topbar .brand-mark {
|
||||
background: var(--primary-sidebar);
|
||||
color: var(--primary-sidebar-foreground);
|
||||
box-shadow: none;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
.shell-navigation { display: flex; align-items: center; gap: 4px; }
|
||||
.shell-nav-button,
|
||||
.shell-return {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-sidebar-dim);
|
||||
font-size: var(--fs-ui-caption);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.shell-nav-button:hover,
|
||||
.shell-return:hover { background: var(--bg-hover); color: var(--text-sidebar); }
|
||||
.shell-nav-button[aria-pressed='true'] { background: var(--bg-active); color: var(--text-sidebar); }
|
||||
.shell-nav-button svg,
|
||||
.shell-return svg { flex: none; }
|
||||
.shell-back-icon { transform: rotate(180deg); }
|
||||
.shell-pending {
|
||||
padding: 0 4px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--bg-active);
|
||||
color: var(--text-sidebar);
|
||||
font-size: var(--fs-ui-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.companion-app .tsb-brand { gap: 4px; padding: 16px 12px 8px; }
|
||||
.companion-app .tsb-projrow { min-height: 36px; height: auto; border-color: transparent; background: none; padding: 4px 8px; }
|
||||
.companion-app .tsb-projrow:hover { background: var(--bg-hover); }
|
||||
.companion-app .tsb-projrow-name { flex: 1; }
|
||||
.companion-app .tsb-projrow-stage { max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); border-radius: var(--radius-sm); }
|
||||
.companion-app .tsb-iconbtn { width: 32px; height: 32px; flex: none; }
|
||||
.companion-app .tsb-actions { padding: 8px 12px 12px; gap: 12px; }
|
||||
.companion-app .tsb-newtask { min-height: 40px; height: auto; box-shadow: none; transition: none; }
|
||||
.companion-app .tsb-newtask-main { justify-content: center; padding: 8px 12px; }
|
||||
.companion-app .tsb-newtask-caret { width: 36px; flex: none; }
|
||||
.companion-app .tsb-search { min-height: 36px; height: auto; padding: 8px 12px; }
|
||||
.companion-app .tsb-orgrow { padding-bottom: 4px; }
|
||||
.companion-app .tsb-task { min-height: 40px; height: auto; gap: 4px; transition: none; }
|
||||
.companion-app .tsb-task-open {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 4px 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.companion-app .tsb-task-open > svg { flex: none; }
|
||||
.companion-app .tsb-task-tm { color: var(--text-dim); }
|
||||
.companion-app .tsb-task:focus-within .tsb-row-acts { display: inline-flex; }
|
||||
.companion-app .tsb-task:focus-within .tsb-task-tm { display: none; }
|
||||
.companion-app .tsb-row-menu {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
.companion-app .tsb-row-menu:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.companion-app .tsb-bottom { padding: 8px 12px; gap: 4px; }
|
||||
.companion-app .tsb-tool { min-height: 36px; }
|
||||
.companion-app .tsb-avatar { background: var(--bg-active); color: var(--text); box-shadow: none; border-radius: var(--radius-full); }
|
||||
.companion-app .tsb-user-sub { color: var(--text-dim); }
|
||||
.companion-app .tsb-user { min-height: 44px; height: auto; }
|
||||
.companion-app .tsb-menu {
|
||||
min-width: min(216px, calc(100vw - 16px));
|
||||
max-width: min(360px, calc(100vw - 16px));
|
||||
max-height: calc(100dvh - 16px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.companion-app .tsb-mi { overflow-wrap: anywhere; }
|
||||
|
||||
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px; }
|
||||
.companion-app.compact .shell-navigation { width: 100%; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.companion-app.compact .shell-nav-button { min-width: 0; min-height: 40px; flex-wrap: wrap; gap: 0 4px; padding: 4px; }
|
||||
.companion-app.compact .shell-nav-button > svg { display: none; }
|
||||
.companion-app.compact .shell-return { min-height: 36px; padding: 4px 8px; font-size: var(--fs-ui-sm); }
|
||||
.companion-app.compact .left-nav,
|
||||
.companion-app.compact .main,
|
||||
.companion-app.compact .right-panel { flex: 1 1 100%; width: 100%; }
|
||||
.companion-app.compact .tsb-tool { min-height: 40px; }
|
||||
.companion-app.compact .tsb-footer { padding: 8px 12px; }
|
||||
.companion-app.compact .tsb-row-acts,
|
||||
.companion-app.compact .tsb-task:focus-within .tsb-row-acts { display: none; }
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon } from '@ui'
|
||||
import { humanChangePasswordError } from './LoginPage'
|
||||
|
||||
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -20,7 +21,10 @@ function Shell({ title, onClose, children }: { title: string; onClose: () => voi
|
||||
)
|
||||
}
|
||||
|
||||
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
|
||||
/** 主动改密(账户菜单入口):用户名(只读关联)+ 当前密码 + 新密码 + 确认。
|
||||
* 表单语义(2026-10-12 增量 3):<form onSubmit> 承载提交(Enter 可用)、autocomplete
|
||||
* 三段标注(username/current-password/new-password)、label 与 input 关联;
|
||||
* 错误文案复用 humanChangePasswordError——401 不再一刀切成「当前密码不正确」。 */
|
||||
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
const me = useStore(s => s.me)
|
||||
const [current, setCurrent] = useState('')
|
||||
@@ -41,23 +45,28 @@ export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
store.toast('密码已更新')
|
||||
setTimeout(onClose, 900)
|
||||
} catch (e) {
|
||||
setErr(String(e).includes('401') ? '当前密码不正确' : String(e))
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell title="修改密码" onClose={onClose}>
|
||||
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
|
||||
<div className="ad-changepw">
|
||||
<label className="lg-label">当前密码({me?.owner ?? ''})</label>
|
||||
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||||
<form className="ad-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||
<label className="lg-label" htmlFor="changepw-username">用户名</label>
|
||||
<input id="changepw-username" className="lg-input" value={me?.owner ?? ''} readOnly autoComplete="username" />
|
||||
<label className="lg-label" htmlFor="changepw-current">当前密码</label>
|
||||
<input id="changepw-current" className="lg-input" type="password" autoComplete="current-password"
|
||||
value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
|
||||
<label className="lg-label" htmlFor="changepw-next">新密码(≥8 位)</label>
|
||||
<input id="changepw-next" className="lg-input" type="password" autoComplete="new-password"
|
||||
value={next} onChange={e => setNext(e.target.value)} />
|
||||
<label className="lg-label" htmlFor="changepw-confirm">确认新密码</label>
|
||||
<input id="changepw-confirm" className="lg-input" type="password" autoComplete="new-password"
|
||||
value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
<button className="lg-submit" disabled={busy || !current || !next || !confirm} onClick={submit}>{busy ? '提交中…' : '更新密码'}</button>
|
||||
</div>
|
||||
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '更新密码'}</button>
|
||||
</form>
|
||||
)}
|
||||
</Shell>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。
|
||||
// 系统管理(重设计方案 阶段②):全屏独立视图(带返回,非弹窗)。
|
||||
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
|
||||
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
|
||||
import { useState } from 'react'
|
||||
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
|
||||
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
|
||||
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
|
||||
import { useCallback, useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
|
||||
import { EmptyState, Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
import { RdmRolesPanel } from './adminRdmSections'
|
||||
import type { AdminZoneSection } from '../api'
|
||||
|
||||
interface AdminSectionDef {
|
||||
key: string
|
||||
@@ -12,7 +18,7 @@ interface AdminSectionDef {
|
||||
icon: IconName
|
||||
desc: string
|
||||
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
|
||||
render: () => React.ReactNode
|
||||
render: (onDirtyChange?: (dirty: boolean) => void) => React.ReactNode
|
||||
}
|
||||
|
||||
function ProfilesPanelWrapper() {
|
||||
@@ -22,32 +28,75 @@ function ProfilesPanelWrapper() {
|
||||
|
||||
const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
|
||||
// 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: onDirtyChange => <SystemSettingsPanel onDirtyChange={onDirtyChange} /> },
|
||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||
]
|
||||
|
||||
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
|
||||
const DECLARED_COMPONENTS: Record<string, () => React.ReactNode> = {
|
||||
'rdm.audit': () => <RdmAuditWorkspace />,
|
||||
}
|
||||
|
||||
/** icon 走基座 IconName 词表;声明携带未知图标时回退通用图标(不渲染空白)。 */
|
||||
const ICONS: IconName[] = ['plus', 'search', 'panel-left', 'inbox', 'settings', 'sun', 'moon', 'pin', 'archive', 'undo',
|
||||
'chevron-right', 'chevron-down', 'folder', 'message', 'package', 'bot', 'puzzle', 'grid', 'arrow-up', 'paperclip',
|
||||
'more', 'logout', 'key', 'user', 'users', 'shield', 'x', 'check', 'alert', 'send', 'sparkles', 'clock', 'list',
|
||||
'file', 'image', 'download', 'trash', 'eye', 'maximize', 'minimize']
|
||||
function declaredIcon(name?: string): IconName {
|
||||
return (ICONS as string[]).includes(name ?? '') ? (name as IconName) : 'grid'
|
||||
}
|
||||
|
||||
function DeclaredSection({ section }: { section: AdminZoneSection }) {
|
||||
const render = DECLARED_COMPONENTS[section.id]
|
||||
if (!render) {
|
||||
return <EmptyState icon="puzzle">分区「{section.title}」由内置插件声明,但当前前端版本未携带其组件<br />(部署前端与二进制版本不匹配时会出现;升级前端即可)</EmptyState>
|
||||
}
|
||||
return <>{render()}</>
|
||||
}
|
||||
|
||||
export function AdminZone() {
|
||||
const me = useStore(s => s.me)
|
||||
const declared = useStore(s => s.adminSections)
|
||||
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
|
||||
const [sectionKey, setSectionKey] = useState('users')
|
||||
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。
|
||||
if (!me?.isAdmin) return null
|
||||
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0]
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const dirtyChange = useCallback((value: boolean) => setDirty(value), [])
|
||||
const depart = () => !dirty || window.confirm('系统配置尚未保存,离开后将丢弃这些改动。继续?')
|
||||
const isAdmin = !!me?.isAdmin
|
||||
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
|
||||
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
|
||||
if (!isAdmin && declared.length === 0) return null
|
||||
const sections: AdminSectionDef[] = [
|
||||
...(isAdmin ? SECTIONS.filter(s => s.key !== 'rdm-roles' || rdmRolesAvailable) : []),
|
||||
...declared.map(s => ({
|
||||
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
|
||||
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
|
||||
render: () => <DeclaredSection section={s} />,
|
||||
})),
|
||||
]
|
||||
const fallback = sections[0]?.key ?? ''
|
||||
const section = sections.find(s => s.key === sectionKey) ?? sections[0]
|
||||
if (!section) return null
|
||||
return (
|
||||
<div className="az" role="dialog" aria-label="系统管理">
|
||||
<div className="az-head">
|
||||
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
|
||||
<button className="btn ghost" onClick={() => { if (depart()) store.setUI({ adminZoneOpen: false }) }}>
|
||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||
</button>
|
||||
<span className="az-title">系统管理</span>
|
||||
<span className="az-sub">仅管理员可见 · 变更即时生效</span>
|
||||
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 按配置注明的时机生效</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="az-tenant">{me.owner}</span>
|
||||
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||
</div>
|
||||
<div className="az-body">
|
||||
<nav className="az-side">
|
||||
{SECTIONS.map(s => (
|
||||
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
|
||||
{sections.map(s => (
|
||||
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => { if (depart()) { setDirty(false); setSectionKey(s.key) } }}>
|
||||
<Icon name={s.icon} size={15} />
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
@@ -58,7 +107,7 @@ export function AdminZone() {
|
||||
<h3>{section.label}</h3>
|
||||
<p>{section.desc}</p>
|
||||
</div>
|
||||
{section.render()}
|
||||
{section.render(dirtyChange)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,17 +3,25 @@
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { api } from '../api'
|
||||
import { Icon } from '@ui'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { artifactIcon, humanSize } from '../messageparts'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { groupArtifactsByName } from '../artifactGrouping'
|
||||
import { surfaceKey } from '../surfaceKey'
|
||||
|
||||
export function ArtifactsPanel() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const surfaces = useStore(s => s.surfaces)
|
||||
const [q, setQ] = useState('')
|
||||
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
|
||||
|
||||
const kinds = ['all', 'document', 'data', 'image'] as const
|
||||
const visible = artifacts.filter(a =>
|
||||
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
|
||||
// 同名工件 = 同一交付物的版本序列(存储保留全量):面板按名称分组去重,
|
||||
// 只显示最新版;组>1 时可展开看历史版本。2026-10-11 实测 3 份同名转写报告
|
||||
// 并列的直接修复(存储与提示词语义「修订=新版本」保持不变)。
|
||||
const groups = groupArtifactsByName(visible)
|
||||
|
||||
return (
|
||||
<div className="artifacts-panel">
|
||||
@@ -32,26 +40,50 @@ export function ArtifactsPanel() {
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
{artifacts.length === 0
|
||||
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
|
||||
: visible.length === 0
|
||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||
: visible.map(a => (
|
||||
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
|
||||
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
|
||||
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
|
||||
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
{surfaces.length > 0 && (
|
||||
<div className="ap-surfaces" data-ap-surfaces>
|
||||
<div className="ap-section-title">生成页面</div>
|
||||
{surfaces.filter(sv => surfaceKey(sv) != null).map(sv => {
|
||||
const id = surfaceKey(sv)!
|
||||
return (
|
||||
<div key={id} className="artifact-row" data-surface-row={id}
|
||||
onClick={() => store.openSurface(id)} title={`打开页面 ${sv.spec?.title ?? ''}`}>
|
||||
<span className="ar-icon"><Icon name="grid" size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{a.name}</span>
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
|
||||
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity?.pluginId}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{artifacts.length === 0
|
||||
? <div className="empty-note">暂无文件,可添加材料或在对话中生成文档</div>
|
||||
: visible.length === 0
|
||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||
: groups.map(g => g.versions.length === 1
|
||||
? <ArtifactRow key={g.latest.id} a={g.latest} />
|
||||
: <Collapsible key={g.latest.id} id={'artv:' + g.latest.id}
|
||||
trigger={
|
||||
<div className="artifact-row artifact-group" data-artifact-group={g.latest.id}>
|
||||
<ArtifactRowInner a={g.latest} badge={`${g.versions.length} 版`} />
|
||||
</div>
|
||||
}>
|
||||
{g.versions.slice(1).map(v => (
|
||||
<div key={v.id} className="artifact-row artifact-version" data-artifact-version={v.id}
|
||||
onClick={() => store.requestPreview(v)} title={`预览历史版本 ${v.name}`}>
|
||||
<span className="ar-icon"><Icon name="clock" size={13} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="meta">v{shortTime(v.updatedAt)} · {humanSize(v.size) || '—'}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<a className="icon-btn" title="下载此版本" href={api.artifactDownloadUrl(v.id)} download={v.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Collapsible>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -65,3 +97,32 @@ function shortTime(iso: string): string {
|
||||
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
function ArtifactRow({ a }: { a: Artifact }) {
|
||||
return (
|
||||
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
|
||||
<div className="artifact-row" data-artifact-row={a.id}
|
||||
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
|
||||
<ArtifactRowInner a={a} />
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactRowInner({ a, badge }: { a: Artifact; badge?: string }) {
|
||||
return (
|
||||
<>
|
||||
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{a.name}{badge ? <span className="ver-badge" data-ver-badge>{badge}</span> : null}</span>
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,25 +2,12 @@ import { useEffect, useState } from 'react'
|
||||
import { Btn, SettingRow, SettingsGroup } from '@ui'
|
||||
import { api } from '../api'
|
||||
|
||||
const services = [
|
||||
['models.chat', '主模型'],
|
||||
['models.lightweight', '轻量模型'],
|
||||
['models.embedding', 'Embedding'],
|
||||
['models.rerank', 'Reranker'],
|
||||
['tools.tavily', 'Tavily'],
|
||||
] as const
|
||||
|
||||
type Secret = { id: string; service: string; label: string; active: boolean; mask: string }
|
||||
type Entry = { id: string; keys: string[]; content: string; constant: boolean; logic: string }
|
||||
type Cmd = { id: string; name: string; title: string; template: string }
|
||||
|
||||
export function BorrowPanels() {
|
||||
const [secrets, setSecrets] = useState<Secret[]>([])
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [cmds, setCmds] = useState<Cmd[]>([])
|
||||
const [service, setService] = useState<string>(services[0][0])
|
||||
const [label, setLabel] = useState('')
|
||||
const [value, setValue] = useState('')
|
||||
const [keys, setKeys] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [constant, setConstant] = useState(false)
|
||||
@@ -29,7 +16,6 @@ export function BorrowPanels() {
|
||||
const [cmdBody, setCmdBody] = useState('请总结以上内容。\n{{input}}')
|
||||
|
||||
const reload = () => {
|
||||
api.listSecrets().then(setSecrets).catch(() => undefined)
|
||||
api.listContextEntries().then(setEntries).catch(() => undefined)
|
||||
api.listPromptCommands().then(setCmds).catch(() => undefined)
|
||||
}
|
||||
@@ -37,28 +23,6 @@ export function BorrowPanels() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsGroup title="密钥">
|
||||
<SettingRow label="新增密钥" desc="每个服务同时只有一把生效。列表只显示掩码。">
|
||||
<select value={service} onChange={e => setService(e.target.value)}>
|
||||
{services.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="标签与密钥" desc="保存后立即生效,并停用同服务的其他密钥。">
|
||||
<input className="search-input" value={label} placeholder="标签" onChange={e => setLabel(e.target.value)} />
|
||||
<input className="search-input" type="password" autoComplete="new-password" value={value} placeholder="密钥" onChange={e => setValue(e.target.value)} />
|
||||
<Btn variant="primary" onClick={async () => {
|
||||
if (!value.trim()) return
|
||||
await api.createSecret(service, label || '未命名', value.trim())
|
||||
setValue(''); setLabel(''); reload()
|
||||
}}>添加</Btn>
|
||||
</SettingRow>
|
||||
{secrets.map(s => (
|
||||
<SettingRow key={s.id} label={s.label} desc={`${s.service} · ${s.mask}${s.active ? ' · 生效' : ''}`}>
|
||||
{!s.active && <Btn onClick={async () => { await api.activateSecret(s.id); reload() }}>启用</Btn>}
|
||||
<Btn onClick={async () => { await api.deleteSecret(s.id); reload() }}>删除</Btn>
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
<SettingsGroup title="条件注入">
|
||||
<SettingRow label="关键词" desc="逗号分隔。用 /正则/ 表示正则。勾选后变为常驻。">
|
||||
<input className="search-input" value={keys} placeholder="关键词" onChange={e => setKeys(e.target.value)} />
|
||||
|
||||
+163
-105
@@ -13,6 +13,8 @@ import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
|
||||
import { ProcessTimeline } from './ProcessTimeline'
|
||||
import { ConversationWork } from './ConversationWork'
|
||||
import '../companion-conversation.css'
|
||||
|
||||
function hasFiles(dt: DataTransfer | null): boolean {
|
||||
return !!dt && Array.from(dt.types).includes('Files')
|
||||
@@ -28,9 +30,7 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
|
||||
markdown={markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))} />
|
||||
case 'thinking':
|
||||
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
|
||||
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
|
||||
return <ThinkingBlock part={part} idKey={part.text?.slice(0, 24) ?? 't'} defaultOpen={!!live} />
|
||||
return null
|
||||
case 'tool_call':
|
||||
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
|
||||
case 'tool_result': {
|
||||
@@ -105,18 +105,6 @@ function findCard(id: string) {
|
||||
return cardCache[id]
|
||||
}
|
||||
|
||||
// 思考块(Collapsible 契约):左线弱色,流式默认展开、终态默认收起;展开态记忆。
|
||||
function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string; defaultOpen?: boolean }) {
|
||||
return (
|
||||
<div className="part-thinking">
|
||||
<Collapsible id={`think:${idKey}`} defaultOpen={defaultOpen} triggerClassName="part-thinking-summary"
|
||||
trigger={<><span className="part-thinking-chev">▸</span>思考过程</>}>
|
||||
<div className="part-thinking-body">{part.text}</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。
|
||||
function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: string; name: string; kind?: string }; updates?: number }) {
|
||||
const [menu, setMenu] = useState(false)
|
||||
@@ -189,10 +177,14 @@ function useElapsed(startedAt: number): string {
|
||||
// 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。
|
||||
function LiveBar({ live }: { live: LiveState }) {
|
||||
const elapsed = useElapsed(live.startedAt)
|
||||
const phase = livePhaseLabel(live)
|
||||
const label = phase.startsWith('正在编写') ? '正在准备操作'
|
||||
: phase.startsWith('正在运行') ? '正在处理'
|
||||
: phase === '思考中' ? '正在梳理' : phase
|
||||
return (
|
||||
<span className="live-bar">
|
||||
<span className="live-dot" />
|
||||
<span className="mk-sweep">{livePhaseLabel(live)}</span>
|
||||
<span className="mk-sweep">{label}</span>
|
||||
<span className="live-elapsed">已工作 {elapsed}</span>
|
||||
</span>
|
||||
)
|
||||
@@ -211,15 +203,14 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
const split = useMemo(() => splitTimeline(parts, finished), [parts, finished])
|
||||
const showProcess = showProcessInline(live ?? null, autoCollapse)
|
||||
const memKey = `process:${msg.runId || msg.id}`
|
||||
const touched = !!(msg.runId && touchedProcess.has(msg.runId))
|
||||
const touched = touchedProcess.has(memKey)
|
||||
const [open, setOpen] = useState(() => {
|
||||
if (live && !touched) return true
|
||||
if (touched) return getPersistedOpen(memKey)
|
||||
return showProcess
|
||||
})
|
||||
useEffect(() => {
|
||||
if (live && msg.runId && !touchedProcess.has(msg.runId)) setOpen(true)
|
||||
}, [live, msg.runId])
|
||||
if (!touchedProcess.has(memKey)) setOpen(showProcess)
|
||||
}, [memKey, showProcess])
|
||||
const extras = live ? [] : citedArtifactCards(split.final)
|
||||
const cards = live ? [] : dedupeDeliverables(split.final)
|
||||
const lastText = (() => {
|
||||
@@ -229,13 +220,13 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
const streamingText = !!(live && live.phase === 'responding' && lastText >= 0
|
||||
&& !live.items.some(it => it.kind === 'tool' && it.state !== 'done'))
|
||||
const toolCount = split.timeline.filter(p => p.type === 'tool_call').length
|
||||
const headline = live ? livePhaseLabel(live) : processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)
|
||||
const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}`
|
||||
const timeline = (
|
||||
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
|
||||
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
|
||||
)
|
||||
const toggleProcess = (next: boolean) => {
|
||||
if (msg.runId) touchedProcess.add(msg.runId)
|
||||
touchedProcess.add(memKey)
|
||||
setPersistedOpen(memKey, next)
|
||||
setOpen(next)
|
||||
}
|
||||
@@ -246,7 +237,7 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
<span>助手</span>
|
||||
{live && <LiveBar live={live} />}
|
||||
</div>
|
||||
{split.timeline.length > 0 && (
|
||||
{split.timeline.some(part => part.type !== 'thinking') && (
|
||||
<div className="process-collapse">
|
||||
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||||
trigger={<>
|
||||
@@ -268,7 +259,8 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
{extras.map(art => (
|
||||
<DeliverableCard key={art.id} artifact={{ artifactId: art.id, name: art.name, kind: art.kind }} />
|
||||
))}
|
||||
{live && <LiveAgentNotes live={live} />}
|
||||
{live && Object.keys(live.agentNotes).length > 0 && <Collapsible id={`agent-notes:${live.runId}`}
|
||||
triggerClassName="process-collapse-summary" trigger={<span>协作记录</span>}><LiveAgentNotes live={live} /></Collapsible>}
|
||||
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
|
||||
{convId && <MessageTools msg={msg} convId={convId} />}
|
||||
</div>
|
||||
@@ -282,25 +274,32 @@ function SessionNoteField({ convId, note }: { convId: string; note: string }) {
|
||||
<input className="search-input session-note" value={value} placeholder="本会话要求"
|
||||
title="附在上下文末尾,不改系统前缀"
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => store.openConversation(convId)) }} />
|
||||
onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => {
|
||||
if (store.getState().currentConvId === convId) return store.openConversation(convId)
|
||||
}) }} />
|
||||
)
|
||||
}
|
||||
|
||||
function MessageTools({ msg, convId }: { msg: Message; convId: string }) {
|
||||
const text = msg.parts.filter(p => p.type === 'text').map(p => p.text ?? '').join('\n')
|
||||
const refresh = () => { void store.openConversation(convId) }
|
||||
const refresh = () => { if (store.getState().currentConvId === convId) void store.openConversation(convId) }
|
||||
return (
|
||||
<div className="msg-tools">
|
||||
<button className="btn ghost" type="button" onClick={() => { void navigator.clipboard.writeText(text) }}>复制</button>
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.excludeMessage(msg.id, !msg.excluded).then(refresh) }}>
|
||||
{msg.excluded ? '纳入上下文' : '排除出上下文'}
|
||||
</button>
|
||||
{msg.role === 'assistant' && (
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.regenerate(convId, msg.id).then(refresh) }}>重新生成</button>
|
||||
)}
|
||||
{msg.variantGroup && msg.excluded && (
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.selectVariant(msg.id).then(refresh) }}>使用此候选</button>
|
||||
)}
|
||||
<button className="companion-message-copy" type="button" onClick={() => { void navigator.clipboard.writeText(text) }}>复制</button>
|
||||
<Collapsible id={`message-operations:${convId}:${msg.id}`} trigger="消息操作" triggerClassName="companion-message-copy">
|
||||
<div className="companion-message-actions">
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.excludeMessage(msg.id, !msg.excluded).then(refresh) }}>
|
||||
{msg.excluded ? '纳入上下文' : '排除出上下文'}
|
||||
</button>
|
||||
{msg.role === 'assistant' && (
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.regenerate(convId, msg.id).then(refresh) }}>重新生成</button>
|
||||
)}
|
||||
{msg.variantGroup && msg.excluded && (
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.selectVariant(msg.id).then(refresh) }}>使用此候选</button>
|
||||
)}
|
||||
{msg.role === 'user' && <RewindControl convId={convId} messageId={msg.id} />}
|
||||
</div>
|
||||
</Collapsible>
|
||||
{msg.variantGroup && <span className="process-collapse-meta">候选 {(msg.variantIndex ?? 0) + 1}</span>}
|
||||
</div>
|
||||
)
|
||||
@@ -349,16 +348,32 @@ function LiveAgentNotes({ live }: { live: LiveState }) {
|
||||
|
||||
function HITLCardView({ card }: { card: HITLCard }) {
|
||||
const [free, setFree] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const actionState = useStore(s => s.cardActionStates?.[`hitl:${card.id}`])
|
||||
const submitting = actionState !== undefined
|
||||
// wfcompile.doScript 使用固定首行 + 脚本预览;其他多行问题可能包含
|
||||
// 用户必须理解的业务后果,保持原样,不凭内容猜测其信息层级。
|
||||
const scriptPrefix = '运行这段脚本?\n'
|
||||
const hasScriptPreview = card.kind === 'confirm' && card.question.startsWith(scriptPrefix)
|
||||
const question = hasScriptPreview ? scriptPrefix.trimEnd() : card.question
|
||||
const detail = [hasScriptPreview ? card.question.slice(scriptPrefix.length) : '', card.detail]
|
||||
.filter(value => value?.trim()).join('\n\n')
|
||||
const answer = async (resp: { choiceId?: string; freeText?: string }) => {
|
||||
setSubmitting(true)
|
||||
if (store.getState().cardActionStates?.[`hitl:${card.id}`]) return
|
||||
await store.resolveHITL(card.id, resp)
|
||||
setSubmitting(false)
|
||||
}
|
||||
return (
|
||||
<div className={`hitl-card${card.kind === 'confirm' ? ' wait' : ''}${card.kind === 'choice' || card.kind === 'input' ? ' ask' : ''}`}>
|
||||
<div className="hitl-q"><Icon name="check" size={15} />{card.question}</div>
|
||||
{card.detail && <pre className="hitl-detail">{card.detail}</pre>}
|
||||
<div className="hitl-q"><Icon name="check" size={15} />{question}</div>
|
||||
{card.blocked && <div className="companion-action-feedback" role="status">
|
||||
操作受限:{card.blockReason || '当前操作条件尚未满足'}
|
||||
</div>}
|
||||
{detail && <div className="process-collapse"><Collapsible id={`hitl-details:${card.conversationId}:${card.id}`}
|
||||
triggerClassName="process-collapse-summary" trigger={<><span className="process-collapse-chev">▸</span>操作详情</>}>
|
||||
<pre className="hitl-detail">{detail}</pre>
|
||||
</Collapsible></div>}
|
||||
{actionState && <div className="companion-action-feedback" role="status">
|
||||
{actionState === 'unknown' ? '正在核实处理结果' : '正在提交你的选择'}
|
||||
</div>}
|
||||
{card.choices && (
|
||||
<div className="hitl-choices">
|
||||
{card.choices.map(c => (
|
||||
@@ -373,7 +388,7 @@ function HITLCardView({ card }: { card: HITLCard }) {
|
||||
{card.allowFreeInput && (
|
||||
<div className="hitl-free">
|
||||
<input className="search-input" value={free} onChange={e => setFree(e.target.value)}
|
||||
placeholder={card.freeInputHint || '自由输入…'} onKeyDown={e => { if (e.key === 'Enter' && free.trim()) answer({ freeText: free.trim() }) }} />
|
||||
disabled={submitting} placeholder={card.freeInputHint || '补充你的要求'} onKeyDown={e => { if (e.key === 'Enter' && free.trim() && !submitting) void answer({ freeText: free.trim() }) }} />
|
||||
<button className="btn ghost" disabled={!free.trim() || submitting} onClick={() => answer({ freeText: free.trim() })}>发送</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -450,6 +465,13 @@ export function NoticeMessage({ msg }: { msg: Message }) {
|
||||
}
|
||||
|
||||
export function Conversation() {
|
||||
const conversationId = useStore(s => s.currentConvId)
|
||||
const projectId = useStore(s => s.currentProjectId)
|
||||
// 本地草稿、附件、折叠设置与滚动跟随属于当前会话,切换时一起重置。
|
||||
return <ConversationSession key={`${projectId}:${conversationId}`} />
|
||||
}
|
||||
|
||||
function ConversationSession() {
|
||||
const currentConvId = useStore(s => s.currentConvId)
|
||||
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
|
||||
const messages = useStore(s => s.messages)
|
||||
@@ -462,17 +484,25 @@ export function Conversation() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const profiles = useStore(s => s.profiles)
|
||||
const cards = useStore(s => s.cards)
|
||||
const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
|
||||
|
||||
const runtimeMock = useStore(s => s.runtimeMock)
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
const cardActionStates = useStore(s => s.cardActionStates)
|
||||
const [text, setText] = useState('')
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
||||
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
|
||||
const [dragDepth, setDragDepth] = useState(0)
|
||||
const [uploading, setUploading] = useState(0)
|
||||
const [uploadError, setUploadError] = useState('')
|
||||
const composeActive = useRef(true)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const stickRef = useRef(true)
|
||||
const [showJump, setShowJump] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
composeActive.current = true
|
||||
return () => { composeActive.current = false }
|
||||
}, [])
|
||||
|
||||
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
|
||||
cardCache = Object.fromEntries(cards.map(c => [c.id, c]))
|
||||
|
||||
@@ -492,8 +522,30 @@ export function Conversation() {
|
||||
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
||||
}, [messages.length, live?.items, live?.phase, activeHitl.length])
|
||||
|
||||
const composeIsCurrent = () => {
|
||||
const state = store.getState()
|
||||
return composeActive.current && state.currentConvId === currentConvId && state.currentProjectId === currentProjectId
|
||||
}
|
||||
const addMaterials = async (files: File[]) => {
|
||||
if (!files.length) return
|
||||
setUploadError('')
|
||||
setUploading(count => count + files.length)
|
||||
for (const file of files) {
|
||||
if (!composeIsCurrent()) return
|
||||
const id = await store.uploadAttachment(file)
|
||||
if (!composeIsCurrent()) return
|
||||
setUploading(count => Math.max(0, count - 1))
|
||||
if (id) {
|
||||
setAttachmentIds(ids => [...ids, id])
|
||||
setAttachmentNames(names => [...names, file.name])
|
||||
} else {
|
||||
setUploadError(message => [message, `「${file.name}」未能添加,请重新选择文件`].filter(Boolean).join(';'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (!text.trim() && attachmentIds.length === 0) return
|
||||
if (uploading > 0 || (!text.trim() && attachmentIds.length === 0)) return
|
||||
const t = text
|
||||
const ids = attachmentIds
|
||||
const names = attachmentNames
|
||||
@@ -507,54 +559,54 @@ export function Conversation() {
|
||||
|
||||
if (!currentConvId) {
|
||||
return (
|
||||
<div className="chat">
|
||||
<div className="chat-body"><div className="empty-state">
|
||||
<div style={{ fontSize: 40 }}>⌘</div>
|
||||
<div>选择或创建一个会话开始</div>
|
||||
{runtimeMock
|
||||
? <div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error</div>
|
||||
: <div style={{ fontSize: 'var(--fs-ui-sm)' }}>演示模式可在设置里打开,无需模型密钥</div>}
|
||||
<div className="chat work-companion-chat">
|
||||
<div className="chat-body"><div className="empty-state companion-empty">
|
||||
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
|
||||
<div className="companion-empty-title">从一件需要完成的工作开始</div>
|
||||
<div>告诉助手你的目标,或准备好需要一起阅读的材料</div>
|
||||
<button type="button" className="btn" onClick={() => { void store.createTask() }}>开始新对话</button>
|
||||
</div></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`chat ${dragDepth > 0 ? 'is-dragging' : ''}`}
|
||||
<div className={`chat work-companion-chat ${dragDepth > 0 ? 'is-dragging' : ''}`}
|
||||
onDragEnter={e => { if (!hasFiles(e.dataTransfer)) return; e.preventDefault(); setDragDepth(d => d + 1) }}
|
||||
onDragOver={e => { if (hasFiles(e.dataTransfer)) e.preventDefault() }}
|
||||
onDragLeave={() => setDragDepth(d => Math.max(0, d - 1))}
|
||||
onDrop={async e => {
|
||||
onDrop={e => {
|
||||
if (!e.dataTransfer?.files?.length) return
|
||||
e.preventDefault()
|
||||
setDragDepth(0)
|
||||
for (const f of Array.from(e.dataTransfer.files)) {
|
||||
const id = await store.uploadAttachment(f)
|
||||
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
|
||||
}
|
||||
void addMaterials(Array.from(e.dataTransfer.files))
|
||||
}}>
|
||||
{dragDepth > 0 && <div className="drop-veil">释放以上传资料</div>}
|
||||
<div className="chat-header">
|
||||
<span className="project-badge" title="当前项目">{projectName}</span>
|
||||
<div className="chat-title">{conversation?.title ?? '会话'}</div>
|
||||
<select className="effect-badge" value={conversation?.profileId || 'default'} title="切换会话画像"
|
||||
<Collapsible id={`conversation-settings:${currentProjectId}:${currentConvId}`} triggerClassName="companion-settings-trigger"
|
||||
trigger={<><Icon name="settings" size={14} /><span>会话设置</span></>} bodyClassName="companion-settings-body">
|
||||
<label className="companion-setting"><span>工作方式</span>
|
||||
<select value={conversation?.profileId || 'default'} aria-label="工作方式"
|
||||
onChange={async e => {
|
||||
if (!currentConvId) return
|
||||
await api.setConversationProfile(currentConvId, e.target.value)
|
||||
await store.openConversation(currentConvId)
|
||||
if (store.getState().currentConvId === currentConvId) await store.openConversation(currentConvId)
|
||||
}}>
|
||||
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<button className="btn ghost" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
</label>
|
||||
<div className="companion-setting"><span>读取同项目的其他对话</span><button className="btn ghost"
|
||||
title={conversation?.allowCrossRead === false
|
||||
? '本会话不读取其他会话记录(点击开启)'
|
||||
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
|
||||
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
|
||||
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
|
||||
{conversation?.allowCrossRead === false ? '关闭' : '允许'}
|
||||
</button>
|
||||
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
|
||||
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||
{!connected && <span className="conn-label">重连中</span>}
|
||||
<span className="companion-setting-note">实际读取范围还需项目设置允许</span></div>
|
||||
<label className="companion-setting"><span>这次工作的要求</span><SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} /></label>
|
||||
</Collapsible>
|
||||
<span title={connected ? '已连接' : '正在重新连接'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||
</div>
|
||||
|
||||
<div className="chat-body" ref={bodyRef}
|
||||
@@ -584,10 +636,10 @@ export function Conversation() {
|
||||
</div>
|
||||
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
|
||||
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
|
||||
{currentConvId && <RewindControl convId={currentConvId} messageId={m.id} />}
|
||||
{anchored.map(c => (
|
||||
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
||||
onAction: (card, actionId) => store.cardAction(card, actionId),
|
||||
actionState: card => cardActionStates?.[card.id],
|
||||
onLocate: card => store.openInbox(card.id),
|
||||
}} />
|
||||
))}
|
||||
@@ -600,10 +652,16 @@ export function Conversation() {
|
||||
{showLiveBubble(live?.runId ?? null, messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string)) && live &&
|
||||
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
|
||||
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
|
||||
{messages.length === 0 && <div className="empty-state">
|
||||
<ConversationWork />
|
||||
{messages.length === 0 && !live && cards.length === 0 && activeHitl.length === 0 && <div className="empty-state companion-empty">
|
||||
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
|
||||
<div>发送消息开始对话</div>
|
||||
{runtimeMock && <div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>}
|
||||
<div className="companion-empty-title">这次想完成什么</div>
|
||||
<div>说说目标,也可以先添加材料</div>
|
||||
<div className="companion-examples" aria-label="可参考的工作目标">
|
||||
{['帮我整理这些材料中的重点和缺项', '一起比较这两个方案,说明各自适合的情况', '帮我把已有想法整理成一份草稿'].map(example => (
|
||||
<button type="button" className="companion-example" key={example} onClick={() => { setText(example); inputRef.current?.focus() }}>{example}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
{showJump && (
|
||||
@@ -616,59 +674,59 @@ export function Conversation() {
|
||||
|
||||
<div className="input-bar">
|
||||
<OutboxPanel />
|
||||
{!connected && <div className="companion-reconnect" role="status">正在重新连接,最新进展待确认</div>}
|
||||
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
|
||||
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
|
||||
{(busy || bgRunning > 0) && (
|
||||
<div className="input-status" data-testid="input-status">
|
||||
<span className="live-dot" />
|
||||
{busy
|
||||
? <span className="mk-sweep">助手工作中{bgRunning > 0 ? ` · ${bgRunning} 个后台任务` : ''}</span>
|
||||
: <span>{bgRunning} 个后台任务进行中</span>}
|
||||
<span className="input-status-note">{busy ? '输入仍可排队' : '完成后回到对话汇报'}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" className="input-status-link" onClick={() => store.openInbox()}>在工作里查看</button>
|
||||
? <span className="mk-sweep">助手正在处理{bgRunning > 0 ? ` · ${bgRunning} 项后台工作` : ''}</span>
|
||||
: <span>{bgRunning} 项后台工作</span>}
|
||||
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,完成后会在这里汇报'}</span>
|
||||
</div>
|
||||
)}
|
||||
{attachmentNames.length > 0 && (
|
||||
<div className="attachments">
|
||||
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
|
||||
<span style={{ cursor: 'pointer', display: 'inline-flex' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}><Icon name="x" size={11} /></span>
|
||||
<button type="button" className="companion-remove-material" aria-label={`移除材料 ${n}`} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}><Icon name="x" size={11} /></button>
|
||||
</span>)}
|
||||
</div>
|
||||
)}
|
||||
{uploading > 0 && <div className="companion-upload-status" role="status">正在添加 {uploading} 份材料,完成后即可发送</div>}
|
||||
{uploadError && <div className="companion-upload-error" role="alert">{uploadError}</div>}
|
||||
<Collapsible id={`operation-settings:${currentProjectId}:${currentConvId}`} triggerClassName="companion-operation-trigger"
|
||||
trigger={<><Icon name="settings" size={13} /><span>操作设置</span></>} bodyClassName="companion-operation-body">
|
||||
<label className="companion-setting"><span>操作权限</span><select aria-label="操作权限" value={conversation?.permissionMode || 'default'} onChange={e => {
|
||||
const mode = e.target.value
|
||||
if (mode === 'default' || mode === 'plan' || mode === 'auto_edit') void store.setPermissionMode(currentConvId, mode)
|
||||
}}>
|
||||
<option value="default">默认</option><option value="plan">计划</option><option value="auto_edit">自动改文件</option>
|
||||
</select></label>
|
||||
<p className="companion-setting-note">计划模式只进行读取与规划;自动改文件模式允许文件修改。重要操作仍按当前权限规则确认</p>
|
||||
</Collapsible>
|
||||
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
|
||||
<div className="input-row">
|
||||
<label className="mode-select" title="本会话的权限模式">
|
||||
<span>权限</span>
|
||||
<select value={conversation?.permissionMode || 'default'} onChange={e => {
|
||||
const mode = e.target.value
|
||||
if (mode === 'default' || mode === 'plan' || mode === 'auto_edit') {
|
||||
if (currentConvId) void store.setPermissionMode(currentConvId, mode)
|
||||
}
|
||||
}}>
|
||||
<option value="default">默认</option>
|
||||
<option value="plan">计划</option>
|
||||
<option value="auto_edit">自动改文件</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="icon-btn" title="上传附件(文档解析后注入上下文)">
|
||||
<Icon name="paperclip" size={16} /><input type="file" multiple style={{ display: 'none' }} onChange={async e => {
|
||||
for (const f of Array.from(e.target.files ?? [])) {
|
||||
const id = await store.uploadAttachment(f)
|
||||
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
|
||||
}
|
||||
e.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
|
||||
: busy ? '继续输入以排队'
|
||||
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
||||
<textarea ref={inputRef} rows={2} value={text} aria-label="工作目标或消息" placeholder={hasOpenConfirm(cards) ? '有一项操作等你确认,请先查看上方说明'
|
||||
: busy ? '继续补充,新消息会在当前回合结束后发送'
|
||||
: '告诉助手你的目标,或添加需要一起阅读的材料'}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
|
||||
<div className="companion-compose-actions">
|
||||
<label className="companion-add-material" title="添加材料">
|
||||
<Icon name="paperclip" size={16} /><span>添加材料</span><input type="file" multiple className="companion-file-input" aria-label="添加材料" onChange={e => {
|
||||
const files = Array.from(e.currentTarget.files ?? [])
|
||||
e.currentTarget.value = ''
|
||||
void addMaterials(files)
|
||||
}} />
|
||||
</label>
|
||||
<span className="companion-input-hint">Enter 发送 · Shift+Enter 换行</span>
|
||||
<span className="companion-compose-spacer" />
|
||||
{showStop && (
|
||||
<button className="btn danger" onClick={() => store.cancelActive()}>停止</button>
|
||||
<button type="button" className="btn danger" onClick={() => store.cancelActive()}>停止</button>
|
||||
)}
|
||||
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>{busy ? '排队' : '发送'}</button>
|
||||
<button type="button" className="btn" onClick={send} disabled={uploading > 0 || (!text.trim() && attachmentIds.length === 0)}>{busy ? '加入队列' : '发送'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -697,7 +755,7 @@ function RewindControl({ convId, messageId }: { convId: string; messageId: strin
|
||||
try {
|
||||
await api.rewindFiles(convId, messageId)
|
||||
setOpen(false)
|
||||
await store.openTask(convId)
|
||||
if (store.getState().currentConvId === convId) await store.openTask(convId)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ActivityCardView, Collapsible, activitySummary, pendingCount, type ActivityCard } from '@ui'
|
||||
import { store, useStore } from '../store'
|
||||
import { docBridge } from './Workbench'
|
||||
|
||||
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
|
||||
export function ConversationWork() {
|
||||
const cards = useStore(s => s.cards)
|
||||
const conversationId = useStore(s => s.currentConvId)
|
||||
const projectId = useStore(s => s.currentProjectId)
|
||||
const messages = useStore(s => s.messages)
|
||||
const actionStates = useStore(s => s.cardActionStates)
|
||||
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
|
||||
const [summaryOpen, setSummaryOpen] = useState(rightCollapsed)
|
||||
useEffect(() => setSummaryOpen(rightCollapsed), [rightCollapsed])
|
||||
const { current, required, focused, history } = useMemo(() => {
|
||||
const current = cards.filter(card => card.status !== 'dismissed'
|
||||
&& card.conversationId === conversationId && card.projectId === projectId)
|
||||
const required: ActivityCard[] = []
|
||||
const remaining: ActivityCard[] = []
|
||||
for (const card of current) {
|
||||
// A confirmation already visible at its user message has one main action
|
||||
// path in the conversation; assistant-anchored cards still need this path.
|
||||
if (card.kind === 'confirm' && card.anchorMessageId
|
||||
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
|
||||
const summary = activitySummary(card)
|
||||
if (card.status === 'action_required' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
|
||||
else remaining.push(card)
|
||||
}
|
||||
// Preserve the host's newest-first order for completed results and history.
|
||||
const focused = remaining.find(card => card.pinned && card.status === 'active')
|
||||
?? remaining.find(card => card.status === 'active')
|
||||
?? remaining.find(card => card.status === 'done') ?? remaining[0]
|
||||
return { current, required, focused, history: remaining.filter(card => card !== focused) }
|
||||
}, [cards, messages, conversationId, projectId])
|
||||
if (current.length === 0) return null
|
||||
const pending = pendingCount(current)
|
||||
const active = current.filter(card => card.status === 'active').length
|
||||
const results = current.filter(card => card.status === 'done' || card.status === 'failed').length
|
||||
const summary = [pending ? `${pending} 项待处理` : '', active ? `${active} 项工作` : '', results ? `${results} 项结果` : ''].filter(Boolean).join(' · ')
|
||||
const callbacks = {
|
||||
onAction: (card: ActivityCard, actionId: string) => { void store.cardAction(card, actionId) },
|
||||
actionState: (card: ActivityCard) => actionStates?.[card.id],
|
||||
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
|
||||
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
|
||||
onLocate: (card: ActivityCard) => { store.locateMessage(card.anchorMessageId) },
|
||||
onArchive: (card: ActivityCard) => { void store.cardToProject(card) },
|
||||
}
|
||||
const view = (card: ActivityCard) => <ActivityCardView key={card.id} card={card} callbacks={callbacks} docBridge={docBridge} />
|
||||
|
||||
return (
|
||||
<section className="conversation-work" data-conversation-work aria-label="工作进展">
|
||||
<div className="conversation-work-heading"><span>工作进展</span><span>{summary || '已处理的工作'}</span></div>
|
||||
<div className="conversation-work-current">
|
||||
{required.map(view)}
|
||||
{focused && <Collapsible id={`conversation-current:${conversationId}:${focused.id}`} open={summaryOpen} onToggle={setSummaryOpen}
|
||||
triggerClassName="conversation-work-more" trigger={<><span className="process-collapse-chev">▸</span>{focused.title} · 查看摘要</>}>
|
||||
{view(focused)}
|
||||
</Collapsible>}
|
||||
</div>
|
||||
{history.length > 0 && <Collapsible id={`conversation-work:${projectId}:${conversationId}`}
|
||||
triggerClassName="conversation-work-more"
|
||||
trigger={<><span className="process-collapse-chev">▸</span>更多工作与成果 · {history.length} 项</>}>
|
||||
<div className="conversation-work-history">{history.map(view)}</div>
|
||||
</Collapsible>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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,8 +42,9 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
const me = await api.login(owner.trim(), password)
|
||||
if (me.mustChangePassword) {
|
||||
// 初始/重置密码:先完成强制改密再放行。
|
||||
// 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明;
|
||||
// 受限会话先进设密门,完成前不放行。
|
||||
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
||||
onMustChange(me.owner)
|
||||
return
|
||||
}
|
||||
@@ -54,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) }
|
||||
}
|
||||
|
||||
@@ -82,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' ? (
|
||||
<>
|
||||
@@ -98,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>
|
||||
@@ -111,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('')
|
||||
@@ -125,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) }
|
||||
@@ -136,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>
|
||||
)
|
||||
@@ -163,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 '认证失败,请重新登录'
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Artifact } from '../contracts'
|
||||
import { humanSize } from '../messageparts'
|
||||
import { previewTabsKey } from '../previewtabs'
|
||||
import { store, useStore } from '../store'
|
||||
import { surfaceKey } from '../surfaceKey'
|
||||
|
||||
export function PreviewPane() {
|
||||
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
|
||||
@@ -17,7 +18,10 @@ export function PreviewPane() {
|
||||
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
|
||||
const surfaces = useStore(s => s.surfaces)
|
||||
const surfaceSnap = active?.id.startsWith('surface:')
|
||||
? surfaces.find(x => 'surface:' + String(x.identity.surfaceId) === active.id)
|
||||
? surfaces.find(x => {
|
||||
const id = surfaceKey(x)
|
||||
return id != null && 'surface:' + id === active.id
|
||||
})
|
||||
: undefined
|
||||
const art = active ? artifacts.find(a => a.id === active.id) : undefined
|
||||
const textKind = !!art && isTextKind(art)
|
||||
@@ -56,7 +60,7 @@ export function PreviewPane() {
|
||||
</div>
|
||||
<div className="preview-pane-body">
|
||||
{surfaceSnap
|
||||
? <SurfaceView snapshot={surfaceSnap} docBridge={{ onOpenDoc: docId => {
|
||||
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(surfaceKey(surfaceSnap) ?? '', id, {})} docBridge={{ onOpenDoc: docId => {
|
||||
const art = artifacts.find(a => a.id === docId)
|
||||
if (art) store.requestPreview(art)
|
||||
} }} />
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// 工作时间线:思考、对用户的解说、同类工具合并成一行。运行中展开;
|
||||
// 终轮结束后由调用方套进「已工作 X」折叠。
|
||||
// 执行记录按工作动作分组;展开状态由调用方控制。
|
||||
import type { ReactNode } from 'react'
|
||||
import { Collapsible, ToolBlock } from '@ui'
|
||||
import { ToolBlock } from '@ui'
|
||||
import type { Part } from '../contracts'
|
||||
import { formatDuration, humanSize } from '../messageparts'
|
||||
import { timelineEntries, toolGroupLabel, toolTail, type ToolGroupItem } from '../toolkind'
|
||||
import { timelineEntries, toolGroupLabel, type ToolGroupItem } from '../toolkind'
|
||||
|
||||
export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
||||
parts: Part[]
|
||||
@@ -16,20 +15,8 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
||||
return (
|
||||
<div className="tl">
|
||||
{rows.map((row, i) => {
|
||||
if (row.kind === 'thinking') {
|
||||
const dur = formatDuration(row.durationMs)
|
||||
const label = row.active ? '思考中' : (dur ? `思考 · 持续了 ${dur}` : '思考')
|
||||
return (
|
||||
<div className="part-thinking" key={`${idKey}:th:${i}`}>
|
||||
<Collapsible id={`think:${idKey}:${i}:${row.active ? 'live' : 'done'}`} defaultOpen={row.active} key={row.active ? 'open' : 'shut'}
|
||||
triggerClassName="part-thinking-summary"
|
||||
trigger={<><span className="part-thinking-chev">▸</span>
|
||||
<span className={row.active ? 'mk-sweep' : ''}>{label}</span></>}>
|
||||
<div className="part-thinking-body">{row.text}</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// 模型内部思考不是用户工作内容,也不属于可展开的执行记录。
|
||||
if (row.kind === 'thinking') return null
|
||||
if (row.kind === 'text') {
|
||||
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
|
||||
}
|
||||
@@ -70,7 +57,6 @@ function sizeLabel(n: number): string {
|
||||
|
||||
function ToolCallBlock({ item }: { item: ToolGroupItem }) {
|
||||
const composing = item.phase === 'composing'
|
||||
const tail = item.running ? toolTail(item.arguments, item.output) : ''
|
||||
const dur = !item.running ? formatDuration(item.durationMs) : ''
|
||||
const detail = composing ? undefined : (dur ? `${item.headline} · ${dur}` : item.headline)
|
||||
return (
|
||||
@@ -88,7 +74,6 @@ function ToolCallBlock({ item }: { item: ToolGroupItem }) {
|
||||
{item.arguments && <pre className="tool-preview">{item.arguments}</pre>}
|
||||
{item.output && <pre className="tool-preview">{item.output}</pre>}
|
||||
</ToolBlock>
|
||||
{tail && <pre className="tool-live-preview">{tail}</pre>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,86 +1,98 @@
|
||||
// 项目设置面板(提案 2026-09-30-cross-session-reading):首个项目级设置 UI。
|
||||
// schema 驱动渲染 project scope(4.9 覆盖链:system → user → project),
|
||||
// 写 putProjectSettings;后续项目级设置项经 settings schema 自动出现,零开发。
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, type SettingsView } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, Btn, EmptyState } from '@ui'
|
||||
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
|
||||
import { flatten, scopedDraft, scopedSchema, SettingsFields, settingsMutation } from './SettingsFields'
|
||||
import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections'
|
||||
import { rdmProjectReadError } from '../rdmManagement'
|
||||
import { projectActionError, projectWorkError } from '../projectmeta'
|
||||
import { Feedback, useRequestFence, validVersion } from './rdmSettingsShared'
|
||||
|
||||
export function ProjectSettingsModal() {
|
||||
const projectId = useStore(s => s.ui.projectSettingsId)
|
||||
const projects = useStore(s => s.projects)
|
||||
const project = projects.find(p => p.id === projectId)
|
||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||
const project = useStore(s => s.projects.find(project => project.id === projectId))
|
||||
const owner = useStore(s => s.me?.owner)
|
||||
const membersAvailable = useStore(s => s.rdmMembersAvailable)
|
||||
const dutiesAvailable = useStore(s => s.rdmDocReviewAvailable)
|
||||
const permissionsAvailable = useStore(s => s.rdmPermMatrixAvailable)
|
||||
const context = `${owner || ''}:${projectId || ''}`
|
||||
const fence = useRequestFence(context)
|
||||
const [tab, setTab] = useState('project')
|
||||
const [settings, setSettings] = useState<SettingsView | null>(null)
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
setLoaded(false)
|
||||
api.getSettings(projectId).then(s => {
|
||||
setSchema(s.schema)
|
||||
setDraft(flatten(s.project))
|
||||
setLoaded(true)
|
||||
}).catch(() => undefined)
|
||||
}, [projectId])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const g: Record<string, SettingDesc[]> = {}
|
||||
for (const d of schema) (g[d.group] ??= []).push(d)
|
||||
return g
|
||||
}, [schema])
|
||||
|
||||
if (!projectId) return null
|
||||
|
||||
const save = async () => {
|
||||
if (!projectId) return
|
||||
const [childDirty, setChildDirty] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const schema = settings ? scopedSchema(settings.schema, 'project') : []
|
||||
const initial = settings ? scopedDraft(settings.project, schema) : {}
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(initial)
|
||||
const writeError = projectWorkError(project)
|
||||
const canSave = settings?.projectCanConfigure === true && validVersion(settings.projectRevision) && !writeError
|
||||
const load = async () => {
|
||||
if (!projectId) return false
|
||||
const current = fence.begin('load')
|
||||
try {
|
||||
await api.putProjectSettings(projectId, unflatten(draft))
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 1500)
|
||||
} catch (e) {
|
||||
store.toast(`项目设置保存失败:${e}`)
|
||||
}
|
||||
const next = await api.getSettings(projectId)
|
||||
if (!current()) return false
|
||||
setSettings(next); setDraft(scopedDraft(next.project, scopedSchema(next.schema, 'project')))
|
||||
return true
|
||||
} catch (e) { if (current()) setError(`项目设置未能加载:${projectActionError(e)}`); return false }
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
title={`项目设置 · ${project?.name ?? projectId}`}
|
||||
active="project"
|
||||
onSelect={() => undefined}
|
||||
onClose={() => store.openProjectSettings(null)}
|
||||
sections={[{ key: 'project', label: '本项目', icon: 'folder' }]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||||
>
|
||||
<div>
|
||||
{!loaded ? (
|
||||
<EmptyState icon="settings">加载中…</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="empty-note" style={{ marginBottom: 10 }}>
|
||||
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
|
||||
</div>
|
||||
{Object.entries(groups).map(([g, descs]) => (
|
||||
<div className="settings-group" key={g}>
|
||||
<h4>{groupNames[g] ?? g}</h4>
|
||||
{descs.map(d => (
|
||||
<div className="setting-row" key={d.key}>
|
||||
<label title={d.key}>{d.label}</label>
|
||||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||||
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
|
||||
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsShell>
|
||||
)
|
||||
useEffect(() => {
|
||||
fence.invalidate(); setSettings(null); setDraft({}); setTab('project'); setChildDirty(false)
|
||||
setError(''); setNotice(''); setBusy(false)
|
||||
void load()
|
||||
}, [context])
|
||||
const dirtyChange = useCallback((value: boolean) => setChildDirty(value), [])
|
||||
const depart = () => !(dirty || childDirty) || window.confirm('存在未保存的设置,离开后将丢弃这些改动。继续?')
|
||||
useEffect(() => {
|
||||
if (!dirty && !childDirty) return
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault() }
|
||||
window.addEventListener('beforeunload', beforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', beforeUnload)
|
||||
}, [dirty, childDirty])
|
||||
const save = async () => {
|
||||
if (!projectId || !canSave || !settings || !validVersion(settings.projectRevision) || busy) return
|
||||
const current = fence.begin('save')
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
try {
|
||||
const mutation = settingsMutation(initial, draft, schema)
|
||||
await api.putProjectSettings(projectId, mutation.values, settings.projectRevision, mutation.unset)
|
||||
if (current() && await load()) setNotice('已保存共享项目配置;按各项标注的生效时机应用')
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
const message = String(e).includes('409') ? '项目设置已被其他成员更新;请核对最新设置后重新调整' : `保存结果未能确认:${projectActionError(e)};请核对最新设置后再保存`
|
||||
await load()
|
||||
if (current()) setError(message)
|
||||
} finally { if (current()) setBusy(false) }
|
||||
}
|
||||
if (!projectId) return null
|
||||
const rdmTab = ['members', 'duties', 'matrix'].includes(tab)
|
||||
const readError = rdmProjectReadError(project)
|
||||
return <SettingsShell title={project?.name || '当前项目'} ariaLabel={`项目设置 · ${project?.name || '当前项目'}`} active={tab}
|
||||
onSelect={next => { if (!busy && depart()) { if (dirty) setDraft(initial); setChildDirty(false); setNotice(''); setTab(next) } }}
|
||||
onClose={() => { if (depart()) { fence.invalidate(); store.openProjectSettings(null) } }}
|
||||
sections={[
|
||||
{ key: 'project', label: '共享项目配置', icon: 'folder' },
|
||||
...(membersAvailable ? [{ key: 'members', label: '项目成员', icon: 'users' as const }] : []),
|
||||
...(dutiesAvailable ? [{ key: 'duties', label: '文档职责', icon: 'shield' as const }] : []),
|
||||
...(permissionsAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
|
||||
]}
|
||||
footer="项目配置由成员共享;个人项目偏好在个人设置中调整"
|
||||
actions={!rdmTab && settings && schema.length > 0 ? <Btn variant="primary" icon="check" disabled={!canSave || busy || !dirty} onClick={save}>保存项目设置</Btn> : undefined}>
|
||||
{rdmTab && readError ? <EmptyState icon="folder">{readError}</EmptyState> : project && <>
|
||||
{tab === 'members' && <RdmMembersSection key={context} project={project} />}
|
||||
{tab === 'duties' && <RdmDutiesSection key={context} project={project} onDirtyChange={dirtyChange} />}
|
||||
{tab === 'matrix' && <RdmPermMatrixSection key={context} project={project} />}
|
||||
</>}
|
||||
{!rdmTab && <>
|
||||
<p className="ad-hint">此处配置项目共享的业务规则。模型、工具与系统插件由系统管理员配置;个人上下文、检索与会话偏好在个人设置中调整</p>
|
||||
{!settings ? <><EmptyState icon="settings">{error || '加载项目设置…'}</EmptyState>{error && <Btn onClick={() => { setError(''); void load() }}>重试</Btn>}</>
|
||||
: schema.length === 0 ? <EmptyState icon="settings">当前没有可配置的共享项目设置</EmptyState>
|
||||
: <SettingsFields schema={schema} draft={draft} defaults={flatten(settings.defaults)} disabled={!canSave || busy} source="本项目共享配置" onChange={(key, value) => { setNotice(''); setDraft(previous => ({ ...previous, [key]: value })) }} />}
|
||||
{settings && !canSave && <p className="ad-hint">{writeError || (validVersion(settings.projectRevision) ? '你可以查看共享配置;保存需要项目设置管理权限' : '共享配置版本尚未就绪,暂不能保存')}</p>}
|
||||
<Feedback error={error} notice={notice} />
|
||||
</>}
|
||||
</SettingsShell>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。
|
||||
// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。
|
||||
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
|
||||
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/详情的精简前端契约) ----
|
||||
|
||||
interface InspectProject { projectId: string; name: string; canAudit: boolean }
|
||||
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
|
||||
interface CoverageItem { face: string; reason: string }
|
||||
interface ListShell<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
|
||||
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
|
||||
interface TreeItem { kind: string; id: string; title: string; state?: string }
|
||||
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
|
||||
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
|
||||
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
|
||||
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
|
||||
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
|
||||
|
||||
// ---- 工作区主组件 ----
|
||||
|
||||
const TABS = [
|
||||
{ id: 'summary', label: '概览' },
|
||||
{ id: 'materials', label: '资料与文档' },
|
||||
{ id: 'trace', label: '关联追溯' },
|
||||
{ id: 'timeline', label: '操作记录' },
|
||||
] as const
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
/** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断;
|
||||
* 「无可审计项目」由调用处独立空态承担,不走本组件。 */
|
||||
function RdmErrorState({ raw }: { raw: string }) {
|
||||
const c = classifyRdmError(raw)
|
||||
return (
|
||||
<div className="rdm-audit-err">
|
||||
<EmptyState icon="alert">{c.message}</EmptyState>
|
||||
<Collapsible id="rdm-audit-err-receipt" trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
|
||||
<code className="rdm-audit-err-raw">{raw}</code>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 || 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 || capsIdentity !== identity) return <div className="empty-note">加载中…</div>
|
||||
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
|
||||
|
||||
return (
|
||||
<div className="rdm-audit">
|
||||
<div className="rdm-audit-header">
|
||||
<select className="ui-input rdm-audit-project" value={pid} onChange={e => setPid(e.target.value)} aria-label="选择项目">
|
||||
{caps.projects.map(p => (
|
||||
<option key={p.projectId} value={p.projectId}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{project?.canAudit && <Chip tone="ok">可审计</Chip>}
|
||||
{caps.crossProject && <Chip tone="info">跨项目</Chip>}
|
||||
</div>
|
||||
<div className="tabs rdm-audit-tabs" role="tablist">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} role="tab" aria-selected={tab === t.id}
|
||||
className={`tab${tab === t.id ? ' active' : ''}`}
|
||||
onClick={() => setTab(t.id)}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rdm-audit-body">
|
||||
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
|
||||
{tab === 'materials' && <MaterialsTab pid={pid} />}
|
||||
{tab === 'trace' && <TraceTab pid={pid} />}
|
||||
{tab === 'timeline' && <TimelineTab pid={pid} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 概览 ----
|
||||
|
||||
function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) {
|
||||
const [data, setData] = useState<SummaryData | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
useEffect(() => {
|
||||
let c = false
|
||||
setData(null); setErr('')
|
||||
api.rdmCommand<SummaryData>('rdm.inspect.summary', { projectId: pid })
|
||||
.then(d => { if (!c) setData(d) })
|
||||
.catch(e => { if (!c) setErr(e.message) })
|
||||
return () => { c = true }
|
||||
}, [pid])
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
if (!data) return <div className="empty-note">加载中…</div>
|
||||
const counts = data.counts || {}
|
||||
const entries: [string, string, number][] = [
|
||||
['切片', 'slices', counts.slices ?? 0],
|
||||
['章节', 'sections', counts.sections ?? 0],
|
||||
['文档', 'docs', counts.docs ?? 0],
|
||||
['生效边', 'edgesActive', counts.edgesActive ?? 0],
|
||||
['待确认边', 'edgesCandidate', counts.edgesCandidate ?? 0],
|
||||
['结论', 'conclusions', counts.conclusions ?? 0],
|
||||
['待办', 'openItems', counts.openItems ?? 0],
|
||||
['挂起上传', 'uploadsHeld', counts.uploadsHeld ?? 0],
|
||||
['空缺章节', 'emptySections', counts.emptySections ?? 0],
|
||||
]
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>统计(截至水位 {data.watermark})</SectionLabel>
|
||||
<div className="rdm-audit-cards">
|
||||
{entries.map(([label, , val]) => (
|
||||
<div key={label} className="rdm-audit-card">
|
||||
<div className="rdm-audit-card-num">{val}</div>
|
||||
<div className="rdm-audit-card-label">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.lastOpAt && <div className="rdm-audit-meta">最近动作:{new Date(data.lastOpAt).toLocaleString('zh-CN')}</div>}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 资料与文档 ----
|
||||
|
||||
function MaterialsTab({ pid }: { pid: string }) {
|
||||
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
|
||||
return (
|
||||
<div className="rdm-audit-materials">
|
||||
<div className="rdm-audit-materials-side">
|
||||
<div className="tabs">
|
||||
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
||||
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
||||
</div>
|
||||
<TreeList pid={pid} kind={kind} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||
const [detail, setDetail] = useState<ObjectDetail | null>(null)
|
||||
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
|
||||
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
|
||||
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
|
||||
const genRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const gen = ++genRef.current
|
||||
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setShell(s) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, kind])
|
||||
|
||||
const loadMore = () => {
|
||||
if (!shell?.nextCursor) return
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: shell.nextCursor })
|
||||
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...s.items] } : s) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const toggleDoc = (item: TreeItem) => {
|
||||
if (children[item.id]) {
|
||||
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
|
||||
return
|
||||
}
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const selectNode = (item: TreeItem) => {
|
||||
if (item.kind === 'doc') { toggleDoc(item); return }
|
||||
setSelected(item); setDetail(null)
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||
.then(d => { if (gen === genRef.current) setDetail(d) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
if (!shell) return <div className="empty-note">加载中…</div>
|
||||
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-tree">
|
||||
<div className="rdm-audit-tree-list">
|
||||
{shell.items.map(item => (
|
||||
<div key={item.id}>
|
||||
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(item)}>
|
||||
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
|
||||
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
||||
</button>
|
||||
{item.kind === 'doc' && children[item.id] && (
|
||||
<div className="rdm-audit-tree-children">
|
||||
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
|
||||
{children[item.id].map(sub => (
|
||||
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(sub)}>
|
||||
<span className="rdm-audit-tree-kind">{sub.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{sub.title}</span>
|
||||
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
|
||||
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="rdm-audit-detail">
|
||||
<SectionLabel>对象详情</SectionLabel>
|
||||
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 关联追溯 ----
|
||||
|
||||
function TraceTab({ pid }: { pid: string }) {
|
||||
const [sel, setSel] = useState<{ kind: string; id: string; title: string } | null>(null)
|
||||
const [relations, setRelations] = useState<RelationEntry[] | null>(null)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const genRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sel) return
|
||||
const gen = ++genRef.current
|
||||
setRelations(null); setErr('')
|
||||
api.rdmCommand<ListShell<RelationEntry>>('rdm.inspect.relations', { projectId: pid, kind: sel.kind, id: sel.id, depth: 1 })
|
||||
.then(s => { if (gen === genRef.current) { setRelations(s.items); setTruncated(s.truncated) } })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, sel])
|
||||
|
||||
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
|
||||
// (parent=<docId>),按文档聚合后合并展示。
|
||||
const [sections, setSections] = useState<TreeItem[]>([])
|
||||
useEffect(() => {
|
||||
if (sel) return
|
||||
let c = false
|
||||
;(async () => {
|
||||
const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
|
||||
const secs: TreeItem[] = []
|
||||
for (const d of top.items.filter(i => i.kind === 'doc')) {
|
||||
const sub = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id })
|
||||
secs.push(...sub.items.filter(i => i.kind === 'section'))
|
||||
}
|
||||
if (!c) setSections(secs)
|
||||
})().catch(() => {})
|
||||
return () => { c = true }
|
||||
}, [pid, sel])
|
||||
|
||||
if (!sel) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>选择对象查看来源与影响</SectionLabel>
|
||||
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
|
||||
<div className="rdm-audit-pick">
|
||||
{sections.map(s => (
|
||||
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
|
||||
{s.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sources = relations?.filter(r => r.to?.id === sel.id && r.to?.kind === sel.kind) ?? []
|
||||
const impacts = relations?.filter(r => r.from?.id === sel.id && r.from?.kind === sel.kind) ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rdm-audit-trace-head">
|
||||
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
|
||||
<strong>{sel.title}</strong>
|
||||
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
|
||||
</div>
|
||||
{err && <RdmErrorState raw={err} />}
|
||||
{!relations && !err && <div className="empty-note">加载中…</div>}
|
||||
{relations && relations.length === 0 && <EmptyState icon="folder">没有已知关联</EmptyState>}
|
||||
{relations && relations.length > 0 && (
|
||||
<div className="rdm-audit-trace-cols">
|
||||
<div className="rdm-audit-trace-col">
|
||||
<SectionLabel>来源依据({sources.length})</SectionLabel>
|
||||
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
</div>
|
||||
<div className="rdm-audit-trace-col">
|
||||
<SectionLabel>影响对象({impacts.length})</SectionLabel>
|
||||
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationRow({ r }: { r: RelationEntry }) {
|
||||
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
|
||||
return (
|
||||
<div className="rdm-audit-rel">
|
||||
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
|
||||
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
|
||||
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
|
||||
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 操作记录(事件时间线) ----
|
||||
|
||||
function TimelineTab({ pid }: { pid: string }) {
|
||||
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
|
||||
const [cursor, setCursor] = useState('')
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [filterOp, setFilterOp] = useState('')
|
||||
const [filterActor, setFilterActor] = useState('')
|
||||
const [historyMode, setHistoryMode] = useState(false)
|
||||
const [historySeq, setHistorySeq] = useState(0)
|
||||
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
|
||||
const genRef = useRef(0)
|
||||
|
||||
const load = useCallback((cur: string, replace: boolean) => {
|
||||
const gen = ++genRef.current
|
||||
if (replace) { setItems([]); setErr('') }
|
||||
const params: Record<string, unknown> = { projectId: pid, cursor: cur, limit: 30 }
|
||||
if (filterOp) params.op = filterOp
|
||||
if (filterActor) params.actor = filterActor
|
||||
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
|
||||
.then(s => {
|
||||
if (gen !== genRef.current) return
|
||||
setItems(prev => replace ? s.items : [...prev, ...s.items])
|
||||
setCursor(s.nextCursor || '')
|
||||
setHasMore(s.hasMore)
|
||||
})
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, filterOp, filterActor])
|
||||
|
||||
useEffect(() => { if (!historyMode) load('', true) }, [load, historyMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyMode) { setHistoryData(null); return }
|
||||
const gen = genRef.current
|
||||
setHistoryData(null)
|
||||
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
|
||||
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
|
||||
.then(d => { if (gen === genRef.current) setHistoryData(d) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, historyMode, historySeq])
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-timeline">
|
||||
<div className="rdm-audit-timeline-filters">
|
||||
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
|
||||
onChange={e => setFilterActor(e.target.value)} />
|
||||
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
|
||||
onChange={e => setFilterOp(e.target.value)} />
|
||||
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
|
||||
{historyMode ? '← 返回当前' : '历史模式'}
|
||||
</button>
|
||||
{historyMode && (
|
||||
<input className="ui-input rdm-audit-seq" type="number" placeholder="截止 seq…"
|
||||
value={historySeq || ''} onChange={e => setHistorySeq(Number(e.target.value) || 0)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{historyMode ? (
|
||||
<div>
|
||||
{!historyData ? <div className="empty-note">加载中…</div> : (
|
||||
<div>
|
||||
<SectionLabel>截至 seq {historyData.atSeq}(当前水位 {historyData.watermark})</SectionLabel>
|
||||
{historyData.countOnlySnapshots ? <div className="rdm-audit-truncated">含 {historyData.countOnlySnapshots} 个旧计数快照(不可作恢复依据)</div> : null}
|
||||
{historyData.formalMissing ? <div className="empty-note">{historyData.formalMissing}</div> : null}
|
||||
{historyData.object ? (
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div>标题:{historyData.object.title}</div>
|
||||
<div>状态:{historyData.object.state}</div>
|
||||
{historyData.object.contentPreview && <div>内容预览:{historyData.object.contentPreview}</div>}
|
||||
</div>
|
||||
) : historyData.empty ? <EmptyState icon="folder">该时点无事件</EmptyState> :
|
||||
<div className="empty-note">输入对象 kind:id 查看历史快照</div>}
|
||||
{historyData.formal && (
|
||||
<div className="rdm-audit-formal">
|
||||
正式版 R{historyData.formal.rev}(内容 v{historyData.formal.seq})——{historyData.formal.actor} 于 {new Date(historyData.formal.at).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{err && <RdmErrorState raw={err} />}
|
||||
{!err && items.length === 0 && <EmptyState icon="clock">暂无事件</EmptyState>}
|
||||
{items.map((item, i) => {
|
||||
if ('events' in item && Array.isArray((item as OpGroup).events)) {
|
||||
return <OpGroupView key={item.operationId || i} group={item as OpGroup} />
|
||||
}
|
||||
return <EventRow key={(item as EventDTO).seq} ev={item as EventDTO} />
|
||||
})}
|
||||
{hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => load(cursor, false)}>加载更多</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OpGroupView({ group }: { group: OpGroup }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div className="rdm-audit-opgroup">
|
||||
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
|
||||
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
|
||||
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
|
||||
<Chip tone="plain">{group.events.length} 个事件</Chip>
|
||||
</button>
|
||||
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ ev }: { ev: EventDTO }) {
|
||||
return (
|
||||
<div className="rdm-audit-event">
|
||||
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
|
||||
<span className="rdm-audit-event-op">{ev.op}</span>
|
||||
<span className="rdm-audit-event-actor">{ev.actor}</span>
|
||||
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
|
||||
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 对象详情视图 ----
|
||||
|
||||
function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
|
||||
return (
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div><strong>{detail.title || detail.id}</strong></div>
|
||||
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
|
||||
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
|
||||
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
|
||||
{detail.versions && detail.versions.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>版本链({detail.versions.length})</SectionLabel>
|
||||
{detail.versions.map((v: any, i: number) => (
|
||||
<div key={i} className="rdm-audit-version">
|
||||
v{v.seq} · {v.actor} · {new Date(v.at).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detail.formal && (
|
||||
<div className="rdm-audit-formal">
|
||||
正式版 R{detail.formal.rev}(内容 v{detail.formal.seq})——{detail.formal.actor} 于 {new Date(detail.formal.at).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
)}
|
||||
{detail.relations && detail.relations.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
|
||||
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
</div>
|
||||
)}
|
||||
{detail.recentEvents && detail.recentEvents.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>最近事件</SectionLabel>
|
||||
{detail.recentEvents.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 覆盖范围标注(不冒充完整) ----
|
||||
|
||||
function CoverageList({ cov }: { cov: CoverageItem[] }) {
|
||||
return (
|
||||
<div className="rdm-audit-coverage">
|
||||
<SectionLabel>覆盖范围</SectionLabel>
|
||||
{cov.map(c => (
|
||||
<div key={c.face} className="rdm-audit-coverage-item">
|
||||
<Chip tone="plain">{c.face}</Chip>
|
||||
<span>{c.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Btn, Collapsible, Picker, SettingRow, SettingsGroup, type PickPage } from '@ui'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { assignDocDutiesPayload, docApprovePayload, docFinalPayload, docRejectPayload, docSubmitPayload } from '../rdmManagement'
|
||||
import { Feedback, personItem, projectContext, readableError, RoleFilter, selectedPeople,
|
||||
useRequestFence, useRoleOptions, validVersion, type MemberList, type Person } from './rdmSettingsShared'
|
||||
|
||||
interface Duties { author: string; reviewers: string[]; approver: string }
|
||||
type WireDuties = Partial<Duties> & { Author?: string; Reviewers?: string[]; Approver?: string }
|
||||
export function normalizeDuties(value?: WireDuties | null): Duties {
|
||||
return { author: value?.author ?? value?.Author ?? '', reviewers: value?.reviewers ?? value?.Reviewers ?? [], approver: value?.approver ?? value?.Approver ?? '' }
|
||||
}
|
||||
const blank = (): Duties => ({ author: '', reviewers: [], approver: '' })
|
||||
const sameDuties = (left: Duties, right: Duties) => left.author === right.author && left.approver === right.approver
|
||||
&& [...left.reviewers].sort().join('\u0000') === [...right.reviewers].sort().join('\u0000')
|
||||
export function dutyValidation(duties: Duties): string {
|
||||
if (!duties.author || !duties.approver || duties.reviewers.length === 0) return '请选择编制人、至少一位审核人和批准人'
|
||||
if (duties.author === duties.approver || duties.reviewers.includes(duties.author) || duties.reviewers.includes(duties.approver)) return '编制、审核、批准须由不同成员承担'
|
||||
return ''
|
||||
}
|
||||
interface CatalogDoc {
|
||||
docId: string; title: string; typeKey: string; typeLabel?: string; stage?: string; stageLabel?: string
|
||||
sections: number; assigned: boolean; author?: string; reviewers?: string[]; approver?: string
|
||||
activeRound?: number; activeSections?: number; status?: string; statusLabel?: string; dutyVersion?: number
|
||||
}
|
||||
interface DutyTreeNode {
|
||||
id: string; kind: 'stage' | 'type' | 'doc' | 'section'; label: string; selectable: boolean
|
||||
docId?: string; sectionId?: string; children?: DutyTreeNode[]
|
||||
}
|
||||
interface Catalog {
|
||||
docs: CatalogDoc[]; next?: string; count?: number; total?: number; canAssignDuties?: boolean
|
||||
filters?: { stages?: { value: string; label: string }[]; typeKeys?: { value: string; label: string }[]; statuses?: { value: string; label: string }[]; assignments?: { value: string; label: string }[] }
|
||||
}
|
||||
interface ActiveDuty { round?: number; state?: string; createdBy?: string; duties?: WireDuties; cardId?: string }
|
||||
interface Chapter {
|
||||
sectionId: string; title: string; roles: WireDuties; dutyVersion?: number; overridden: boolean; active?: ActiveDuty | null
|
||||
}
|
||||
interface DutyDetail {
|
||||
docId: string; title: string; assigned: boolean; roles: WireDuties; dutyVersion?: number
|
||||
sections: Chapter[]; active?: ActiveDuty | null; canAssignDuties?: boolean
|
||||
}
|
||||
/** A default change affects whole-document review and only chapters inheriting that default. */
|
||||
export function documentAssignmentImpact(document: Pick<DutyDetail, 'roles' | 'active' | 'sections'>, duties: Duties): boolean {
|
||||
if (sameDuties(normalizeDuties(document.roles), duties)) return false
|
||||
return !!document.active || document.sections.some(section => !section.overridden && !!section.active
|
||||
&& !sameDuties(normalizeDuties(section.roles), duties))
|
||||
}
|
||||
interface Submission {
|
||||
round: number; state: string; duties: WireDuties; reviews: Record<string, boolean>; createdBy: string
|
||||
}
|
||||
const statusLabel = (state?: string) => ({ submitted: '审核中', final: '已批准', rejected: '已驳回', invalidated: '职责变更,待重新提交', 'in-review': '审核中', approved: '已批准', draft: '编写中', unassigned: '未分配' }[state || ''] || '暂无审签')
|
||||
|
||||
/** Only document nodes may become a document selection. Chapters remain visible context. */
|
||||
export function DutyTree({ nodes, selected, onSelect }: { nodes: DutyTreeNode[]; selected: string; onSelect: (docId: string) => void }) {
|
||||
return <ul className="rdm-duty-tree">{nodes.map(node => <li key={node.id}>
|
||||
{node.kind === 'doc' && node.selectable === true && node.docId
|
||||
? <button className={`ad-mini${selected === node.docId ? ' ad-row-on' : ''}`} onClick={() => onSelect(node.docId!)}>{node.label}</button>
|
||||
: <span className="ad-dim">{node.label}{node.kind === 'section' ? ' · 章节' : ''}</span>}
|
||||
{!!node.children?.length && <Collapsible trigger={`展开 ${node.label}`} defaultOpen={node.kind === 'stage' || node.kind === 'type'}>
|
||||
<DutyTree nodes={node.children} selected={selected} onSelect={onSelect} />
|
||||
</Collapsible>}
|
||||
</li>)}</ul>
|
||||
}
|
||||
function mergeTree(previous: DutyTreeNode[], incoming: DutyTreeNode[]): DutyTreeNode[] {
|
||||
const merged = new Map(previous.map(node => [node.id, node]))
|
||||
for (const node of incoming) {
|
||||
const old = merged.get(node.id)
|
||||
merged.set(node.id, old ? { ...node, children: mergeTree(old.children || [], node.children || []) } : node)
|
||||
}
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
function DutyPeople({ value, onChange, fetchPage, people, disabled, labelPrefix = '' }: {
|
||||
value: Duties; onChange: (value: Duties) => void; fetchPage: (query: string, cursor: string, limit: number, purpose: 'author' | 'reviewer' | 'approver') => Promise<PickPage>
|
||||
people: Person[]; disabled: boolean; labelPrefix?: string
|
||||
}) {
|
||||
const authorPage = useCallback((query: string, cursor: string, limit: number) => fetchPage(query, cursor, limit, 'author'), [fetchPage])
|
||||
const reviewerPage = useCallback((query: string, cursor: string, limit: number) => fetchPage(query, cursor, limit, 'reviewer'), [fetchPage])
|
||||
const approverPage = useCallback((query: string, cursor: string, limit: number) => fetchPage(query, cursor, limit, 'approver'), [fetchPage])
|
||||
return <>
|
||||
<SettingRow label={`${labelPrefix}编制人`} desc="单选项目成员">
|
||||
<Picker aria-label={`${labelPrefix}编制人`} selected={value.author ? [value.author] : []} selectedItems={selectedPeople([value.author].filter(Boolean), people)}
|
||||
onSelect={ids => onChange({ ...value, author: ids[0] || '' })} fetchPage={authorPage} disabled={disabled} />
|
||||
</SettingRow>
|
||||
<SettingRow label={`${labelPrefix}审核人`} desc="至少选择一位,与编制人和批准人互异">
|
||||
<Picker aria-label={`${labelPrefix}审核人`} multiple selected={value.reviewers} selectedItems={selectedPeople(value.reviewers, people)}
|
||||
onSelect={reviewers => onChange({ ...value, reviewers })} fetchPage={reviewerPage} disabled={disabled} />
|
||||
</SettingRow>
|
||||
<SettingRow label={`${labelPrefix}批准人`} desc="单选项目成员">
|
||||
<Picker aria-label={`${labelPrefix}批准人`} selected={value.approver ? [value.approver] : []} selectedItems={selectedPeople([value.approver].filter(Boolean), people)}
|
||||
onSelect={ids => onChange({ ...value, approver: ids[0] || '' })} fetchPage={approverPage} disabled={disabled} />
|
||||
</SettingRow>
|
||||
</>
|
||||
}
|
||||
|
||||
export function RdmDutiesSection({ project, onDirtyChange }: { project: Project; onDirtyChange?: (dirty: boolean) => void }) {
|
||||
const me = useStore(s => s.me)
|
||||
const context = projectContext(project, me?.owner)
|
||||
const fence = useRequestFence(context)
|
||||
const writeError = projectWorkError(project)
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null)
|
||||
const [tree, setTree] = useState<DutyTreeNode[] | null>(null)
|
||||
const [view, setView] = useState<'list' | 'tree'>('list')
|
||||
const [query, setQuery] = useState('')
|
||||
const [match, setMatch] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [stage, setStage] = useState('')
|
||||
const [typeKey, setTypeKey] = useState('')
|
||||
const [assignment, setAssignment] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [roleId, setRoleId] = useState('')
|
||||
const [members, setMembers] = useState<MemberList | null>(null)
|
||||
const [docId, setDocId] = useState('')
|
||||
const [sectionId, setSectionId] = useState('')
|
||||
const [detail, setDetail] = useState<DutyDetail | null>(null)
|
||||
const [draft, setDraft] = useState<Duties>(blank)
|
||||
const [sub, setSub] = useState<Submission | null | undefined>(undefined)
|
||||
const [selection, setSelection] = useState<Set<string>>(new Set())
|
||||
const [batchDraft, setBatchDraft] = useState<Duties>(blank)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const { roles, error: roleError } = useRoleOptions(project, 'duties', members?.canAssignDuties === true)
|
||||
const selectedRef = useRef('')
|
||||
const memberNames = useRef(new Map<string, Person>())
|
||||
const canAssign = catalog?.canAssignDuties === true && members?.canAssignDuties === true && !writeError
|
||||
const canEditCurrent = canAssign && detail?.canAssignDuties === true
|
||||
const currentChapter = detail?.sections.find(section => section.sectionId === sectionId)
|
||||
const original = normalizeDuties(sectionId ? currentChapter?.roles : detail?.roles)
|
||||
const currentVersion = sectionId ? currentChapter?.dutyVersion : detail?.dutyVersion
|
||||
const active = sectionId ? currentChapter?.active || detail?.active : detail?.active
|
||||
const dirty = !!detail && !sameDuties(draft, original)
|
||||
const batchDirty = selection.size > 0 && (batchDraft.author !== '' || batchDraft.approver !== '' || batchDraft.reviewers.length > 0)
|
||||
useEffect(() => { onDirtyChange?.(dirty || batchDirty); return () => onDirtyChange?.(false) }, [dirty, batchDirty, onDirtyChange])
|
||||
|
||||
const filters = { query, match, stage, typeKey, assignment, status }
|
||||
const clearDetail = () => {
|
||||
fence.cancel('detail'); fence.cancel('status'); fence.cancel('mutation'); fence.cancel('batch-preview')
|
||||
selectedRef.current = ''; setDocId(''); setSectionId(''); setDetail(null); setDraft(blank()); setSub(undefined)
|
||||
setSelection(new Set()); setBatchDraft(blank()); setNotice(''); setBusy(false)
|
||||
}
|
||||
const depart = () => !(dirty || batchDirty) || window.confirm('有未保存的职责调整,离开后将丢弃这些改动。继续?')
|
||||
const changeFilter = (change: () => void) => { if (depart()) { fence.cancel('catalog'); clearDetail(); setCatalog(null); setTree(null); setError(''); change() } }
|
||||
|
||||
const loadCatalog = async (cursor = '') => {
|
||||
const current = fence.begin('catalog')
|
||||
setLoading(true)
|
||||
try {
|
||||
const out = await api.rdmProjectCommand<Catalog>(project, 'rdm.duty.catalog', { ...filters, cursor, limit: 30 })
|
||||
if (!current()) return false
|
||||
if (!Array.isArray(out.docs) || typeof out.canAssignDuties !== 'boolean') throw new Error('文档目录契约尚未就绪,请升级项目服务')
|
||||
setCatalog(prev => cursor && prev ? { ...out, docs: [...prev.docs, ...out.docs] } : out)
|
||||
if (view === 'tree') {
|
||||
const treeOut = await api.rdmProjectCommand<{ nodes: DutyTreeNode[]; next?: string }>(project, 'rdm.duty.tree', { ...filters, cursor, limit: 30 })
|
||||
if (!current()) return false
|
||||
if (!Array.isArray(treeOut.nodes)) throw new Error('文档树接口尚未就绪')
|
||||
setTree(previous => cursor ? mergeTree(previous || [], treeOut.nodes) : treeOut.nodes)
|
||||
}
|
||||
return true
|
||||
} catch (e) { if (current()) { setError(readableError(e)); if (!cursor) { setCatalog(null); setTree(null) } } return false }
|
||||
finally { if (current()) setLoading(false) }
|
||||
}
|
||||
const loadDetail = async (id: string) => {
|
||||
const current = fence.begin('detail')
|
||||
const statusCurrent = fence.begin('status')
|
||||
selectedRef.current = id; setDocId(id); setSectionId(''); setDetail(null); setDraft(blank()); setSub(undefined); setError('')
|
||||
try {
|
||||
const duty = await api.rdmProjectCommand<DutyDetail>(project, 'rdm.duty.get', { docId: id })
|
||||
if (!current() || selectedRef.current !== id) return false
|
||||
if (duty.docId !== id || !Array.isArray(duty.sections)) throw new Error('职责结果与所选文档不一致')
|
||||
setDetail(duty); setDraft(normalizeDuties(duty.roles))
|
||||
} catch (e) {
|
||||
if (current()) setError(readableError(e))
|
||||
return false
|
||||
}
|
||||
if (!current() || !statusCurrent()) return false
|
||||
try {
|
||||
const output = await api.rdmProjectCommand<{ submission: Submission | null }>(project, 'rdm.review.doc.status', { docId: id })
|
||||
if (!current() || !statusCurrent() || selectedRef.current !== id) return false
|
||||
setSub(output.submission)
|
||||
return true
|
||||
} catch (e) {
|
||||
if (current() && statusCurrent()) setError(`整文审签状态读取失败:${readableError(e)}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const selectDoc = (id: string) => {
|
||||
if (!depart()) return
|
||||
fence.cancel('mutation'); setBusy(false); setSelection(new Set()); setBatchDraft(blank()); setNotice('')
|
||||
void loadDetail(id)
|
||||
}
|
||||
useEffect(() => {
|
||||
fence.invalidate(); clearDetail(); setCatalog(null); setTree(null); setMembers(null); memberNames.current.clear()
|
||||
setQuery(''); setStage(''); setTypeKey(''); setAssignment(''); setStatus(''); setRoleId(''); setError('')
|
||||
const current = fence.begin('members')
|
||||
api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { limit: 50 })
|
||||
.then(out => { if (current()) { setMembers(out); for (const person of out.members ?? []) memberNames.current.set(person.owner, person) } })
|
||||
.catch(e => { if (current()) setError(`成员候选无法读取:${readableError(e)}`) })
|
||||
}, [context])
|
||||
useEffect(() => {
|
||||
fence.cancel('catalog')
|
||||
const timer = setTimeout(() => { void loadCatalog() }, query ? 250 : 0)
|
||||
return () => { clearTimeout(timer); fence.cancel('catalog') }
|
||||
}, [context, query, match, stage, typeKey, assignment, status, view])
|
||||
|
||||
const memberPage = useCallback(async (query: string, cursor: string, limit: number, purpose: 'author' | 'reviewer' | 'approver'): Promise<PickPage> => {
|
||||
const current = fence.capture()
|
||||
const out = await api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { query, cursor, limit, roleId })
|
||||
if (!current()) return { items: [] }
|
||||
if (!Array.isArray(out.members) || out.accountDirectoryAvailable !== true) throw new Error('项目成员账号状态尚未就绪')
|
||||
return { items: out.members.map(person => {
|
||||
memberNames.current.set(person.owner, person)
|
||||
const item = personItem(person)
|
||||
const eligibility = person.dutyEligibility?.[purpose]
|
||||
return eligibility?.allowed === false ? { ...item, disabled: true, disabledReason: eligibility.reason || '当前成员暂不具备该职责资格' } : item
|
||||
}), nextCursor: out.next || undefined }
|
||||
}, [project, roleId, context])
|
||||
|
||||
const run = async (message: string, mutation: () => Promise<unknown>, id = selectedRef.current) => {
|
||||
const current = fence.begin('mutation')
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
try {
|
||||
await mutation()
|
||||
if (!current()) return
|
||||
const catalogVerified = await loadCatalog()
|
||||
if (!current()) return false
|
||||
const detailVerified = !id || await loadDetail(id)
|
||||
if (current() && catalogVerified && detailVerified) { setNotice(message); return true }
|
||||
return false
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
setError(`${readableError(e)};请核对最新状态后再操作`)
|
||||
// Outcome may be unknown after disconnection. Reconcile, never retry a write automatically.
|
||||
if (id) await loadDetail(id)
|
||||
if (current()) setError(`${readableError(e)};已尝试读取最新状态,未自动重试写入`)
|
||||
return false
|
||||
} finally { if (current()) setBusy(false) }
|
||||
}
|
||||
const save = () => {
|
||||
if (!canEditCurrent || !detail || !validVersion(currentVersion) || !dirty || dutyValidation(draft) || busy) return
|
||||
const affected = sectionId ? !!active : documentAssignmentImpact(detail, draft)
|
||||
const confirmed = affected ? window.confirm(`「${detail.title}」${active?.round ? `第 ${active.round} 轮` : '相关章节'}审签将失效,需重新提交。确认职责变更?`) : false
|
||||
if (affected && !confirmed) return
|
||||
const payload = assignDocDutiesPayload(project.id, detail.docId, draft.author, draft.reviewers, draft.approver, currentVersion, confirmed)
|
||||
void run('职责已保存,已重新读取当前状态', () => api.rdmProjectCommand(project,
|
||||
sectionId ? 'rdm.review.assign' : 'rdm.review.assign.doc', sectionId ? { ...payload, sectionId } : payload))
|
||||
}
|
||||
const batchApply = async () => {
|
||||
if (!canAssign || busy || selection.size === 0 || dutyValidation(batchDraft)) return
|
||||
const current = fence.begin('batch-preview'); setBusy(true); setError('')
|
||||
try {
|
||||
const ids = [...selection]
|
||||
const preview = await Promise.all(ids.map(id => api.rdmProjectCommand<DutyDetail>(project, 'rdm.duty.get', { docId: id })))
|
||||
if (!current()) return
|
||||
if (preview.some((document, index) => document.docId !== ids[index] || !validVersion(document.dutyVersion))) throw new Error('批次中有文档版本未核验,请重新选择')
|
||||
const affected = preview.filter(document => documentAssignmentImpact(document, batchDraft))
|
||||
const summary = preview.map(document => `${document.title}${!documentAssignmentImpact(document, batchDraft) ? '' : document.active?.round ? `(第 ${document.active.round} 轮将失效)` : '(章节审签将失效)'}`).join('\n')
|
||||
if (!window.confirm(`将对以下 ${preview.length} 份文档应用相同职责,任一失败整批不写入:\n${summary}\n编制:${batchDraft.author}\n审核:${batchDraft.reviewers.join('、')}\n批准:${batchDraft.approver}\n${affected.length ? '受影响的审签需重新提交。' : ''}确认分配?`)) return
|
||||
if (!current()) return
|
||||
const saved = await run('批量职责已保存,已重新读取目录', () => api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', {
|
||||
docs: preview.map(document => ({ docId: document.docId, author: batchDraft.author, reviewers: batchDraft.reviewers, approver: batchDraft.approver,
|
||||
expectedDutyVersion: document.dutyVersion!, confirmInvalidation: affected.length > 0 })),
|
||||
confirmInvalidation: affected.length > 0,
|
||||
}))
|
||||
if (current() && saved) { setSelection(new Set()); setBatchDraft(blank()) }
|
||||
} catch (e) { if (current()) setError(readableError(e)) }
|
||||
finally { if (current()) setBusy(false) }
|
||||
}
|
||||
const submissionDuties = normalizeDuties(sub?.duties)
|
||||
const canSubmit = !!detail && detail.assigned && normalizeDuties(detail.roles).author === me?.owner && sub !== undefined && sub?.state !== 'submitted'
|
||||
const canReview = sub?.state === 'submitted' && !!me?.owner && submissionDuties.reviewers.includes(me.owner)
|
||||
const canFinal = sub?.state === 'submitted' && submissionDuties.approver === me?.owner && submissionDuties.reviewers.every(owner => sub.reviews?.[owner] === true)
|
||||
const people = [...memberNames.current.values()]
|
||||
|
||||
return <div className="rdm-settings">
|
||||
<h4 className="settings-group-title">文档职责</h4>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索文档" placeholder="输入文档名称或 ID" value={query} onChange={e => changeFilter(() => setQuery(e.target.value))} />
|
||||
<select className="lg-input" aria-label="文档匹配方式" value={match} onChange={e => changeFilter(() => setMatch(e.target.value as 'exact' | 'fuzzy'))}>
|
||||
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
|
||||
</select>
|
||||
<select className="lg-input" aria-label="文档浏览方式" value={view} onChange={e => { fence.cancel('catalog'); setTree(null); setView(e.target.value as 'list' | 'tree') }}>
|
||||
<option value="list">列表</option><option value="tree">树形</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="ad-row rdm-filter-row">
|
||||
<select className="lg-input" aria-label="阶段筛选" value={stage} onChange={e => changeFilter(() => setStage(e.target.value))}><option value="">全部阶段</option>{(catalog?.filters?.stages ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select className="lg-input" aria-label="文档类型筛选" value={typeKey} onChange={e => changeFilter(() => setTypeKey(e.target.value))}><option value="">全部文档类型</option>{(catalog?.filters?.typeKeys ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select className="lg-input" aria-label="职责分配筛选" value={assignment} onChange={e => changeFilter(() => setAssignment(e.target.value))}><option value="">全部职责</option>{(catalog?.filters?.assignments ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select className="lg-input" aria-label="审签状态筛选" value={status} onChange={e => changeFilter(() => setStatus(e.target.value))}><option value="">全部审签状态</option>{(catalog?.filters?.statuses ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
</div>
|
||||
{view === 'tree' && tree ? <DutyTree nodes={tree} selected={docId} onSelect={selectDoc} /> : view === 'list' && catalog && <div className="rdm-table-scroll"><table className="ad-table">
|
||||
<thead><tr><th>选择</th><th>文档</th><th>类型/阶段</th><th>当前职责</th><th>审签状态</th></tr></thead>
|
||||
<tbody>{catalog.docs.map(document => <tr key={document.docId} className={document.docId === docId ? 'ad-row-on' : ''}>
|
||||
<td><input type="checkbox" aria-label={`选择 ${document.title}`} checked={selection.has(document.docId)} disabled={!canAssign || busy || (selection.size >= 20 && !selection.has(document.docId))} onChange={e => {
|
||||
if (dirty && !window.confirm('丢弃当前文档未保存的职责调整,切换到批量分配?')) return
|
||||
if (docId) { fence.cancel('detail'); setDocId(''); selectedRef.current = ''; setDetail(null); setDraft(blank()); setSub(undefined) }
|
||||
const next = new Set(selection); e.target.checked ? next.add(document.docId) : next.delete(document.docId); setSelection(next)
|
||||
}} /></td>
|
||||
<td><button className="ad-mini" onClick={() => selectDoc(document.docId)}>{document.title}</button><div className="ad-dim">{document.docId}</div></td>
|
||||
<td>{document.typeLabel || catalog.filters?.typeKeys?.find(option => option.value === document.typeKey)?.label || '文档'}{document.stage && <div className="ad-dim">{document.stageLabel || catalog.filters?.stages?.find(option => option.value === document.stage)?.label || '阶段未提供'}</div>}</td>
|
||||
<td>{document.assigned ? `编制 ${document.author || '—'} · 审核 ${(document.reviewers ?? []).join('、') || '—'} · 批准 ${document.approver || '—'}` : '未分配'}</td>
|
||||
<td>{document.activeRound ? `第 ${document.activeRound} 轮审核中` : document.statusLabel || statusLabel(document.status)}{document.activeSections ? <div className="ad-dim">{document.activeSections} 个章节审签中</div> : null}</td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>}
|
||||
{loading && <p className="ad-hint" role="status">加载文档…</p>}
|
||||
{!loading && catalog?.docs.length === 0 && <p className="ad-hint">没有匹配且可访问的文档</p>}
|
||||
{!loading && !catalog && <Btn onClick={() => { setError(''); void loadCatalog() }}>重新加载文档</Btn>}
|
||||
{catalog?.next && <Btn disabled={loading} onClick={() => loadCatalog(catalog.next)}>加载更多文档</Btn>}
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
{!canAssign && catalog && <p className="ad-hint">你可以查看有权访问的文档;分配职责需要文档职责管理权限</p>}
|
||||
{docId && !detail && <p className="ad-hint">{error ? '文档职责未能加载' : '读取当前职责…'}</p>}
|
||||
{detail && <SettingsGroup title={`${detail.title} · 当前职责`}>
|
||||
<div className="ad-row"><select className="lg-input" aria-label="职责对象" value={sectionId} onChange={e => {
|
||||
if (dirty && !window.confirm('丢弃未保存的职责调整?')) return
|
||||
const id = e.target.value; setSectionId(id); setDraft(normalizeDuties(id ? detail.sections.find(section => section.sectionId === id)?.roles : detail.roles))
|
||||
}}><option value="">整份文档默认职责</option>{detail.sections.map(section => <option key={section.sectionId} value={section.sectionId}>{section.title}{section.overridden ? ' · 单独指派' : ' · 沿用文档默认'}</option>)}</select>
|
||||
<RoleFilter roles={roles} value={roleId} onChange={value => { fence.cancel('member-candidates'); setRoleId(value) }} disabled={!canEditCurrent || !!roleError} />
|
||||
</div>
|
||||
<DutyPeople value={draft} onChange={setDraft} fetchPage={memberPage} people={people} disabled={!canEditCurrent || busy || !validVersion(currentVersion) || members?.accountDirectoryAvailable !== true} />
|
||||
{dirty && dutyValidation(draft) && <p className="ad-hint">{dutyValidation(draft)}</p>}
|
||||
{!validVersion(currentVersion) && <p className="ad-hint">职责版本尚未就绪,只能查看;请升级项目服务后重新读取</p>}
|
||||
{active && <p className="ad-hint">{active.round ? `第 ${active.round} 轮` : '章节'}审签正在进行,职责变更需要确认后重新提交</p>}
|
||||
<Btn variant="primary" disabled={!canEditCurrent || busy || !dirty || !validVersion(currentVersion) || !!dutyValidation(draft)} onClick={save}>保存当前职责</Btn>
|
||||
<p className="ad-hint">当前职责与提交时冻结名单独立。项目管理权限不代表编制、审核或批准资格</p>
|
||||
</SettingsGroup>}
|
||||
{detail && <SettingsGroup title={sub ? `整文审签 · 第 ${sub.round} 轮 · ${statusLabel(sub.state)}` : '整文审签'}>
|
||||
<Btn disabled={busy || !!writeError || !canSubmit} onClick={() => run('整文已提交审核', () => api.rdmProjectCommand(project, 'rdm.review.doc.submit', docSubmitPayload(project.id, docId)))}>整文提交(编制人)</Btn>
|
||||
{sub && <>
|
||||
<p>第 {sub.round} 轮 · {statusLabel(sub.state)}</p>
|
||||
<p className="ad-hint">冻结名单:编制 {submissionDuties.author} · 审核 {submissionDuties.reviewers.join('、')} · 批准 {submissionDuties.approver}</p>
|
||||
<p className="ad-hint">审核进度:{submissionDuties.reviewers.map(owner => `${owner} ${sub.reviews?.[owner] ? '已通过' : '待审核'}`).join(';')}</p>
|
||||
<div className="ad-row"><Btn disabled={busy || !!writeError || !canReview} onClick={() => run('已完成审核', () => api.rdmProjectCommand(project, 'rdm.review.doc.approve', docApprovePayload(project.id, docId, '通过')))}>审核通过</Btn>
|
||||
<Btn disabled={busy || !!writeError || !canReview} onClick={() => { const reason = window.prompt('请输入驳回原因'); if (reason?.trim()) void run('已驳回整文', () => api.rdmProjectCommand(project, 'rdm.review.doc.reject', docRejectPayload(project.id, docId, reason.trim()))) }}>驳回</Btn>
|
||||
<Btn variant="primary" disabled={busy || !!writeError || !canFinal} onClick={() => run('整文已批准', () => api.rdmProjectCommand(project, 'rdm.review.doc.final', docFinalPayload(project.id, docId, sub.round)))}>终批(批准人)</Btn></div>
|
||||
</>}
|
||||
{sub === null && <p className="ad-hint">尚无整文提交记录</p>}
|
||||
{sub === undefined && <p className="ad-hint">审签状态尚未核验,暂不能执行审签操作</p>}
|
||||
</SettingsGroup>}
|
||||
{selection.size > 0 && <SettingsGroup title={`批量分配 · 已选 ${selection.size} 份文档`}>
|
||||
<p className="ad-hint">最多选择 20 份;提交前读取各文档版本、预览影响,任一失败整批不写入</p>
|
||||
<RoleFilter roles={roles} value={roleId} onChange={setRoleId} disabled={!canAssign || !!roleError} />
|
||||
<DutyPeople value={batchDraft} onChange={setBatchDraft} fetchPage={memberPage} people={people} disabled={!canAssign || busy || members?.accountDirectoryAvailable !== true} labelPrefix="批量" />
|
||||
<div className="ad-row"><Btn variant="primary" disabled={!canAssign || busy || !!dutyValidation(batchDraft)} onClick={batchApply}>预览并分配 {selection.size} 份文档</Btn>
|
||||
<Btn disabled={busy} onClick={() => { if (!batchDirty || window.confirm('清空已选文档及未保存的批量职责?')) { fence.cancel('batch-preview'); setSelection(new Set()); setBatchDraft(blank()) } }}>清空选择</Btn></div>
|
||||
</SettingsGroup>}
|
||||
<Feedback error={error || roleError} notice={notice} />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Btn, Picker } from '@ui'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { memberAddPayload, memberRemovePayload, memberSetRolePayload } from '../rdmManagement'
|
||||
import { Feedback, personItem, personLabel, projectContext, projectRoleLabel, readableError,
|
||||
RoleFilter, useRequestFence, useRoleOptions, type Person, type MemberList } from './rdmSettingsShared'
|
||||
|
||||
export function RdmMembersSection({ project }: { project: Project }) {
|
||||
const owner = useStore(s => s.me?.owner)
|
||||
const context = projectContext(project, owner)
|
||||
const fence = useRequestFence(context)
|
||||
const writeError = projectWorkError(project)
|
||||
const [list, setList] = useState<MemberList | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [picked, setPicked] = useState<string[]>([])
|
||||
const [role, setRole] = useState<'member' | 'owner'>('member')
|
||||
const [roleId, setRoleId] = useState('')
|
||||
const [match, setMatch] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [impact, setImpact] = useState<{ member: string; canRemove: boolean; lastOwner: boolean; reasons: string[]; documents: { docId: string; title?: string; label?: string; sectionId?: string; activeRound?: number }[] } | null>(null)
|
||||
const { roles, error: roleError } = useRoleOptions(project, 'members', list?.canManageMembers === true)
|
||||
const candidates = useRef(new Map<string, Person>())
|
||||
const canManage = list?.canManageMembers === true && !writeError
|
||||
const directoryReady = list?.accountDirectoryAvailable === true
|
||||
const rowsRef = useRef(list?.members ?? [])
|
||||
rowsRef.current = list?.members ?? []
|
||||
|
||||
const refresh = async (cursor = '') => {
|
||||
const current = fence.begin('members')
|
||||
try {
|
||||
const out = await api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { cursor, limit: 50 })
|
||||
if (!current()) return false
|
||||
if (!Array.isArray(out.members)) throw new Error('成员查询接口未就绪')
|
||||
setList(prev => cursor && prev ? { ...out, members: [...prev.members, ...out.members] } : out)
|
||||
return true
|
||||
} catch (e) { if (current()) { setError(readableError(e)); if (!cursor) setList(null) } return false }
|
||||
}
|
||||
useEffect(() => {
|
||||
setList(null); setPicked([]); candidates.current.clear(); setRoleId(''); setRole('member')
|
||||
setError(''); setNotice(''); setBusy(false); setImpact(null)
|
||||
void refresh()
|
||||
}, [context])
|
||||
|
||||
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const current = fence.begin('candidates')
|
||||
const out = await api.rdmProjectCommand<{ accounts: Person[]; next?: string }>(project, 'rdm.account.list', { query, cursor, limit, roleId, match })
|
||||
if (!current()) return { items: [] }
|
||||
if (!Array.isArray(out.accounts)) throw new Error('账号目录接口未就绪')
|
||||
const joined = new Set(rowsRef.current.map(person => person.owner))
|
||||
return { items: out.accounts.map(person => {
|
||||
const candidate = { ...person, joined: person.joined === true || joined.has(person.owner) }
|
||||
candidates.current.set(candidate.owner, candidate)
|
||||
return personItem(candidate, true)
|
||||
}), nextCursor: out.next || undefined }
|
||||
}, [project, roleId, match, context])
|
||||
|
||||
const run = async (message: string, mutation: () => Promise<unknown>, clearPick = false) => {
|
||||
if (!canManage || busy) return
|
||||
const current = fence.begin('mutation')
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
try {
|
||||
await mutation()
|
||||
if (!current()) return
|
||||
if (clearPick) setPicked([])
|
||||
const verified = await refresh()
|
||||
if (current() && verified) setNotice(message)
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
setError(`${readableError(e)};已重新读取成员状态,请核对后再操作`)
|
||||
await refresh()
|
||||
} finally { if (current()) setBusy(false) }
|
||||
}
|
||||
const pickedPerson = candidates.current.get(picked[0] || '')
|
||||
const addable = pickedPerson?.valid === true && pickedPerson.joined === false
|
||||
|
||||
const previewRemove = async (person: Person) => {
|
||||
if (!canManage || busy) return
|
||||
const current = fence.begin('impact'); setBusy(true); setError(''); setImpact(null)
|
||||
try {
|
||||
const out = await api.rdmProjectCommand<NonNullable<typeof impact>>(project, 'rdm.project.member.impact', { member: person.owner })
|
||||
if (!current()) return
|
||||
if (out.member !== person.owner || typeof out.canRemove !== 'boolean' || !Array.isArray(out.documents) || !Array.isArray(out.reasons)) throw new Error('成员职责影响查询尚未就绪,暂不能移除成员')
|
||||
setImpact(out)
|
||||
} catch (e) { if (current()) setError(readableError(e)) }
|
||||
finally { if (current()) setBusy(false) }
|
||||
}
|
||||
|
||||
return <div className="rdm-settings">
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<RoleFilter roles={roles} value={roleId} disabled={!canManage || !!roleError} onChange={value => { fence.cancel('candidates'); candidates.current.clear(); setRoleId(value); setPicked([]) }} />
|
||||
<select aria-label="人员匹配方式" className="lg-input" value={match} disabled={!canManage} onChange={e => { fence.cancel('candidates'); setMatch(e.target.value as 'exact' | 'fuzzy'); setPicked([]) }}>
|
||||
<option value="fuzzy">模糊匹配</option><option value="exact">精确账号或姓名</option>
|
||||
</select>
|
||||
<Picker aria-label="选择人员" selected={picked} onSelect={setPicked} fetchPage={fetchPage}
|
||||
selectedItems={pickedPerson ? [personItem(pickedPerson)] : []} disabled={!canManage || !directoryReady || busy}
|
||||
reloadKey={`${context}:${roleId}:${match}:${list?.members.map(p => p.owner).join(',')}`}
|
||||
placeholder="输入账号或姓名,或浏览候选…" />
|
||||
<select aria-label="新成员项目身份" className="lg-input" value={role} disabled={!canManage || busy} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">项目成员</option><option value="owner">项目负责人</option>
|
||||
</select>
|
||||
<Btn variant="primary" disabled={!canManage || !directoryReady || !addable || busy} onClick={() => {
|
||||
if (!pickedPerson || !addable) return
|
||||
void run(`已添加 ${personLabel(pickedPerson)}`, () => api.rdmProjectCommand(project, 'rdm.project.member.add', memberAddPayload(project.id, pickedPerson.owner, role)), true)
|
||||
}}>添加成员</Btn>
|
||||
</div>
|
||||
<p className="ad-hint">专业角色用于查找人员,项目身份用于项目管理。已加入人员请在下表调整身份;最后一位项目负责人不可移除或降级</p>
|
||||
{list && !list.canManageMembers && <p className="ad-hint">你可以查看成员;添加、调整与移除需要管理项目成员权限</p>}
|
||||
{list && !directoryReady && <p className="ad-hint">系统账号目录尚未就绪,暂不能添加成员</p>}
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
</div>
|
||||
<div className="rdm-table-scroll"><table className="ad-table">
|
||||
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th>操作</th></tr></thead>
|
||||
<tbody>{(list?.members ?? []).map(person => <tr key={person.owner}>
|
||||
<td>{personLabel(person)}{person.name && <span className="ad-dim"> · {person.owner}</span>}</td>
|
||||
<td>{person.profRole?.name || '未配置专业角色'}</td><td>{projectRoleLabel(person.role)}</td>
|
||||
<td className="ad-dim">{person.addedBy || '—'}</td><td className="ad-dim">{person.addedAt ? new Date(person.addedAt).toLocaleString() : '—'}</td>
|
||||
<td><button className="ad-mini" disabled={!canManage || busy} onClick={() => {
|
||||
const next = person.role === 'owner' ? 'member' : 'owner'
|
||||
if (window.confirm(`将「${personLabel(person)}」调整为${projectRoleLabel(next)}?`)) void run('已调整项目身份', () => api.rdmProjectCommand(project, 'rdm.project.member.set-role', memberSetRolePayload(project.id, person.owner, next)))
|
||||
}}>{person.role === 'owner' ? '降为成员' : '升为负责人'}</button>{' '}
|
||||
<button className="ad-mini" disabled={!canManage || busy} onClick={() => previewRemove(person)}>移除</button></td>
|
||||
</tr>)}{!list && <tr><td colSpan={6}>{error ? '成员未能加载' : '加载中…'}</td></tr>}
|
||||
{list?.members.length === 0 && <tr><td colSpan={6}>暂无成员</td></tr>}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{list?.next && <Btn disabled={busy} onClick={() => refresh(list.next)}>加载更多成员</Btn>}
|
||||
{error && !list && <Btn onClick={() => { setError(''); void refresh() }}>重试</Btn>}
|
||||
{impact && <div className="settings-group"><h4>移除成员 · {list?.members.find(person => person.owner === impact.member)?.name || impact.member}</h4>
|
||||
{impact.documents.length > 0 && <><p className="ad-hint">该成员仍承担以下文档职责,请先在文档职责中完成交接</p><ul>{impact.documents.map(document => <li key={`${document.docId}:${document.sectionId || ''}`}>
|
||||
{document.title || document.label || '受控文档'} <span className="ad-dim">{document.docId}{document.sectionId ? ' · 章节职责' : ''}{document.activeRound ? ` · 第 ${document.activeRound} 轮审核中` : ''}</span>
|
||||
</li>)}</ul></>}
|
||||
{impact.reasons.map(reason => <p key={reason} className="ad-hint">{reason}</p>)}
|
||||
{impact.lastOwner && <p className="ad-hint">最后一位项目负责人不能移除,请先指定其他负责人</p>}
|
||||
<div className="ad-row"><Btn disabled={busy || !canManage || !impact.canRemove || impact.lastOwner || impact.documents.length > 0} onClick={() => {
|
||||
if (window.confirm(`确认移除「${impact.member}」?`)) void run('已移除项目成员', async () => { await api.rdmProjectCommand(project, 'rdm.project.member.remove', memberRemovePayload(project.id, impact.member)); setImpact(null) })
|
||||
}}>确认移除</Btn><Btn disabled={busy} onClick={() => { fence.cancel('impact'); setImpact(null) }}>取消</Btn></div>
|
||||
</div>}
|
||||
<Feedback error={error || roleError} notice={notice} />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Btn, Collapsible, Picker } from '@ui'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { actionLabel, describeSource } from '../rdmManagement'
|
||||
import { Feedback, personItem, projectContext, projectRoleLabel, readableError, RoleFilter,
|
||||
useRequestFence, useRoleOptions, type MemberList } from './rdmSettingsShared'
|
||||
|
||||
export interface MatrixAction {
|
||||
action: string; label?: string; scope?: string; allowed: boolean; defaultAllowed?: boolean; capAllowed?: boolean
|
||||
override?: null | boolean; source: string; sourceLabel?: string; reason?: string
|
||||
}
|
||||
interface MatrixRow {
|
||||
member: string; name?: string; role: string; profRole?: { roleId: string; name: string } | null; actions: MatrixAction[]
|
||||
}
|
||||
interface MatrixOut extends MatrixRow { projectId: string; canConfigurePermissions?: boolean }
|
||||
interface MatrixBatch { projectId: string; rows: MatrixRow[]; next?: string; count?: number; canConfigurePermissions?: boolean }
|
||||
export function projectActions(actions: MatrixAction[]): MatrixAction[] {
|
||||
return actions.filter(action => action.scope === 'project' && action.action !== 'project.create')
|
||||
}
|
||||
export function overrideMode(action: MatrixAction): 'default' | 'allow' | 'deny' | 'unavailable' {
|
||||
return action.override === null ? 'default' : action.override === true ? 'allow' : action.override === false ? 'deny' : 'unavailable'
|
||||
}
|
||||
const actionGroup = (action: string) => action.startsWith('doc.') || action.startsWith('review.') || action === 'duty.assign' ? 'documents'
|
||||
: action.startsWith('trace.') || action.startsWith('knowledge.') ? 'knowledge' : 'project'
|
||||
const reasonText = (action: MatrixAction) => {
|
||||
if (!action.reason) return action.allowed ? '具备项目动作权限;具体资源仍按职责与状态核验' : '当前项目策略未允许'
|
||||
if (action.reason.includes('project.create') || action.reason === action.action) return '配置范围不符'
|
||||
return /[\u4e00-\u9fff]/.test(action.reason) ? action.reason : '当前策略未允许,请联系项目负责人核对'
|
||||
}
|
||||
export function ActionDetailTable({ actions, canManage = false, busy = false, onOverride }: {
|
||||
actions: MatrixAction[]; canManage?: boolean; busy?: boolean
|
||||
onOverride?: (action: string, mode: 'default' | 'allow' | 'deny') => void
|
||||
}) {
|
||||
return <div className="rdm-table-scroll"><table className="ad-table">
|
||||
<thead><tr><th>项目操作</th><th>权限</th><th>来源</th><th>说明</th>{canManage && <th>项目内配置</th>}</tr></thead>
|
||||
<tbody>{projectActions(actions).map(action => {
|
||||
const mode = overrideMode(action)
|
||||
const supported = mode !== 'unavailable' && typeof action.capAllowed === 'boolean' && typeof action.defaultAllowed === 'boolean'
|
||||
return <tr key={action.action}><td>{actionLabel(action.action, action.label)}</td>
|
||||
<td><span className={`ad-chip${action.allowed ? ' ad-chip-admin' : ' ad-chip-warn'}`}>{action.allowed ? '允许' : '禁止'}</span></td>
|
||||
<td>{action.sourceLabel && action.sourceLabel !== action.source ? action.sourceLabel : describeSource(action.source)}</td>
|
||||
<td>{reasonText(action)}<Collapsible trigger="查看权限依据">
|
||||
<p className="ad-hint">角色默认:{action.defaultAllowed === true ? '允许' : action.defaultAllowed === false ? '禁止' : '未提供'} · 管理员上限:{action.capAllowed === true ? '允许' : action.capAllowed === false ? '禁止' : '未提供'}</p>
|
||||
<p className="ad-hint">项目内配置:{mode === 'default' ? '沿用默认' : mode === 'allow' ? '允许' : mode === 'deny' ? '禁止' : '尚未核验'}</p>
|
||||
</Collapsible></td>
|
||||
{canManage && <td><select className="lg-input" aria-label={`${actionLabel(action.action, action.label)}项目覆盖`} value={mode} disabled={busy || !supported}
|
||||
onChange={e => onOverride?.(action.action, e.target.value as 'default' | 'allow' | 'deny')}>
|
||||
{!supported && <option value="unavailable">配置尚未核验</option>}
|
||||
<option value="default">沿用角色默认</option><option value="allow" disabled={action.capAllowed !== true}>项目内允许</option><option value="deny">项目内禁止</option>
|
||||
</select>{action.capAllowed === false && <p className="ad-hint">管理员上限禁止授予</p>}</td>}
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</table></div>
|
||||
}
|
||||
|
||||
export function RdmPermMatrixSection({ project }: { project: Project }) {
|
||||
const me = useStore(s => s.me)
|
||||
const context = projectContext(project, me?.owner)
|
||||
const fence = useRequestFence(context)
|
||||
const writeError = projectWorkError(project)
|
||||
const [view, setView] = useState<'member' | 'matrix'>('member')
|
||||
const [member, setMember] = useState(me?.owner || '')
|
||||
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
|
||||
const [batch, setBatch] = useState<MatrixBatch | null>(null)
|
||||
const [canConfigure, setCanConfigure] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [roleId, setRoleId] = useState('')
|
||||
const [projectRole, setProjectRole] = useState('')
|
||||
const [group, setGroup] = useState('all')
|
||||
const [expanded, setExpanded] = useState<{ member: string; action: string } | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const { roles, error: roleError } = useRoleOptions(project, 'permissions', canConfigure)
|
||||
const queryMember = async (owner: string) => {
|
||||
if (!owner) return false
|
||||
const current = fence.begin('read')
|
||||
setMatrix(null); setLoading(true); setError('')
|
||||
try {
|
||||
const out = await api.rdmProjectCommand<MatrixOut>(project, 'rdm.perm.check', { member: owner })
|
||||
if (!current()) return false
|
||||
if (out.member !== owner || !Array.isArray(out.actions)) throw new Error('权限查询结果与成员不一致')
|
||||
setCanConfigure(out.canConfigurePermissions === true)
|
||||
setMatrix(out)
|
||||
return true
|
||||
} catch (e) { if (current()) { setError(readableError(e)); setCanConfigure(false) } return false }
|
||||
finally { if (current()) setLoading(false) }
|
||||
}
|
||||
const queryBatch = async (cursor = '') => {
|
||||
if (!canConfigure) return false
|
||||
const current = fence.begin('read')
|
||||
setLoading(true); setError(''); if (!cursor) setBatch(null)
|
||||
try {
|
||||
const out = await api.rdmProjectCommand<MatrixBatch>(project, 'rdm.perm.matrix', { query, roleId, projectRole, cursor, limit: 20 })
|
||||
if (!current()) return false
|
||||
if (out.canConfigurePermissions !== true || !Array.isArray(out.rows)) throw new Error('没有查看项目权限矩阵的能力')
|
||||
setBatch(previous => cursor && previous ? { ...out, rows: [...previous.rows, ...out.rows] } : out)
|
||||
return true
|
||||
} catch (e) { if (current()) setError(readableError(e)); return false }
|
||||
finally { if (current()) setLoading(false) }
|
||||
}
|
||||
useEffect(() => {
|
||||
fence.invalidate(); setView('member'); setMember(me?.owner || ''); setBatch(null); setMatrix(null)
|
||||
setCanConfigure(false); setExpanded(null); setQuery(''); setRoleId(''); setProjectRole(''); setBusy(false); setNotice(''); setError('')
|
||||
void queryMember(me?.owner || '')
|
||||
}, [context])
|
||||
useEffect(() => {
|
||||
if (view !== 'matrix' || !canConfigure) return
|
||||
fence.cancel('read'); setExpanded(null)
|
||||
const timer = setTimeout(() => { void queryBatch() }, query ? 250 : 0)
|
||||
return () => { clearTimeout(timer); fence.cancel('read') }
|
||||
}, [view, query, roleId, projectRole, canConfigure])
|
||||
const memberPage = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const out = await api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { query, roleId, projectRole, cursor, limit })
|
||||
if (!Array.isArray(out.members) || out.canConfigurePermissions !== true) throw new Error('无权选择其他项目成员')
|
||||
return { items: out.members.map(person => personItem(person)), nextCursor: out.next || undefined }
|
||||
}, [project, roleId, projectRole])
|
||||
const override = async (owner: string, actionId: string, mode: 'allow' | 'deny' | 'default') => {
|
||||
const row = view === 'member' ? matrix : batch?.rows.find(row => row.member === owner)
|
||||
const action = row?.actions.find(action => action.action === actionId && action.scope === 'project' && action.action !== 'project.create')
|
||||
if (!canConfigure || writeError || busy || !action || overrideMode(action) === 'unavailable' || (mode === 'allow' && action.capAllowed !== true)) return
|
||||
const current = fence.begin('mutation')
|
||||
setBusy(true); setNotice(''); setError('')
|
||||
try {
|
||||
await api.rdmProjectCommand(project, mode === 'default' ? 'rdm.perm.unset' : 'rdm.perm.set',
|
||||
{ member: owner, action: actionId, ...(mode === 'default' ? {} : { allow: mode === 'allow' }) })
|
||||
if (!current()) return
|
||||
const verified = view === 'matrix' ? await queryBatch() : await queryMember(owner)
|
||||
if (current() && verified) setNotice(`${actionLabel(actionId)}:${mode === 'default' ? '已恢复角色默认' : mode === 'allow' ? '已配置为项目内允许' : '已配置为项目内禁止'}`)
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
const failure = readableError(e)
|
||||
if (view === 'matrix') await queryBatch(); else await queryMember(owner)
|
||||
if (current()) setError(`${failure};已尝试核对最新权限,未自动重试写入`)
|
||||
} finally { if (current()) setBusy(false) }
|
||||
}
|
||||
const visible = (actions: MatrixAction[]) => projectActions(actions).filter(action => group === 'all' || actionGroup(action.action) === group)
|
||||
const matrixActions = [...new Map((batch?.rows ?? []).flatMap(row => visible(row.actions)).map(action => [action.action, action])).values()]
|
||||
const expandedRow = expanded ? batch?.rows.find(row => row.member === expanded.member) : undefined
|
||||
return <div className="rdm-settings">
|
||||
<div className="ad-create"><div className="ad-row">
|
||||
<select aria-label="权限查看方式" className="lg-input" value={view} disabled={busy} onChange={e => {
|
||||
const next = e.target.value as 'member' | 'matrix'
|
||||
if (next === 'matrix' && !canConfigure) return
|
||||
fence.cancel('read'); fence.cancel('mutation'); setExpanded(null); setNotice(''); setView(next)
|
||||
if (next === 'member') void queryMember(member || me?.owner || '')
|
||||
}}><option value="member">按成员查看</option><option value="matrix" disabled={!canConfigure}>权限矩阵</option></select>
|
||||
<select aria-label="权限分组" className="lg-input" value={group} onChange={e => { setGroup(e.target.value); setExpanded(null) }}>
|
||||
<option value="all">全部项目操作</option><option value="project">项目与成员</option><option value="documents">文档与审签</option><option value="knowledge">知识与追溯</option>
|
||||
</select>
|
||||
<RoleFilter roles={roles} value={roleId} disabled={!canConfigure || !!roleError || busy} onChange={value => { fence.cancel('read'); setRoleId(value); setExpanded(null) }} />
|
||||
<select aria-label="项目身份筛选" className="lg-input" value={projectRole} disabled={!canConfigure || busy} onChange={e => { fence.cancel('read'); setProjectRole(e.target.value); setExpanded(null) }}>
|
||||
<option value="">全部项目身份</option><option value="owner">项目负责人</option><option value="member">项目成员</option>
|
||||
</select>
|
||||
{view === 'member' && <Picker aria-label="选择权限成员" selected={member ? [member] : []}
|
||||
selectedItems={[{ id: member, title: matrix?.name || (member === me?.owner ? me?.name || member : member), subtitle: member }]}
|
||||
onSelect={ids => { fence.cancel('read'); setMember(ids[0] || me?.owner || ''); void queryMember(ids[0] || me?.owner || '') }}
|
||||
fetchPage={memberPage} disabled={!canConfigure || busy} reloadKey={`${context}:${roleId}:${projectRole}`} />}
|
||||
{view === 'matrix' && <input aria-label="搜索权限成员" className="lg-input" placeholder="输入账号或姓名" value={query} onChange={e => { fence.cancel('read'); setBatch(null); setQuery(e.target.value) }} />}
|
||||
<Btn disabled={loading || busy} onClick={() => view === 'matrix' ? queryBatch() : queryMember(member)}>刷新权限</Btn>
|
||||
</div>
|
||||
<p className="ad-hint">默认显示自己的权限。查看其他成员、矩阵和配置需要权限管理能力;文档操作仍按具体资源、职责和审签状态核验</p>
|
||||
</div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
{loading && <p className="ad-hint" role="status">核验项目权限…</p>}
|
||||
{view === 'member' && matrix && <>
|
||||
<p>{matrix.name || matrix.member} · {projectRoleLabel(matrix.role)}{matrix.profRole?.name ? ` · ${matrix.profRole.name}` : ''}</p>
|
||||
<ActionDetailTable actions={visible(matrix.actions)} canManage={canConfigure && !writeError} busy={busy} onOverride={(action, mode) => override(matrix.member, action, mode)} />
|
||||
</>}
|
||||
{view === 'matrix' && batch && <>
|
||||
<div className="rdm-table-scroll"><table className="ad-table">
|
||||
<thead><tr><th>成员</th>{matrixActions.map(action => <th key={action.action}>{actionLabel(action.action, action.label)}</th>)}</tr></thead>
|
||||
<tbody>{batch.rows.map(row => <tr key={row.member}><td>{row.name || row.member}<div className="ad-dim">{projectRoleLabel(row.role)}{row.profRole?.name ? ` · ${row.profRole.name}` : ''}</div></td>
|
||||
{matrixActions.map(action => {
|
||||
const permission = row.actions.find(item => item.action === action.action && item.scope === 'project')
|
||||
return <td key={action.action}>{permission ? <button className="ad-mini" aria-label={`${row.name || row.member} · ${actionLabel(action.action)} · ${permission.allowed ? '允许' : '禁止'}`}
|
||||
onClick={() => setExpanded({ member: row.member, action: action.action })}>{permission.allowed ? '允许' : '禁止'}</button> : '未核验'}</td>
|
||||
})}</tr>)}</tbody>
|
||||
</table></div>
|
||||
{batch.rows.length === 0 && <p className="ad-hint">没有匹配的项目成员</p>}
|
||||
{batch.next && <Btn disabled={loading || busy} onClick={() => queryBatch(batch.next)}>加载更多成员</Btn>}
|
||||
{expanded && <div className="settings-group"><h4>{expandedRow?.name || expanded.member} · 权限依据</h4>
|
||||
<ActionDetailTable actions={expandedRow?.actions.filter(action => action.action === expanded.action) || []}
|
||||
canManage={canConfigure && !writeError} busy={busy} onOverride={(action, mode) => override(expanded.member, action, mode)} /></div>}
|
||||
</>}
|
||||
<Feedback error={error || (canConfigure ? roleError : '')} notice={notice} />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Btn, SettingsGroup, SettingRow } from '@ui'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
|
||||
const groupNames: Record<string, string> = {
|
||||
models: '模型与密钥', runtime: '运行环境', context: '上下文', retrieval: '检索',
|
||||
ui: '界面', plugins: '系统插件', crossSession: '个人会话协作', tools: '工具服务', deliverables: '项目交付规则', telemetry: '运行观测',
|
||||
}
|
||||
const optionLabels: Record<string, string> = { modern: '现代', formal: '正式', minimal: '简洁',
|
||||
dark: '深色', light: '浅色', system: '跟随系统', zh: '中文', en: '英文', auto: '自动', off: '关闭', on: '开启',
|
||||
disabled: '关闭', enabled: '开启', relevant: '相关会话', all: '全部本人会话', none: '关闭',
|
||||
direct: '直接对话', squad: '协作小队', zhCN: '简体中文', 'zh-CN': '简体中文', 'en-US': '英语',
|
||||
}
|
||||
function readableValue(value: unknown) {
|
||||
if (value === true) return '开启'
|
||||
if (value === false) return '关闭'
|
||||
if (value === undefined || value === null || value === '') return '未配置'
|
||||
const text = String(value)
|
||||
return optionLabels[text] || text
|
||||
}
|
||||
export function scopedSchema(schema: SettingDesc[], scope: string) {
|
||||
return schema.filter(desc => !desc.scope
|
||||
? scope === 'user' || scope === 'project-personal'
|
||||
: desc.scope === scope || (scope === 'project-personal' && desc.scope === 'user'))
|
||||
}
|
||||
export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } {
|
||||
return desc.secret || desc.type === 'secret' ? { type: 'password', autoComplete: 'new-password' } : { type: 'text', autoComplete: 'off' }
|
||||
}
|
||||
export function SettingInput({ desc, value, onChange, disabled = false }: { desc: SettingDesc; value: any; onChange: (value: any) => void; disabled?: boolean }) {
|
||||
if (desc.type === 'boolean') return <select aria-label={desc.label} value={value === true ? 'true' : value === false ? 'false' : ''} disabled={disabled}
|
||||
onChange={e => onChange(e.target.value === '' ? undefined : e.target.value === 'true')}>
|
||||
<option value="">沿用默认({readableValue(desc.default)})</option><option value="true">开启</option><option value="false">关闭</option>
|
||||
</select>
|
||||
if (desc.type === 'enum') return <select aria-label={desc.label} value={value ?? ''} disabled={disabled} onChange={e => onChange(e.target.value || undefined)}>
|
||||
<option value="">沿用默认({readableValue(desc.default)})</option>
|
||||
{(desc.options ?? []).map(option => <option key={option} value={option}>{optionLabels[option] || option}</option>)}
|
||||
</select>
|
||||
if (desc.type === 'number') return <input aria-label={desc.label} type="number" step="any" disabled={disabled} value={value ?? ''}
|
||||
placeholder={`默认:${readableValue(desc.default)}`} onChange={e => onChange(e.target.value === '' || !Number.isFinite(e.target.valueAsNumber) ? undefined : e.target.valueAsNumber)} />
|
||||
const secret = desc.secret || desc.type === 'secret'
|
||||
return <input aria-label={desc.label} {...settingInputAttrs(desc)} disabled={disabled} value={value === '••••••' ? '' : value ?? ''}
|
||||
placeholder={secret ? value === '••••••' ? '已配置,输入以替换' : '未配置,输入密钥' : `默认:${readableValue(desc.default)}`}
|
||||
onChange={e => onChange(secret ? e.target.value || undefined : e.target.value)} />
|
||||
}
|
||||
export function SettingsFields({ schema, draft, defaults = {}, onChange, disabled = false, source = '个人配置' }: {
|
||||
schema: SettingDesc[]; draft: Record<string, any>; defaults?: Record<string, any>
|
||||
onChange: (key: string, value: any) => void; disabled?: boolean; source?: string
|
||||
}) {
|
||||
const groups: Record<string, SettingDesc[]> = {}
|
||||
for (const desc of schema) (groups[desc.group] ??= []).push(desc)
|
||||
return <>{Object.entries(groups).map(([group, descs]) => <SettingsGroup key={group} title={groupNames[group] || '其他设置'}>
|
||||
{descs.map(desc => <SettingRow key={desc.key} label={desc.label} effect={desc.effect}
|
||||
desc={`${desc.description || ''}${desc.description ? ' · ' : ''}${draft[desc.key] === undefined ? `沿用默认:${desc.secret ? '受保护配置' : readableValue(defaults[desc.key] ?? desc.default)}` : source}`}>
|
||||
<SettingInput desc={{ ...desc, default: defaults[desc.key] ?? desc.default }} value={draft[desc.key]} disabled={disabled} onChange={value => onChange(desc.key, value)} />
|
||||
{desc.type === 'string' && !desc.secret && <Btn disabled={disabled || draft[desc.key] === undefined} onClick={() => onChange(desc.key, undefined)}>沿用默认</Btn>}
|
||||
</SettingRow>)}
|
||||
</SettingsGroup>)}</>
|
||||
}
|
||||
export function flatten(map: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
const visit = (object: Record<string, any>, prefix = '') => {
|
||||
for (const [key, value] of Object.entries(object || {})) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) visit(value, path)
|
||||
else out[path] = value
|
||||
}
|
||||
}
|
||||
visit(map); return out
|
||||
}
|
||||
function unflatten(map: Record<string, any>, preserveEmpty = false): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
for (const [path, value] of Object.entries(map)) {
|
||||
if (value === undefined || (value === '' && !preserveEmpty)) continue
|
||||
const parts = path.split('.')
|
||||
let current = out
|
||||
for (const part of parts.slice(0, -1)) current = current[part] ??= {}
|
||||
current[parts[parts.length - 1]] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
export function scopedDraft(values: Record<string, unknown>, schema: SettingDesc[]) {
|
||||
const keys = new Set(schema.map(desc => desc.key))
|
||||
return Object.fromEntries(Object.entries(flatten(values)).filter(([key]) => keys.has(key)))
|
||||
}
|
||||
/** A settings write is a merge patch; omitted overrides are deliberately removed. */
|
||||
export function settingsMutation(initial: Record<string, any>, draft: Record<string, any>, schema: SettingDesc[]) {
|
||||
const changed: Record<string, any> = {}
|
||||
const unset: string[] = []
|
||||
for (const desc of schema) {
|
||||
const next = draft[desc.key]
|
||||
if (Object.is(initial[desc.key], next)) continue
|
||||
if (desc.secret || desc.type === 'secret') {
|
||||
if (next === undefined || next === '••••••') continue
|
||||
changed[desc.key] = next
|
||||
} else if (next === undefined) unset.push(desc.key)
|
||||
else changed[desc.key] = next
|
||||
}
|
||||
return { values: unflatten(changed, true), unset }
|
||||
}
|
||||
export function personalSettingsMutation(initial: Record<string, any>, draft: Record<string, any>, schema: SettingDesc[]) {
|
||||
const mutation = settingsMutation(initial, draft, schema)
|
||||
const values = flatten(mutation.values)
|
||||
for (const key of mutation.unset) values[key] = null
|
||||
return unflatten(values, true)
|
||||
}
|
||||
@@ -1,154 +1,110 @@
|
||||
// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义,
|
||||
// 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。
|
||||
// 重设计方案 阶段②(IA):设置瘦身——只留个人偏好(常规);智能体画像/插件管理/
|
||||
// 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type SettingsView } from '../api'
|
||||
import { isFontSizeChoice, store, useStore } from '../store'
|
||||
import type { ThemeChoice } from '../theme'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
|
||||
import { SettingsShell, SettingRow, SettingsGroup, Btn, EmptyState } from '@ui'
|
||||
import { BorrowPanels } from './BorrowPanels'
|
||||
import { flatten, scopedDraft, scopedSchema, SettingsFields, personalSettingsMutation } from './SettingsFields'
|
||||
import { Feedback, readableError, useRequestFence } from './rdmSettingsShared'
|
||||
|
||||
type Section = 'settings'
|
||||
|
||||
// 分组显示名(项目设置面板共用)。
|
||||
export const groupNames: Record<string, string> = {
|
||||
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
|
||||
ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具',
|
||||
}
|
||||
export { SettingInput, settingInputAttrs } from './SettingsFields'
|
||||
|
||||
export function SettingsModal() {
|
||||
const [section] = useState<Section>('settings')
|
||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||
const [, setUser] = useState<Record<string, any>>({})
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [saved, setSaved] = useState(false)
|
||||
const owner = useStore(s => s.me?.owner)
|
||||
const projectId = useStore(s => s.currentProjectId)
|
||||
const projectName = useStore(s => s.projects.find(project => project.id === projectId)?.name)
|
||||
const theme = useStore(s => s.ui.theme)
|
||||
const fontSize = useStore(s => s.ui.fontSize)
|
||||
|
||||
const fence = useRequestFence(`${owner || ''}:${projectId || ''}`)
|
||||
const [section, setSection] = useState('settings')
|
||||
const [settings, setSettings] = useState<SettingsView | null>(null)
|
||||
const [projectSettings, setProjectSettings] = useState<SettingsView | null>(null)
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [personalDraft, setPersonalDraft] = useState<Record<string, any>>({})
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const schema = settings ? scopedSchema(settings.schema, 'user') : []
|
||||
const personalSchema = projectSettings ? scopedSchema(projectSettings.personalSchema || [], 'project-personal') : []
|
||||
const initial = settings ? scopedDraft(settings.user, schema) : {}
|
||||
const personalInitial = projectSettings ? scopedDraft(projectSettings.personalProject || {}, personalSchema) : {}
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(initial) || JSON.stringify(personalDraft) !== JSON.stringify(personalInitial)
|
||||
const load = async (projectOnly = false) => {
|
||||
const current = fence.begin('load')
|
||||
setError('')
|
||||
try {
|
||||
if (!projectOnly) {
|
||||
const out = await api.getSettings()
|
||||
if (!current()) return false
|
||||
setSettings(out); setDraft(scopedDraft(out.user, scopedSchema(out.schema, 'user')))
|
||||
}
|
||||
if (projectId) {
|
||||
const out = await api.getSettings(projectId)
|
||||
if (!current()) return false
|
||||
const personal = scopedSchema(out.personalSchema || [], 'project-personal')
|
||||
setProjectSettings(out); setPersonalDraft(scopedDraft(out.personalProject || {}, personal))
|
||||
}
|
||||
return true
|
||||
} catch (e) { if (current()) setError(`个人设置无法读取:${readableError(e)}`); return false }
|
||||
}
|
||||
useEffect(() => {
|
||||
api.getSettings().then(s => {
|
||||
setSchema(s.schema)
|
||||
setUser(flatten(s.user))
|
||||
setDraft(flatten(s.user))
|
||||
}).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const g: Record<string, SettingDesc[]> = {}
|
||||
for (const d of schema) (g[d.group] ??= []).push(d)
|
||||
return g
|
||||
}, [schema])
|
||||
|
||||
fence.invalidate(); setSettings(null); setProjectSettings(null); setDraft({}); setPersonalDraft({}); setNotice(''); setBusy(false)
|
||||
void load()
|
||||
}, [owner, projectId])
|
||||
const depart = () => !dirty || window.confirm('个人设置尚未保存,离开后将丢弃这些改动。继续?')
|
||||
const save = async () => {
|
||||
const values = unflatten(draft)
|
||||
await api.putUserSettings(values)
|
||||
setUser(draft)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 1500)
|
||||
// hot 类设置即时生效:主题。
|
||||
if (draft['ui.theme']) store.setUI({ theme: draft['ui.theme'] })
|
||||
store.refreshPlugins()
|
||||
if (busy || (section === 'settings' ? !settings : !projectSettings?.personalSchema)) return
|
||||
const current = fence.begin('save')
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
try {
|
||||
if (section === 'settings') await api.putUserSettings(personalSettingsMutation(initial, draft, schema))
|
||||
else if (projectId) await api.putPersonalProjectSettings(projectId, personalSettingsMutation(personalInitial, personalDraft, personalSchema))
|
||||
if (!current()) return
|
||||
if (await load()) {
|
||||
if (!current()) return
|
||||
setNotice('个人偏好已保存;按各项标注的生效时机应用')
|
||||
void store.refreshPlugins()
|
||||
}
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
const message = `保存结果未能确认:${readableError(e)};请核对最新设置后再保存`
|
||||
await load()
|
||||
if (current()) setError(message)
|
||||
} finally { if (current()) setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
title="设置" active={section} onSelect={() => undefined}
|
||||
onClose={() => store.openSettings(false)}
|
||||
sections={[
|
||||
{ key: 'settings', label: '常规', icon: 'settings' },
|
||||
]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
|
||||
actions={section === 'settings' && schema.length > 0 ? (
|
||||
<>
|
||||
{saved && <span style={{ color: 'var(--ok)', fontSize: 'var(--fs-ui-sm)', marginRight: 8 }}>✓ 已保存</span>}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
{section === 'settings' && (
|
||||
<SettingsGroup title="外观">
|
||||
<SettingRow label="主题" desc="墨绿是可选身份皮肤。跟随系统只在深色与浅色之间切换。深色和浅色会随设置保存" effect="hot">
|
||||
<select value={theme} onChange={e => {
|
||||
const next = e.target.value as ThemeChoice
|
||||
store.setUI({ theme: next })
|
||||
if (next === 'dark' || next === 'light') setDraft(x => ({ ...x, 'ui.theme': next }))
|
||||
}}>
|
||||
<option value="dark">深色</option>
|
||||
<option value="light">浅色</option>
|
||||
<option value="molv">墨绿</option>
|
||||
<option value="system">跟随系统</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="界面字号" desc="小、标准、大、特大。只保存在这台设备上,图标和间距不跟随缩放" effect="hot">
|
||||
<select value={fontSize} onChange={e => {
|
||||
const next = Number(e.target.value)
|
||||
if (isFontSizeChoice(next)) store.setUI({ fontSize: next })
|
||||
}}>
|
||||
<option value={13}>小</option>
|
||||
<option value={14}>标准</option>
|
||||
<option value={16}>大</option>
|
||||
<option value={18}>特大</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
{section === 'settings' && Object.entries(groups).map(([g, descs]) => {
|
||||
const rows = descs.filter(d => d.key !== 'ui.theme')
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<SettingsGroup key={g} title={groupNames[g] ?? g}>
|
||||
{rows.map(d => (
|
||||
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
|
||||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
)
|
||||
})}
|
||||
{section === 'settings' && <BorrowPanels />}
|
||||
</SettingsShell>
|
||||
)
|
||||
return <SettingsShell title="个人设置" active={section}
|
||||
onSelect={next => { if (!busy && depart()) { setDraft(initial); setPersonalDraft(personalInitial); setNotice(''); setSection(next) } }}
|
||||
onClose={() => { if (depart()) { fence.invalidate(); store.openSettings(false) } }}
|
||||
sections={[{ key: 'settings', label: '个人偏好', icon: 'settings' }, ...(projectId ? [{ key: 'project-personal', label: '我在当前项目的偏好', icon: 'folder' as const }] : [])]}
|
||||
footer="个人偏好只影响本人;项目业务规则在项目设置中管理"
|
||||
actions={(section === 'settings' ? settings : projectSettings?.personalSchema) ? <Btn variant="primary" icon="check" disabled={busy || !dirty} onClick={save}>保存个人设置</Btn> : undefined}>
|
||||
{section === 'settings' && <>
|
||||
<SettingsGroup title="外观">
|
||||
<SettingRow label="主题" desc="立即应用到这台设备;深色和浅色可随个人偏好保存" effect="hot">
|
||||
<select value={theme} aria-label="主题" disabled={busy || !settings} onChange={e => {
|
||||
const value = e.target.value as ThemeChoice; store.setUI({ theme: value })
|
||||
if (value === 'dark' || value === 'light') setDraft(previous => ({ ...previous, 'ui.theme': value }))
|
||||
}}><option value="dark">深色</option><option value="light">浅色</option><option value="molv">墨绿</option><option value="system">跟随系统</option></select>
|
||||
</SettingRow>
|
||||
<SettingRow label="界面字号" desc="仅保存在这台设备上;图标和间距不随字号缩放" effect="hot">
|
||||
<select value={fontSize} aria-label="界面字号" onChange={e => { const value = Number(e.target.value); if (isFontSizeChoice(value)) store.setUI({ fontSize: value }) }}>
|
||||
<option value={13}>小</option><option value={14}>标准</option><option value={16}>大</option><option value={18}>特大</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
{!settings && <><EmptyState icon="settings">{error || '读取个人偏好…'}</EmptyState>{error && <Btn onClick={() => load()}>重试</Btn>}</>}
|
||||
{settings && <SettingsFields schema={schema.filter(desc => desc.key !== 'ui.theme')} draft={draft} defaults={flatten(settings.defaults)} disabled={busy} onChange={(key, value) => setDraft(previous => ({ ...previous, [key]: value }))} />}
|
||||
<BorrowPanels />
|
||||
</>}
|
||||
{section === 'project-personal' && <>
|
||||
<p className="ad-hint">当前项目:{projectName || '尚未选择'}。以下偏好仅影响本人;跨会话读取仅访问本人授权的会话,不开放其他成员的私聊、原件或草稿</p>
|
||||
{!projectSettings && <><EmptyState icon="settings">{error || '读取当前项目的个人偏好…'}</EmptyState>{error && <Btn onClick={() => load(true)}>重试</Btn>}</>}
|
||||
{projectSettings && !projectSettings.personalSchema && <EmptyState icon="settings">项目个人偏好接口尚未就绪,请升级服务后重试</EmptyState>}
|
||||
{projectSettings?.personalSchema && personalSchema.length === 0 && <EmptyState icon="settings">当前没有可调整的个人项目偏好</EmptyState>}
|
||||
{projectSettings?.personalSchema && <SettingsFields schema={personalSchema} draft={personalDraft}
|
||||
defaults={{ ...flatten(projectSettings.personalDefaults || {}), ...flatten(projectSettings.personalUser || {}) }} source="本人在当前项目的偏好" disabled={busy}
|
||||
onChange={(key, value) => setPersonalDraft(previous => ({ ...previous, [key]: value }))} />}
|
||||
</>}
|
||||
<Feedback error={error} notice={notice} />
|
||||
</SettingsShell>
|
||||
}
|
||||
|
||||
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
|
||||
if (desc.type === 'boolean') {
|
||||
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
|
||||
}
|
||||
if (desc.type === 'enum') {
|
||||
return <select value={value} onChange={e => onChange(e.target.value)}>
|
||||
{(desc.options ?? []).map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
}
|
||||
const isSecret = desc.secret || desc.type === 'secret'
|
||||
return <input type="password" autoComplete="new-password" style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
|
||||
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
|
||||
onChange={e => onChange(e.target.value)} />
|
||||
}
|
||||
|
||||
// 点路径扁平化/还原。
|
||||
function flat(m: Record<string, any>, prefix: string, out: Record<string, any>) {
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out)
|
||||
else out[key] = v
|
||||
}
|
||||
}
|
||||
export function flatten(m: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
flat(m, '', out)
|
||||
return out
|
||||
}
|
||||
export function unflatten(flatMap: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries(flatMap)) {
|
||||
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
|
||||
const parts = k.split('.')
|
||||
let cur = out
|
||||
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]] ??= {}
|
||||
cur[parts[parts.length - 1]] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
|
||||
@@ -21,7 +21,7 @@ function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClo
|
||||
if (x + el.offsetWidth > window.innerWidth - 8) x = window.innerWidth - el.offsetWidth - 8
|
||||
if (y + el.offsetHeight > window.innerHeight - 8) y = r.top - el.offsetHeight - 6
|
||||
el.style.left = `${Math.max(8, x)}px`
|
||||
el.style.top = `${y}px`
|
||||
el.style.top = `${Math.max(8, Math.min(y, window.innerHeight - el.offsetHeight - 8))}px`
|
||||
const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() }
|
||||
window.addEventListener('mousedown', h)
|
||||
return () => window.removeEventListener('mousedown', h)
|
||||
@@ -36,15 +36,27 @@ function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; da
|
||||
</button>
|
||||
}
|
||||
|
||||
export function TaskSidebar() {
|
||||
interface TaskSidebarProps {
|
||||
onCollapse?: () => void
|
||||
onConversationOpen?: () => void
|
||||
onFilesOpen?: () => void
|
||||
onWorkOpen?: () => void
|
||||
}
|
||||
|
||||
export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
onFilesOpen = store.openFiles, onWorkOpen = store.focusPending,
|
||||
}: TaskSidebarProps = {}) {
|
||||
const projects = useStore(s => s.projects)
|
||||
const tasks = useStore(s => s.tasks)
|
||||
const me = useStore(s => s.me)
|
||||
const adminSections = useStore(s => s.adminSections)
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const currentConvId = useStore(s => s.currentConvId)
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
const showArchived = useStore(s => s.ui.showArchived)
|
||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
|
||||
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
|
||||
const theme = useStore(s => s.ui.theme)
|
||||
const pendingBadge = useStore(s =>
|
||||
s.cards.filter(c => c.status === 'action_required').length +
|
||||
@@ -55,23 +67,20 @@ export function TaskSidebar() {
|
||||
const [creatingProject, setCreatingProject] = useState(false)
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
|
||||
// ⌘N/Ctrl+N 新建任务(P6:创建入口唯一 + 快捷键)。
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
const k = e.key.toLowerCase()
|
||||
if ((e.metaKey || e.ctrlKey) && k === 'n') { e.preventDefault(); store.createTask() }
|
||||
if ((e.metaKey || e.ctrlKey) && k === 'b') { e.preventDefault(); store.toggleLeft() }
|
||||
}
|
||||
window.addEventListener('keydown', h, true)
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}, [])
|
||||
const createConversation = async (projectId?: string) => {
|
||||
try {
|
||||
await store.createTask(projectId)
|
||||
onConversationOpen?.()
|
||||
} catch { /* store owns the actionable failure toast */ }
|
||||
}
|
||||
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces)
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
|
||||
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
|
||||
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
|
||||
|
||||
@@ -94,16 +103,21 @@ export function TaskSidebar() {
|
||||
?? tasks.find(t => t.projectId === pid && !t.archived)
|
||||
if (next) store.openTask(next.id)
|
||||
else store.openProject(pid)
|
||||
onConversationOpen?.()
|
||||
}
|
||||
|
||||
const taskRow = (t: Conversation) => (
|
||||
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
|
||||
onClick={() => store.openTask(t.id)}
|
||||
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
|
||||
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
|
||||
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
||||
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
||||
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
|
||||
<Icon name="message" size={14} />
|
||||
<span className="tsb-task-t" title={t.title}>{t.title || '未命名对话'}</span>
|
||||
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
||||
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
||||
</button>
|
||||
{rowActions(t)}
|
||||
<button className="tsb-row-menu" aria-label={`「${t.title || '未命名对话'}」更多操作`} title="更多对话操作"
|
||||
onClick={e => setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget })}><Icon name="more" size={14} /></button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -118,22 +132,21 @@ export function TaskSidebar() {
|
||||
{stage && <span className="tsb-projrow-stage">{stage}</span>}
|
||||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||
</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||||
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={onCollapse}><Icon name="panel-left" /></button>
|
||||
</div>
|
||||
|
||||
{/* 单一主创建入口(P6):新建任务 + 次级菜单(新建项目/指定项目新建) */}
|
||||
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
|
||||
<div className="tsb-actions">
|
||||
<div className="tsb-newtask">
|
||||
<button className="tsb-newtask-main" title="在当前项目新建任务(⌘N)" onClick={() => store.createTask()}>
|
||||
<Icon name="plus" size={15} />新建任务{currentProject ? <span className="tsb-newtask-proj">{currentProject.name}</span> : null}
|
||||
<button className="tsb-newtask-main" title="在当前项目新建对话(⌘N)" onClick={() => createConversation()}>
|
||||
<Icon name="plus" size={15} />新建对话
|
||||
</button>
|
||||
<button className="tsb-newtask-caret" title="更多创建选项"
|
||||
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: el }) }}><Icon name="chevron-down" size={14} /></button>
|
||||
</div>
|
||||
<div className="tsb-search">
|
||||
<Icon name="search" size={14} />
|
||||
<input placeholder="搜索任务…" value={q} onChange={e => setQ(e.target.value)} />
|
||||
<input aria-label="搜索本项目会话" placeholder="搜索对话…" value={q} onChange={e => setQ(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,8 +165,10 @@ export function TaskSidebar() {
|
||||
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)
|
||||
}}
|
||||
@@ -169,14 +184,14 @@ export function TaskSidebar() {
|
||||
)}
|
||||
|
||||
{rest.length === 0 && pinned.length === 0 && (
|
||||
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
|
||||
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
|
||||
)}
|
||||
{rest.map(t => taskRow(t))}
|
||||
</div>
|
||||
|
||||
{/* 底部(方案:文件、工作区、账户沉底)。 */}
|
||||
<div className="tsb-bottom">
|
||||
<button className="tsb-tool" title="文件搜索、预览、编辑与下载(第三栏「文件」入口)" onClick={() => store.openFiles()}>
|
||||
<button className="tsb-tool" title="文件搜索、预览、编辑与下载" onClick={onFilesOpen}>
|
||||
<span className="tsb-tool-icon"><Icon name="folder" size={14} /></span>
|
||||
<span className="tsb-tool-name">文件</span>
|
||||
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
|
||||
@@ -188,7 +203,6 @@ export function TaskSidebar() {
|
||||
<kbd>{entry.shortcut}</kbd>
|
||||
</button>
|
||||
))}
|
||||
{serverWorkspaces.length === 0 && <div className="tsb-tool-note">领域工具由系统级插件提供</div>}
|
||||
{currentProject && (
|
||||
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
|
||||
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
|
||||
@@ -208,14 +222,14 @@ export function TaskSidebar() {
|
||||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||
</button>
|
||||
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" size={15} /></button>
|
||||
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.focusPending()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
|
||||
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={onWorkOpen}><Icon name="inbox" size={15} />{pendingBadge}</button>}
|
||||
</div>
|
||||
|
||||
{/* 弹出菜单们 */}
|
||||
<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}
|
||||
@@ -229,11 +243,11 @@ export function TaskSidebar() {
|
||||
</Menu>
|
||||
|
||||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||||
<MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
||||
<MenuItem key={p.id} onClick={() => { store.createTask(p.id); setMenu(null) }}>在「{p.name}」新建任务</MenuItem>
|
||||
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
@@ -259,13 +273,13 @@ export function TaskSidebar() {
|
||||
</div>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
|
||||
<MenuItem icon="inbox" onClick={() => { store.focusPending(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
|
||||
<MenuItem icon="inbox" onClick={() => { onWorkOpen(); setMenu(null) }}>待处理事项{pendingBadge > 0 ? `(${pendingBadge})` : ''}</MenuItem>
|
||||
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
|
||||
<MenuItem icon="moon" onClick={() => { store.setUI({ theme: 'dark' }); setMenu(null) }}>深色主题{theme === 'dark' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
|
||||
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{me?.auth && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||||
|
||||
+43
-11
@@ -1,20 +1,52 @@
|
||||
// 顶栏:产品标识与待处理铃铛。项目身份在左栏顶部切换器集中显示一次(方案),
|
||||
// 阶段徽章随切换器;本栏不再重复。
|
||||
// Project identity lives in context; the top bar keeps work/files reachable when panes close.
|
||||
import { BrandMark, Icon, pendingCount } from '@ui'
|
||||
import { store, useStore } from '../store'
|
||||
import type { WorkbenchPane } from '../workbenchLayout'
|
||||
|
||||
export function TopBar() {
|
||||
interface TopBarProps {
|
||||
compact?: boolean
|
||||
activePane?: WorkbenchPane
|
||||
showReturn?: boolean
|
||||
onContext?: () => void
|
||||
onConversation?: () => void
|
||||
onWork?: () => void
|
||||
onFiles?: () => void
|
||||
}
|
||||
|
||||
export function TopBar({ compact = false, activePane = 'conversation', showReturn = false,
|
||||
onContext = store.toggleLeft, onConversation,
|
||||
onWork = () => { store.openInbox(); store.setUI({ inboxFilter: 'all', previewExpanded: false }) }, onFiles = store.openFiles,
|
||||
}: TopBarProps = {}) {
|
||||
const pending = useStore(s => pendingCount(s.cards) + pendingCount(s.projectCards))
|
||||
const ui = useStore(s => s.ui)
|
||||
const returnVisible = showReturn || compact && activePane !== 'conversation'
|
||||
return (
|
||||
<header className="topbar">
|
||||
<BrandMark size={22} />
|
||||
<span className="topbar-name">Agent-System</span>
|
||||
<header className={`topbar companion-topbar${compact ? ' compact' : ''}`}>
|
||||
<div className="topbar-identity"><BrandMark size={22} /><span className="topbar-name">工作助手</span></div>
|
||||
<span className="topbar-spacer" />
|
||||
<button className="topbar-bell" title={pending > 0 ? `待处理 ${pending},打开工作入口` : '工作入口'}
|
||||
onClick={() => store.focusPending()}>
|
||||
<Icon name="inbox" size={16} />
|
||||
{pending > 0 && <span className="rail-badge" data-testid="topbar-pending">{pending}</span>}
|
||||
</button>
|
||||
{returnVisible && <button className="shell-return" aria-label="返回对话" onClick={onConversation}>
|
||||
<Icon name="chevron-right" className="shell-back-icon" size={14} /><span>返回对话</span>
|
||||
</button>}
|
||||
<nav className="shell-navigation" aria-label="工作台导航">
|
||||
{compact && <>
|
||||
<button className="shell-nav-button" aria-label="打开上下文" aria-pressed={activePane === 'context'} onClick={onContext}>
|
||||
<Icon name="panel-left" size={15} /><span>上下文</span>
|
||||
</button>
|
||||
<button className="shell-nav-button" aria-label="打开对话" aria-pressed={activePane === 'conversation'} onClick={onConversation}>
|
||||
<Icon name="message" size={15} /><span>对话</span>
|
||||
</button>
|
||||
</>}
|
||||
<button className="shell-nav-button" aria-label="打开工作"
|
||||
aria-pressed={compact ? activePane === 'work' : !ui.rightCollapsed && ui.rightTab === 'work'}
|
||||
title={pending > 0 ? `${pending} 项待处理` : '工作'} onClick={onWork}>
|
||||
<Icon name="inbox" size={15} /><span>工作</span>
|
||||
{pending > 0 && <span className="shell-pending" data-testid="topbar-pending">{pending > 99 ? '99+' : pending}</span>}
|
||||
</button>
|
||||
<button className="shell-nav-button" aria-label="打开文件"
|
||||
aria-pressed={compact ? activePane === 'files' : !ui.rightCollapsed && ui.rightTab === 'files'} onClick={onFiles}>
|
||||
<Icon name="folder" size={15} /><span>文件</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
|
||||
import { Icon } from '@ui'
|
||||
import { Icon, SurfaceView } from '@ui'
|
||||
import { previewTabsKey } from '../previewtabs'
|
||||
import { surfaceKey } from '../surfaceKey'
|
||||
import { ArtifactsPanel } from './ArtifactsPanel'
|
||||
import { PreviewPane } from './PreviewPane'
|
||||
|
||||
@@ -21,6 +22,7 @@ export const docBridge: DocBridge = {
|
||||
|
||||
const inboxCallbacks = {
|
||||
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
|
||||
actionState: (c: ActivityCard) => store.getState().cardActionStates[c.id],
|
||||
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
||||
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
||||
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
|
||||
@@ -51,6 +53,8 @@ function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxF
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
// Subscribe to shared feedback even when no card object has changed yet.
|
||||
useStore(s => s.cardActionStates)
|
||||
const rightTab = useStore(s => s.ui.rightTab)
|
||||
const cards = useStore(s => s.cards)
|
||||
const plan = useStore(s => s.plan)
|
||||
@@ -91,6 +95,11 @@ function FilesTab() {
|
||||
return <div className="tab-body"><ArtifactsPanel /></div>
|
||||
}
|
||||
|
||||
/** U1:同会话存在后台 progress 卡(进行中/待确认)时,progress 为权威对象,隐藏 CurrentTaskCard 避免双标题。 */
|
||||
export function hasActiveProgressCard(cards: ActivityCard[]): boolean {
|
||||
return cards.some(c => c.kind === 'progress' && (c.status === 'active' || c.status === 'action_required'))
|
||||
}
|
||||
|
||||
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
|
||||
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
|
||||
@@ -99,10 +108,11 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
projectCards: ActivityCard[]
|
||||
cards: ActivityCard[]
|
||||
}) {
|
||||
const showCurrentTask = !hasActiveProgressCard(cards)
|
||||
return (
|
||||
<>
|
||||
<div className="tab-body" data-scroll-sync="inbox">
|
||||
<CurrentTaskCard plan={plan} todos={todos} />
|
||||
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
|
||||
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
|
||||
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
|
||||
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
|
||||
@@ -115,6 +125,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
{inboxScope === 'project'
|
||||
? <ProjectInbox cards={projectCards} filter={filter} />
|
||||
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
|
||||
<WorkspaceSurfaces />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -172,3 +183,35 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// U6 工作区任务页容器:会话内页面在"工作"入口的常驻紧凑视图——默认显示最新
|
||||
// 一页,固定(pin)后不随新页面抢占;关闭插件后此处自然消失(surfaces 为空),
|
||||
// 收件箱/任务卡等原有内容不受影响(禁用回退完整性)。点击标题去文件预览全屏。
|
||||
function WorkspaceSurfaces() {
|
||||
const surfaces = useStore(s => s.surfaces)
|
||||
const pin = useStore(s => s.ui.workspaceSurfacePin)
|
||||
const usable = surfaces.filter(x => surfaceKey(x) != null)
|
||||
if (usable.length === 0) return null
|
||||
const shown = pin ? usable.find(x => surfaceKey(x) === pin) : usable[usable.length - 1]
|
||||
if (!shown) return null
|
||||
const shownId = surfaceKey(shown)!
|
||||
return (
|
||||
<div className="workspace-surfaces" data-workspace-surfaces>
|
||||
<div className="ws-head">
|
||||
<span className="ws-title">任务页面</span>
|
||||
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
|
||||
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : shownId })}>
|
||||
{pin ? '📍' : '📌'}
|
||||
</button>
|
||||
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(shownId)}>
|
||||
↗
|
||||
</button>
|
||||
</div>
|
||||
<div className="ws-body">
|
||||
<SurfaceView snapshot={shown} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import { OverlayHost, type DocBridge } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { AgentRow } from './Workbench'
|
||||
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
|
||||
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
|
||||
export function workspaceEntries(): WorkspaceEntry[] {
|
||||
return buildWorkspaceEntries(store.getState().serverWorkspaces)
|
||||
const s = store.getState()
|
||||
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable })
|
||||
}
|
||||
|
||||
export function Workspaces() {
|
||||
@@ -28,6 +31,11 @@ export function Workspaces() {
|
||||
<AgentsPanel runs={convRuns} />
|
||||
</OverlayHost>
|
||||
}
|
||||
if (active === 'rdm-audit') {
|
||||
return <OverlayHost title="追溯审计" icon="🔍" shortcut={entry?.shortcut} sayHint="打开追溯审计" onClose={close}>
|
||||
<RdmAuditWorkspace />
|
||||
</OverlayHost>
|
||||
}
|
||||
if (active.startsWith('plugin:')) {
|
||||
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
|
||||
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
|
||||
// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
|
||||
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { EmptyState, Icon } from '@ui'
|
||||
import {
|
||||
ROLE_ACTIONS,
|
||||
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
|
||||
actionLabel,
|
||||
rolesListPlaceholder, describeRoleListError,
|
||||
} from '../rdmManagement'
|
||||
|
||||
// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ----
|
||||
|
||||
interface RoleView {
|
||||
roleId: string
|
||||
name: string
|
||||
disabled: boolean
|
||||
version: number
|
||||
actions: Record<string, [boolean, boolean]> | null
|
||||
accounts: string[] | null
|
||||
}
|
||||
|
||||
interface RoleActionView { action: string; label: string; scope: 'project' | 'system'; configurable: boolean }
|
||||
interface RoleListView {
|
||||
roles: RoleView[]
|
||||
roleActions?: RoleActionView[]
|
||||
systemActions?: RoleActionView[]
|
||||
canManageRoles?: boolean
|
||||
canAssignRoles?: boolean
|
||||
}
|
||||
|
||||
export function RdmRolesPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
const [roles, setRoles] = useState<RoleView[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [catalog, setCatalog] = useState<RoleListView | null>(null)
|
||||
const request = useRef(0)
|
||||
const [newName, setNewName] = useState('')
|
||||
// 动作编辑器
|
||||
const [editRole, setEditRole] = useState('')
|
||||
const [editAction, setEditAction] = useState<string>('doc.read')
|
||||
const [editDef, setEditDef] = useState(false)
|
||||
const [editCap, setEditCap] = useState(true)
|
||||
// 绑定
|
||||
const [assignOwner, setAssignOwner] = useState('')
|
||||
|
||||
const refresh = async () => {
|
||||
const seq = ++request.current
|
||||
try {
|
||||
const out = await api.rdmCommand<RoleListView>('rdm.role.list')
|
||||
if (seq !== request.current) return
|
||||
setErr(''); setRoles(out.roles ?? []); setCatalog(out)
|
||||
} catch (e) {
|
||||
if (seq !== request.current) return
|
||||
setRoles(null); setCatalog(null); setErr(describeRoleListError(String(e)))
|
||||
}
|
||||
}
|
||||
useEffect(() => { refresh(); return () => { request.current++ } }, [])
|
||||
|
||||
const projectActions = catalog?.roleActions?.filter(a => a.scope === 'project' && a.configurable && a.action !== 'project.create')
|
||||
?? [...ROLE_ACTIONS.filter(a => a !== 'project.create'), 'audit.read'].map(action => ({ action, label: actionLabel(action), scope: 'project' as const, configurable: false }))
|
||||
const systemActions = catalog?.systemActions?.filter(a => a.scope === 'system')
|
||||
?? [{ action: 'project.create', label: actionLabel('project.create'), scope: 'system' as const, configurable: false }]
|
||||
const canManage = catalog?.canManageRoles === true && Array.isArray(catalog.roleActions)
|
||||
const canAssign = catalog?.canAssignRoles === true
|
||||
const sel = roles?.find(r => r.roleId === editRole)
|
||||
const editCurrent = sel?.actions?.[editAction]
|
||||
const canEditAction = canManage && !sel?.disabled && projectActions.some(a => a.action === editAction && a.configurable)
|
||||
useEffect(() => {
|
||||
setEditDef(!!editCurrent?.[0]); setEditCap(!!editCurrent?.[1])
|
||||
}, [editRole, editAction, roles])
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try { await fn(); setNotice(label); await refresh() }
|
||||
catch (e) { setErr(describeRoleListError(String(e))) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!me?.isAdmin) return <EmptyState icon="shield">仅平台管理员可管理专业角色</EmptyState>
|
||||
|
||||
// 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见
|
||||
// rolesListPlaceholder 注释)。
|
||||
const placeholder = rolesListPlaceholder(roles, err)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
|
||||
onChange={e => setNewName(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !canManage || !newName.trim()}
|
||||
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
|
||||
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
|
||||
setNewName('')
|
||||
})}>创建角色</button>
|
||||
</div>
|
||||
<p className="ad-hint">新角色尚无业务权限,配置后才会生效。项目权限可在角色上限内调整。</p>
|
||||
</div>
|
||||
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(roles ?? []).map(r => (
|
||||
<tr key={r.roleId}>
|
||||
<td title={r.roleId}>{r.name}</td>
|
||||
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
|
||||
<td className="ad-dim">v{r.version}</td>
|
||||
<td>{Object.keys(r.actions ?? {}).length} 项</td>
|
||||
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
|
||||
<td><button className="ad-mini" disabled={busy || !canManage}
|
||||
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号的业务权限停止生效)`,
|
||||
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
|
||||
{r.disabled ? '启用' : '停用'}</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
|
||||
{placeholder && <tr><td colSpan={6} className="ad-dim">{placeholder}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
|
||||
<div className="ad-row">
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<select className="lg-input" value={editAction}
|
||||
onChange={e => setEditAction(e.target.value)}>
|
||||
{projectActions.map(a => <option key={a.action} value={a.action}>{a.label}</option>)}
|
||||
<optgroup label="系统权限(此处仅供查看)">
|
||||
{systemActions.map(a => <option key={a.action} value={a.action} disabled>{a.label}</option>)}
|
||||
</optgroup>
|
||||
</select>
|
||||
<label className="ad-check"><input type="checkbox" checked={editDef} disabled={busy || !canEditAction}
|
||||
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
|
||||
<label className="ad-check"><input type="checkbox" checked={editCap} disabled={busy || !canEditAction}
|
||||
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !editRole || !canEditAction}
|
||||
onClick={() => run(`已配置 ${sel?.name || '角色'} · ${actionLabel(editAction)}`,
|
||||
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
|
||||
</div>
|
||||
{sel && editCurrent && <p className="ad-hint">当前值:默认{editCurrent[0] ? '允许' : '禁止'},上限{editCurrent[1] ? '允许' : '禁止'}</p>}
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="成员账号" value={assignOwner}
|
||||
onChange={e => setAssignOwner(e.target.value)} />
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !canAssign || !assignOwner.trim() || !editRole || sel?.disabled}
|
||||
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${sel?.name || '所选角色'}`,
|
||||
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
|
||||
</div>
|
||||
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { RdmMembersSection } from './RdmMembersSection'
|
||||
export { RdmDutiesSection } from './RdmDutiesSection'
|
||||
export { RdmPermMatrixSection } from './RdmPermMatrixSection'
|
||||
@@ -6,11 +6,23 @@ 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, scopedDraft, settingsMutation, scopedSchema } from './SettingsFields'
|
||||
import { Feedback, readableError, useRequestFence, validVersion } from './rdmSettingsShared'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import { randomPassword } from '../passwords'
|
||||
|
||||
// ---- 用户管理(原 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 +34,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 +64,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 +102,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 +150,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>
|
||||
))}
|
||||
@@ -193,9 +249,18 @@ export function PluginsSection() {
|
||||
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
|
||||
{plugins.map(p => (
|
||||
<SettingRow key={p.manifest.id}
|
||||
label={`${p.manifest.name} v${p.manifest.version}`}
|
||||
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
|
||||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||||
label={p.manifest.name + (p.manifest.version ? ` v${p.manifest.version}` : '')}
|
||||
desc={[
|
||||
`工具 ${p.manifest.provides.tools?.length ?? 0}`,
|
||||
`命令 ${p.manifest.provides.commands?.length ?? 0}`,
|
||||
`面板 ${p.panels?.length ?? 0}`,
|
||||
`意图 ${p.intents?.length ?? p.manifest.provides.intents?.length ?? 0}`,
|
||||
`工作流 ${p.workflows?.length ?? 0}`,
|
||||
].join(' · ')}>
|
||||
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
||||
{p.manifest.backend?.mode === 'inproc' && <span className="effect-badge">内置</span>}
|
||||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||||
</span>
|
||||
</SettingRow>
|
||||
))}
|
||||
</div>
|
||||
@@ -231,3 +296,105 @@ export function PluginWorkspacesPanel() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 模型与服务配置(P2 系统受管项的唯一写入面)----
|
||||
|
||||
/**
|
||||
* SystemSettingsPanel:消费 GET/PUT /api/settings/system。
|
||||
* - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施);
|
||||
* 非受管项属于用户/项目偏好,不在本面板出现。
|
||||
* - 保存只提交变化字段与载入版本,未修改的掩码凭据不回传;显式清除发送空值。
|
||||
* 保存后重新 GET 以服务端结果回填。
|
||||
* - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。
|
||||
*/
|
||||
export function SystemSettingsPanel({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void } = {}) {
|
||||
const owner = useStore(s => s.me?.owner)
|
||||
const fence = useRequestFence(owner || '')
|
||||
const [schema, setSchema] = useState<SettingDesc[] | null>(null)
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [initial, setInitial] = useState<Record<string, any>>({})
|
||||
const [defaults, setDefaults] = useState<Record<string, any>>({})
|
||||
const [revision, setRevision] = useState<number | undefined>(undefined)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [forbidden, setForbidden] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(initial)
|
||||
useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false) }, [dirty, onDirtyChange])
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault() }
|
||||
window.addEventListener('beforeunload', beforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', beforeUnload)
|
||||
}, [dirty])
|
||||
|
||||
const load = async () => {
|
||||
const current = fence.begin('load')
|
||||
setErr(''); setForbidden(false)
|
||||
try {
|
||||
const out = await api.getSystemSettings()
|
||||
if (!current()) return false
|
||||
const schema = scopedSchema(out.schema, 'system')
|
||||
const values = scopedDraft(out.system, schema)
|
||||
setSchema(schema); setDraft(values); setInitial(values); setDefaults(flatten(out.defaults))
|
||||
setRevision(out.systemRevision)
|
||||
setLoaded(true)
|
||||
return true
|
||||
} catch (e) {
|
||||
if (!current()) return false
|
||||
if (String(e).includes('403')) { setForbidden(true); setLoaded(true) }
|
||||
else { setErr(readableError(e)); setLoaded(true); setSchema(null) }
|
||||
return false
|
||||
}
|
||||
}
|
||||
useEffect(() => { fence.invalidate(); setLoaded(false); setSchema(null); setDraft({}); setInitial({}); setNotice(''); setBusy(false); void load() }, [owner])
|
||||
|
||||
const groups: Record<string, SettingDesc[]> = {}
|
||||
for (const d of schema ?? []) (groups[d.group] ??= []).push(d)
|
||||
|
||||
const save = async () => {
|
||||
if (!validVersion(revision) || !schema || busy || !dirty) return
|
||||
const current = fence.begin('save')
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
const mutation = settingsMutation(initial, draft, schema)
|
||||
await api.putSystemSettings(mutation.values, revision, mutation.unset)
|
||||
if (current() && await load()) setNotice('系统配置已保存;需重启的项在服务重启后生效')
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
const message = String(e).includes('409') ? '系统配置已被其他管理员修改;请核对最新配置后重新调整' : `保存结果未能确认:${readableError(e)};请核对最新配置后再保存`
|
||||
await load()
|
||||
if (current()) setErr(message)
|
||||
} finally { if (current()) setBusy(false) }
|
||||
}
|
||||
|
||||
if (!loaded) return <EmptyState icon="settings">加载系统设置…</EmptyState>
|
||||
if (forbidden) return <EmptyState icon="shield">系统设置仅管理员可配置</EmptyState>
|
||||
if (!schema) return <><EmptyState icon="settings">{err || '系统设置未能读取'}</EmptyState><Btn onClick={load}>重试</Btn></>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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 || ''} · ${draft[d.key] === undefined ? '沿用系统默认' : d.secret ? '受保护的系统凭据' : '系统配置'}`} effect={d.effect}>
|
||||
<SettingInput desc={{ ...d, default: defaults[d.key] ?? d.default }} value={draft[d.key]} disabled={busy || !validVersion(revision)} onChange={v => { setNotice(''); setDraft(prev => ({ ...prev, [d.key]: v })) }} />
|
||||
{d.secret || d.type === 'secret' ? <Btn disabled={busy || !validVersion(revision)} onClick={() => {
|
||||
if (window.confirm(`清除「${d.label}」配置?保存后该系统凭据将撤销`)) setDraft(previous => ({ ...previous, [d.key]: '' }))
|
||||
}}>清除配置</Btn> : d.type === 'string' ? <Btn disabled={busy || !validVersion(revision) || draft[d.key] === undefined} onClick={() => setDraft(previous => ({ ...previous, [d.key]: undefined }))}>沿用默认</Btn> : null}
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
))}
|
||||
<div className="ad-row" style={{ marginTop: 12 }}>
|
||||
<Btn variant="primary" icon="check" disabled={busy || !validVersion(revision) || !dirty} onClick={save}>保存系统配置</Btn>
|
||||
</div>
|
||||
{!validVersion(revision) && <p className="ad-hint">系统配置版本尚未就绪,暂不能保存</p>}
|
||||
<Feedback error={err} notice={notice} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Icon, type PickItem } from '@ui'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { useStore } from '../store'
|
||||
|
||||
export interface Person {
|
||||
owner: string; name?: string; valid?: boolean; role?: string; projectRole?: string; joined?: boolean
|
||||
profRole?: { roleId: string; name: string } | null
|
||||
dutyEligibility?: { author: { allowed: boolean; reason?: string }; reviewer: { allowed: boolean; reason?: string }; approver: { allowed: boolean; reason?: string } }
|
||||
addedBy?: string; addedAt?: string
|
||||
}
|
||||
export interface MemberList {
|
||||
members: Person[]; next?: string; count?: number; accountDirectoryAvailable?: boolean
|
||||
canManageMembers?: boolean; canAssignDuties?: boolean; canConfigurePermissions?: boolean
|
||||
}
|
||||
interface RoleOption { roleId: string; name: string }
|
||||
export const projectRoleLabel = (role?: string) => role === 'owner' ? '项目负责人' : role === 'member' ? '项目成员' : '未加入项目'
|
||||
export const personLabel = (person: Person) => person.name || person.owner
|
||||
export function personItem(person: Person, adding = false): PickItem {
|
||||
const disabledReason = person.valid !== true ? '账号有效状态尚未核验' : adding && person.joined ? '已加入,请在成员表中调整项目身份' : undefined
|
||||
return { id: person.owner, title: personLabel(person),
|
||||
subtitle: [person.owner, person.profRole?.name, !adding ? projectRoleLabel(person.role || person.projectRole) : ''].filter(Boolean).join(' · '),
|
||||
badge: person.joined && adding ? '已加入' : undefined, disabled: !!disabledReason, disabledReason }
|
||||
}
|
||||
export const selectedPeople = (ids: string[], people: Person[]) => ids.map(id => personItem(people.find(p => p.owner === id) ?? { owner: id }))
|
||||
|
||||
/** A context change invalidates requests immediately, before effects run. */
|
||||
export class RequestFence {
|
||||
private context: string
|
||||
private epoch = 0
|
||||
private sequences = new Map<string, number>()
|
||||
constructor(context: string) { this.context = context }
|
||||
setContext(context: string) { if (context !== this.context) { this.context = context; this.invalidate() } }
|
||||
invalidate() { this.epoch++; this.sequences.clear() }
|
||||
capture() { const epoch = this.epoch; return () => epoch === this.epoch }
|
||||
cancel(channel: string) { this.sequences.set(channel, (this.sequences.get(channel) ?? 0) + 1) }
|
||||
begin(channel: string) {
|
||||
const epoch = this.epoch
|
||||
const seq = (this.sequences.get(channel) ?? 0) + 1
|
||||
this.sequences.set(channel, seq)
|
||||
return () => epoch === this.epoch && seq === this.sequences.get(channel)
|
||||
}
|
||||
}
|
||||
export function useRequestFence(context: string) {
|
||||
const ref = useRef(new RequestFence(context))
|
||||
ref.current.setContext(context)
|
||||
useEffect(() => () => ref.current.invalidate(), [])
|
||||
return ref.current
|
||||
}
|
||||
export function projectContext(project: Project, owner?: string) {
|
||||
return `${owner || ''}:${project.id}:${project.settings?.['rdm.projectId'] || ''}:${project.settings?.['rdm.state'] || ''}`
|
||||
}
|
||||
|
||||
export function useRoleOptions(project: Project, purpose: 'members' | 'duties' | 'permissions', enabled: boolean) {
|
||||
const owner = useStore(s => s.me?.owner)
|
||||
const context = projectContext(project, owner)
|
||||
const fence = useRequestFence(context)
|
||||
const [roles, setRoles] = useState<RoleOption[]>([])
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
const current = fence.begin('roles')
|
||||
setRoles([]); setError('')
|
||||
if (!enabled) return
|
||||
api.rdmProjectCommand<{ roles: RoleOption[] }>(project, 'rdm.role.options', { purpose })
|
||||
.then(out => { if (current()) { if (!Array.isArray(out.roles)) throw new Error('专业角色选项接口未就绪'); setRoles(out.roles) } })
|
||||
.catch(() => { if (current()) setError('专业角色筛选暂不可用,请重试') })
|
||||
return () => fence.cancel('roles')
|
||||
}, [context, purpose, enabled])
|
||||
return { roles, error }
|
||||
}
|
||||
export function RoleFilter({ roles, value, onChange, disabled = false }: {
|
||||
roles: RoleOption[]; value: string; onChange: (role: string) => void; disabled?: boolean
|
||||
}) {
|
||||
return <select aria-label="专业角色筛选" className="lg-input" value={value} disabled={disabled} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">全部专业角色</option>
|
||||
{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
|
||||
</select>
|
||||
}
|
||||
export function Feedback({ error, notice }: { error?: string; notice?: string }) {
|
||||
return <>{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
|
||||
{error && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{error}</div>}</>
|
||||
}
|
||||
export function readableError(error: unknown): string {
|
||||
const raw = String(error).replace(/^(?:Error|TypeError):\s*/, '')
|
||||
if (raw.includes('unknown or disabled command')) return '该功能尚未装配到当前服务,请升级服务后重新打开'
|
||||
const text = raw.replace(/^\d{3}:\s*/, '')
|
||||
try {
|
||||
const body = JSON.parse(text)
|
||||
if (typeof body?.receipt?.reason === 'string') return readableError(body.receipt.reason.replace(/^(?:rejected|failed):\s*/i, ''))
|
||||
if (typeof body.error === 'string') return readableError(body.error)
|
||||
if (typeof body.message === 'string') return readableError(body.message)
|
||||
if (body && typeof body === 'object') return '操作暂未完成,请重新读取状态后再试'
|
||||
} catch { /* A plain service message is also valid. */ }
|
||||
if (/[\u4e00-\u9fff]/.test(text)) return text
|
||||
if (raw.startsWith('403:') || /denied|forbidden/i.test(raw)) return '当前账号没有权限执行此操作,请联系项目负责人核对'
|
||||
if (raw.startsWith('409:') || /version.*conflict|revision.*mismatch/i.test(raw)) return '数据已经更新,请核对最新结果后重新操作'
|
||||
if (/fetch|network|connection/i.test(raw)) return '网络连接中断,请核对最新状态;写入结果可能尚未确认'
|
||||
return '操作暂未完成,请重新读取状态后再试'
|
||||
}
|
||||
export const validVersion = (version: unknown): version is number => typeof version === 'number' && Number.isSafeInteger(version) && version >= 0
|
||||
@@ -270,6 +270,8 @@ export interface SettingDesc {
|
||||
default?: unknown; options?: string[];
|
||||
effect: 'hot' | 'new-run' | 'restart';
|
||||
secret?: boolean;
|
||||
/** Setting ownership; an omitted scope retains legacy personal compatibility. */
|
||||
scope?: 'system' | 'user' | 'project' | 'project-personal';
|
||||
}
|
||||
|
||||
// ---- 插件 ----
|
||||
|
||||
+4
-4
@@ -29,7 +29,7 @@ export type LiveItem =
|
||||
| { kind: 'check'; passed: boolean; reason: string }
|
||||
| { kind: 'final' }
|
||||
|
||||
export type LiveTool = Extract<LiveItem, { kind: 'tool' }>
|
||||
type LiveTool = Extract<LiveItem, { kind: 'tool' }>
|
||||
|
||||
const OUTPUT_TAIL = 4096
|
||||
|
||||
@@ -246,9 +246,9 @@ export function livePhaseLabel(live: LiveState, now = Date.now()): string {
|
||||
return '回复中'
|
||||
}
|
||||
|
||||
// showProcessInline:运行中时间线始终展开。run 结束后随「自动折叠过程」偏好收成一行。
|
||||
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
|
||||
return live != null || !autoCollapse
|
||||
// 默认阅读层只显示阶段;运行中也把技术记录留给用户主动展开。
|
||||
export function showProcessInline(_live: LiveState | null, autoCollapse: boolean): boolean {
|
||||
return !autoCollapse
|
||||
}
|
||||
|
||||
// hasSettledReply:助手回复落库后,这个 run 的实时气泡才该收起。
|
||||
|
||||
+11
-15
@@ -1,5 +1,5 @@
|
||||
// 消息切分:过程时间线 = 从开头到最后一个 process 部件(中间夹着的内容部件留在时间线里);
|
||||
// 最终段 = 它之后的正文与交付卡片。渲染顺序仍是落库顺序,只在这一刀上折叠。
|
||||
// 消息切分:技术记录留在过程时间线,正文、错误与文件入口始终直接可读。
|
||||
// 两区各自保留落库顺序,后续工具或终轮检查不再把解说藏入折叠区。
|
||||
import type { Part } from './contracts'
|
||||
|
||||
export interface TimelineSplit {
|
||||
@@ -7,22 +7,18 @@ export interface TimelineSplit {
|
||||
final: Part[]
|
||||
}
|
||||
|
||||
/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。
|
||||
* promoteLastText:回复区没有正文时,把时间线里最后一段解说提上来。失败或被截断的 run 用它保住可见回复。 */
|
||||
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
|
||||
/** 最后一个 process 部件之前的技术记录留在时间线,其余为可见内容。
|
||||
* 保留旧 promoteLastText 参数以兼容调用方;解说现在始终可见,不再依赖终态提升。 */
|
||||
export function splitTimeline(parts: Part[], _promoteLastText = false): TimelineSplit {
|
||||
let last = -1
|
||||
for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i
|
||||
if (last < 0) return { timeline: [], final: parts }
|
||||
const timeline = parts.slice(0, last + 1)
|
||||
const final = parts.slice(last + 1)
|
||||
if (!promoteLastText || final.some(p => p.type === 'text' && (p.text ?? '').trim())) return { timeline, final }
|
||||
let textAt = -1
|
||||
for (let i = timeline.length - 1; i >= 0; i--) {
|
||||
if (timeline[i].type === 'text' && (timeline[i].text ?? '').trim()) { textAt = i; break }
|
||||
}
|
||||
if (textAt < 0) return { timeline, final }
|
||||
const lifted = { ...timeline[textAt], process: undefined }
|
||||
return { timeline: timeline.filter((_, i) => i !== textAt), final: [lifted, ...final] }
|
||||
// text 是公开解说;thinking、工具参数与结果、终轮检查不提升为正文。
|
||||
const visible = (part: Part) => part.type === 'text' || part.type === 'error' || part.type === 'artifact_ref'
|
||||
const before = parts.slice(0, last + 1)
|
||||
const timeline = before.filter(part => !visible(part))
|
||||
const final = [...before.filter(visible), ...parts.slice(last + 1)]
|
||||
return { timeline, final }
|
||||
}
|
||||
|
||||
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
|
||||
|
||||
@@ -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*/, '')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// 审计命令错误分类(2026-10-12 增量 2):RdmAuditWorkspace 不再直出原始报错
|
||||
//(普通基座上曾是 `400: {"receipt":…unknown or disabled command…}`),改为
|
||||
//「能力缺失 / 登录已失效 / 查询失败」人话文案;raw 始终保留供「原始回执」折叠区诊断。
|
||||
// 依据:api.ts json() 对非 2xx 抛 `${status}: ${body}`;后端 unknown/disabled 命令回
|
||||
// 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
|
||||
// 503 "commands disabled"(server.go execCommand)。
|
||||
|
||||
type RdmErrorKind = 'capability' | 'auth' | 'query'
|
||||
|
||||
interface RdmErrorView {
|
||||
kind: RdmErrorKind
|
||||
message: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
export function classifyRdmError(err: unknown): RdmErrorView {
|
||||
const raw = err instanceof Error ? err.message : String(err)
|
||||
if (/^40[13]:/.test(raw)) {
|
||||
return { kind: 'auth', message: '登录已失效,请重新登录后再查看追溯审计', raw }
|
||||
}
|
||||
if (/unknown or disabled command|commands disabled/.test(raw)) {
|
||||
return { kind: 'capability', message: '当前服务未提供审计能力(RDM 命令未注册或已停用)', raw }
|
||||
}
|
||||
return { kind: 'query', message: '查询失败,请稍后重试', raw }
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// U6/KTD5:RDM 管理与项目页面的纯逻辑层——能力门控(与 rdmAuditReady 同源语义:
|
||||
// 命令清单不可用=未就绪,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',
|
||||
'rdm.duty.tree', 'rdm.role.options', 'rdm.account.list', 'rdm.project.member.impact',
|
||||
])
|
||||
|
||||
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 = [
|
||||
'rdm.role.create',
|
||||
'rdm.role.action',
|
||||
'rdm.role.assign',
|
||||
'rdm.role.disable',
|
||||
'rdm.role.list',
|
||||
] as const
|
||||
|
||||
/** 项目设置「成员」页消费的命令面。 */
|
||||
export const RDM_MEMBER_COMMANDS = [
|
||||
'rdm.project.member.list',
|
||||
'rdm.project.member.add',
|
||||
'rdm.project.member.remove',
|
||||
'rdm.project.member.set-role', 'rdm.project.member.impact', 'rdm.account.list', 'rdm.role.options',
|
||||
] as const
|
||||
|
||||
/** 项目设置「项目权限」页消费的命令面(矩阵解释 + 成员清单)。 */
|
||||
export const RDM_PERM_MATRIX_COMMANDS = [
|
||||
'rdm.perm.check',
|
||||
'rdm.project.member.list',
|
||||
'rdm.perm.matrix', 'rdm.perm.set', 'rdm.perm.unset', 'rdm.role.options',
|
||||
] as const
|
||||
|
||||
/** 项目设置「文档职责 · 整文审签」页消费的命令面。 */
|
||||
export const RDM_DOC_REVIEW_COMMANDS = [
|
||||
'rdm.review.assign.doc',
|
||||
'rdm.review.doc.submit',
|
||||
'rdm.review.doc.approve',
|
||||
'rdm.review.doc.reject',
|
||||
'rdm.review.doc.final',
|
||||
'rdm.review.doc.status',
|
||||
'rdm.duty.catalog', 'rdm.duty.tree', 'rdm.duty.get', 'rdm.project.member.list',
|
||||
'rdm.role.options', 'rdm.review.assign', 'rdm.review.assign.doc.batch',
|
||||
] as const
|
||||
|
||||
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
|
||||
|
||||
function ready(cmds: CommandListItem[] | null | undefined, need: readonly string[]): boolean {
|
||||
if (!Array.isArray(cmds)) return false
|
||||
return need.every(n => cmds.some(c => c?.id === n && c.enabled === true))
|
||||
}
|
||||
|
||||
/** 角色分区是否就绪(普通基座无 rdm.role.* → 隐藏)。 */
|
||||
export const rdmRolesReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
ready(cmds, RDM_ROLES_COMMANDS)
|
||||
|
||||
/** 项目成员页是否就绪。 */
|
||||
export const rdmMembersReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
ready(cmds, RDM_MEMBER_COMMANDS)
|
||||
|
||||
/** 文档职责/整文审签页是否就绪。 */
|
||||
export const rdmDocReviewReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
ready(cmds, RDM_DOC_REVIEW_COMMANDS)
|
||||
|
||||
/** 项目权限矩阵页是否就绪。 */
|
||||
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
ready(cmds, RDM_PERM_MATRIX_COMMANDS)
|
||||
|
||||
/** 旧动作目录用于只读占位;可编辑目录及当前权限由服务端返回。 */
|
||||
export const ROLE_ACTIONS = [
|
||||
'project.create', 'project.archive', 'member.manage', 'duty.assign',
|
||||
'stage.advance', 'doc.read', 'doc.write', 'doc.publish', 'review.participate',
|
||||
'trace.edit', 'knowledge.publish', 'project.settings',
|
||||
] as const
|
||||
|
||||
// ---- 载荷构造(字段名与 RDM 命令 schema 一一对应) ----
|
||||
|
||||
export const roleCreatePayload = (name: string) => ({ name })
|
||||
export const roleActionPayload = (roleId: string, action: string, def: boolean, cap: boolean) =>
|
||||
({ roleId, action, default: def, cap })
|
||||
export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId })
|
||||
export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled })
|
||||
|
||||
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,
|
||||
expectedDutyVersion: number, confirmInvalidation = false,
|
||||
) => ({ projectId, docId, author, reviewers, approver, expectedDutyVersion, confirmInvalidation })
|
||||
|
||||
export const docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId })
|
||||
export const docApprovePayload = (projectId: string, docId: string, note?: string) =>
|
||||
note ? { projectId, docId, note } : { projectId, docId }
|
||||
export const docRejectPayload = (projectId: string, docId: string, reason?: string) =>
|
||||
reason ? { projectId, docId, reason } : { projectId, docId }
|
||||
/** round=0(未指定)不携带——服务端只认在途轮次,旧轮次传参即拒。 */
|
||||
export const docFinalPayload = (projectId: string, docId: string, round = 0) =>
|
||||
round > 0 ? { projectId, docId, round } : { projectId, docId }
|
||||
export const docStatusPayload = (projectId: string, docId: string) => ({ projectId, docId })
|
||||
|
||||
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
|
||||
|
||||
/** 内部来源码只进入按需技术详情。 */
|
||||
export function describeSource(source: string): string {
|
||||
switch (source) {
|
||||
case 'admin': return '系统管理员'
|
||||
case 'prof-role-default': return '专业角色默认'
|
||||
case 'override': return '项目内覆盖'
|
||||
case 'relation-default': return '项目关系默认'
|
||||
case 'non-member': return '非项目成员'
|
||||
case 'scope': return '配置范围不符'
|
||||
case 'error': return '暂时无法核验'
|
||||
case 'cap': case 'cap-denied': return '管理员权限上限'
|
||||
case 'project': return '项目内配置'
|
||||
case 'default': return '角色默认'
|
||||
default: return '来源未提供'
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
'project.create': '创建项目', 'project.archive': '归档项目', 'member.manage': '管理项目成员',
|
||||
'duty.assign': '分配文档职责', 'stage.advance': '推进项目阶段', 'doc.read': '读取文档',
|
||||
'doc.write': '编制与编辑文档', 'doc.publish': '发布受控文档', 'review.participate': '参与文档审签',
|
||||
'trace.edit': '编辑追溯关系', 'knowledge.publish': '发布项目知识', 'project.settings': '配置项目设置',
|
||||
}
|
||||
|
||||
export function actionLabel(action: string, label?: string): string {
|
||||
return ACTION_LABELS[action] || (label && label !== action ? label : '其他项目操作')
|
||||
}
|
||||
|
||||
/** 角色清单表格占位行文案:加载中仅在无错误时成立——rdm.role.list 被拒后
|
||||
* 停留「加载中…」是 2026-10-13 8788 锁死事故的误导性表象(表格永远在转圈)。 */
|
||||
export function rolesListPlaceholder(roles: unknown[] | null, err: string): string {
|
||||
if (roles !== null) return roles.length === 0 ? '暂无专业角色' : ''
|
||||
return err ? '角色清单未加载(见下方提示)' : '加载中…'
|
||||
}
|
||||
|
||||
/** 角色管理须有独立系统权限;不引导使用已退出此链路的台账管理员移交。 */
|
||||
export function describeRoleListError(raw: string): string {
|
||||
if (raw.includes('不是系统管理员')) {
|
||||
return '当前账号无权管理系统专业角色,请联系具有角色管理权限的系统管理员'
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** 审阅人 CSV 解析:去空白、去空项、保序去重(表单输入 → reviewers 数组)。 */
|
||||
export function parseReviewers(csv: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const part of csv.split(',')) {
|
||||
const v = part.trim()
|
||||
if (!v || seen.has(v)) continue
|
||||
seen.add(v)
|
||||
out.push(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
+577
-81
File diff suppressed because it is too large
Load Diff
@@ -512,6 +512,17 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.ad-table tr:last-child td { border-bottom: none; }
|
||||
.rdm-settings .ad-row { flex-wrap: wrap; align-items: center; }
|
||||
.rdm-settings .ad-row .lg-input { flex: 1 1 160px; }
|
||||
.rdm-settings .ad-row .picker { flex: 2 1 220px; min-width: 180px; }
|
||||
.rdm-settings .ui-setting-control .picker { width: 100%; }
|
||||
.rdm-filter-row { margin: 12px 0; }
|
||||
.rdm-table-scroll { overflow-x: auto; margin: 12px 0; }
|
||||
.rdm-table-scroll th { white-space: nowrap; }
|
||||
.rdm-table-scroll td { vertical-align: top; min-width: 100px; }
|
||||
.rdm-table-scroll td:first-child { min-width: 48px; }
|
||||
.rdm-duty-tree { list-style: none; padding-left: 16px; margin: 8px 0; }
|
||||
.rdm-duty-tree li { margin: 8px 0; }
|
||||
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.ad-dim { color: var(--text-dim); }
|
||||
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
|
||||
@@ -577,3 +588,65 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.session-note { width: 12rem; }
|
||||
.rewind-actions { display: flex; gap: 8px; }
|
||||
.rewind-error { color: var(--err-text); font-size: var(--fs-ui-sm); }
|
||||
|
||||
/* ---- U4 追溯审计工作区(令牌驱动;@ui/tokens.css 消费) ---- */
|
||||
.rdm-audit { display: flex; flex-direction: column; min-height: 300px; gap: 0; }
|
||||
.rdm-audit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
|
||||
.rdm-audit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-tabs { padding: 0 0 10px; }
|
||||
.rdm-audit-body { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
|
||||
.rdm-audit-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; margin-bottom: 12px; }
|
||||
.rdm-audit-card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; text-align: center; }
|
||||
.rdm-audit-card-num { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
|
||||
.rdm-audit-card-label { font-size: var(--fs-ui-xs); color: var(--text-dim); margin-top: 2px; }
|
||||
.rdm-audit-meta { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 8px; }
|
||||
.rdm-audit-materials { display: flex; gap: 0; min-height: 200px; }
|
||||
.rdm-audit-materials-side { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.rdm-audit-tree { display: flex; gap: 14px; min-height: 200px; }
|
||||
.rdm-audit-tree-list { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.rdm-audit-tree-node { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: none; border: none; border-radius: var(--radius-sm); cursor: pointer; text-align: left; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.rdm-audit-tree-node:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.rdm-audit-tree-node.sel { background: var(--bg-active); color: var(--text); }
|
||||
.rdm-audit-tree-caret { width: 14px; flex: none; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-tree-children { padding-left: 18px; display: flex; flex-direction: column; gap: 0; }
|
||||
.rdm-audit-tree-kind { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 3.2em; }
|
||||
.rdm-audit-tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rdm-audit-more { margin-top: 8px; }
|
||||
.rdm-audit-truncated { font-size: var(--fs-ui-sm); color: var(--text-faint); padding: 6px 0; }
|
||||
.rdm-audit-detail { flex: 1; min-width: 0; border-left: 1px solid var(--border-soft); padding-left: 14px; }
|
||||
.rdm-audit-detail-body { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-version { padding: 3px 0; color: var(--text-dim); }
|
||||
.rdm-audit-formal { margin-top: 8px; padding: 8px 10px; background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--ok-text); }
|
||||
.rdm-audit-pick { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.rdm-audit-trace-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.rdm-audit-trace-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.rdm-audit-trace-col { min-width: 0; }
|
||||
.rdm-audit-rel { display: flex; align-items: center; gap: 6px; padding: 4px 0; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-rel-type { color: var(--text-dim); }
|
||||
.rdm-audit-rel-endpoint { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rdm-audit-timeline-filters { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.rdm-audit-timeline-filters .ui-input { max-width: 160px; height: 30px; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-seq { max-width: 100px; }
|
||||
.rdm-audit-opgroup { border: 1px solid var(--border-soft); border-radius: var(--radius-sm); margin-bottom: 4px; overflow: hidden; }
|
||||
.rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; }
|
||||
.rdm-audit-opgroup-head:hover { background: var(--bg-hover); }
|
||||
.rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); }
|
||||
.rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); }
|
||||
.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
|
||||
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; }
|
||||
.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; }
|
||||
.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); }
|
||||
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
+140
-4
@@ -1,13 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as apiModule from './api'
|
||||
import { api } from './api'
|
||||
import { store } from './store'
|
||||
import type { StreamEvent, SurfaceSnapshot } from './contracts'
|
||||
import { previewTabsKey } from './previewtabs'
|
||||
import type { StreamEvent, SurfaceMeta, SurfaceSnapshot } from './contracts'
|
||||
|
||||
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
|
||||
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
|
||||
|
||||
function surfEvent(seq: number, surfaceId: string, revision: number, title: string): StreamEvent<SurfaceSnapshot> {
|
||||
function surfEvent(seq: number, surfaceId: string, revision: number, title: string, conversationId = 'c1'): StreamEvent<SurfaceSnapshot> {
|
||||
return {
|
||||
seq, conversationId: 'c1', ts: new Date().toISOString(), v: 1, type: 'surface.updated',
|
||||
seq, conversationId, ts: new Date().toISOString(), v: 1, type: 'surface.updated',
|
||||
payload: {
|
||||
identity: { surfaceId, owner: 'alice', pluginId: 'generative-ui', conversationId: 'c1' },
|
||||
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' },
|
||||
@@ -17,6 +20,24 @@ function surfEvent(seq: number, surfaceId: string, revision: number, title: stri
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
|
||||
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => ({
|
||||
conversation: { id, projectId: 'p1', title: id, createdAt: '', updatedAt: '' },
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0,
|
||||
}))
|
||||
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'listCards').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listTasks').mockResolvedValue([])
|
||||
vi.spyOn(apiModule, 'connectEvents').mockReturnValue({ close: () => {} })
|
||||
await store.openConversation('c1')
|
||||
for (const tab of store.getState().previewTabs.c1 ?? []) store.closePreview(tab.id)
|
||||
store.setUI({ rightTab: 'work', rightCollapsed: true, filesView: 'list' })
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('surface.updated projection', () => {
|
||||
it('新事件入库;旧修订与重复事件不覆盖', () => {
|
||||
store.applyEvent(surfEvent(1, 's1', 1, '初版'))
|
||||
@@ -30,6 +51,7 @@ describe('surface.updated projection', () => {
|
||||
})
|
||||
|
||||
it('多页面并存,互不覆盖', () => {
|
||||
store.applyEvent(surfEvent(1, 's1', 1, '页面一'))
|
||||
store.applyEvent(surfEvent(5, 's2', 1, '页面二'))
|
||||
const list = store.getState().surfaces
|
||||
expect(list.filter(x => String(x.identity.surfaceId) === 's1')).toHaveLength(1)
|
||||
@@ -37,3 +59,117 @@ describe('surface.updated projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('surface.updated 可见性联动', () => {
|
||||
it('keeps new background pages available without opening the file pane', () => {
|
||||
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
|
||||
const ui = store.getState().ui
|
||||
expect(ui.rightTab).toBe('work')
|
||||
expect(ui.rightCollapsed).toBe(true)
|
||||
expect(ui.filesView).toBe('list')
|
||||
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
|
||||
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(false)
|
||||
store.openSurface('sv1')
|
||||
expect(store.getState().ui.rightTab).toBe('files')
|
||||
expect(store.getState().previewActive.c1).toBe('surface:sv1')
|
||||
})
|
||||
|
||||
it('抑制开标签时留 toast 痕迹', () => {
|
||||
// 预置:已有 surface 标签但 active 是别的工件标签 → 新页面不开标签
|
||||
store.applyEvent(surfEvent(102, 'sv2', 1, '第二页'))
|
||||
// sv2 已入 surfaces 数组(事件投影),标签未强开——至少状态一致
|
||||
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('U6 工作区任务页', () => {
|
||||
it('pin 固定后新页面事件不改变固定对象;取消后跟随最新', () => {
|
||||
store.setUI({ workspaceSurfacePin: null })
|
||||
store.applyEvent(surfEvent(201, 'w1', 1, '第一页'))
|
||||
const first = store.getState().ui.workspaceSurfacePin
|
||||
expect(first).toBeNull() // 默认跟随(不自动 pin)
|
||||
// 用户固定 w1
|
||||
store.setUI({ workspaceSurfacePin: 'w1' })
|
||||
store.applyEvent(surfEvent(202, 'w2', 1, '第二页'))
|
||||
// pin 不变,且 surfaces 数组两页并存(禁用回退时容器随空数组自然消失)
|
||||
expect(store.getState().ui.workspaceSurfacePin).toBe('w1')
|
||||
const w2 = store.getState().surfaces.find(x => String(x.identity.surfaceId) === 'w2')
|
||||
expect(w2?.spec.title).toBe('第二页')
|
||||
store.setUI({ workspaceSurfacePin: null })
|
||||
expect(store.getState().ui.workspaceSurfacePin).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('saved surfaces on conversation reload', () => {
|
||||
const snapshot = (id: string, revision = 1) => surfEvent(1, id, revision, id).payload!
|
||||
const meta = (id: string): SurfaceMeta => ({
|
||||
surfaceId: id, title: id, pluginId: 'generative-ui', revision: 1,
|
||||
conversationId: 'c1', updatedAt: '2026-10-12T00:00:00Z',
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceGet').mockImplementation(async id => snapshot(id))
|
||||
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => ({
|
||||
conversation: { id, projectId: 'p1', title: id, createdAt: '', updatedAt: '' },
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0,
|
||||
}))
|
||||
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'listCards').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listTasks').mockResolvedValue([])
|
||||
vi.spyOn(apiModule, 'connectEvents').mockReturnValue({ close: () => {} })
|
||||
store.setUI({ workspaceSurfacePin: null })
|
||||
const key = previewTabsKey(store.getState().currentConvId)
|
||||
for (const tab of store.getState().previewTabs[key] ?? []) store.closePreview(tab.id)
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('hydrates the metadata directory before exposing two renderable snapshots', async () => {
|
||||
vi.mocked(api.surfaceList).mockResolvedValue([meta('reload-1'), meta('reload-2')])
|
||||
await store.openConversation('reload')
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().surfaces).toEqual([snapshot('reload-1'), snapshot('reload-2')])
|
||||
})
|
||||
expect(api.surfaceGet).toHaveBeenCalledWith('reload-1')
|
||||
expect(api.surfaceGet).toHaveBeenCalledWith('reload-2')
|
||||
expect(store.getState().previewActive.reload).toBe('surface:reload-1')
|
||||
})
|
||||
|
||||
it('isolates one unavailable page while restoring the remaining page', async () => {
|
||||
vi.mocked(api.surfaceList).mockResolvedValue([meta('missing'), meta('available')])
|
||||
vi.mocked(api.surfaceGet).mockImplementation(async id => {
|
||||
if (id === 'missing') throw new Error('404: removed page')
|
||||
return snapshot(id)
|
||||
})
|
||||
await store.openConversation('partial')
|
||||
await vi.waitFor(() => expect(store.getState().surfaces).toEqual([snapshot('available')]))
|
||||
})
|
||||
|
||||
it('keeps a newer live revision when an older snapshot arrives late', async () => {
|
||||
let resolve!: (value: SurfaceSnapshot) => void
|
||||
vi.mocked(api.surfaceList).mockResolvedValue([meta('racing')])
|
||||
vi.mocked(api.surfaceGet).mockReturnValue(new Promise<SurfaceSnapshot>(r => { resolve = r }))
|
||||
await store.openConversation('race')
|
||||
store.applyEvent(surfEvent(301, 'racing', 2, 'live revision', 'race'))
|
||||
resolve(snapshot('racing', 1))
|
||||
await new Promise<void>(r => setTimeout(r, 0))
|
||||
expect(store.getState().surfaces).toHaveLength(1)
|
||||
expect(store.getState().surfaces[0].revision).toBe(2)
|
||||
expect(store.getState().surfaces[0].spec.title).toBe('live revision')
|
||||
})
|
||||
|
||||
it('discards hydration from the previous conversation after switching', async () => {
|
||||
let resolve!: (value: SurfaceSnapshot) => void
|
||||
vi.mocked(api.surfaceList).mockResolvedValueOnce([meta('previous')]).mockResolvedValue([])
|
||||
vi.mocked(api.surfaceGet).mockReturnValue(new Promise<SurfaceSnapshot>(r => { resolve = r }))
|
||||
await store.openConversation('previous-conv')
|
||||
await store.openConversation('current-conv')
|
||||
resolve(snapshot('previous'))
|
||||
await new Promise<void>(r => setTimeout(r, 0))
|
||||
expect(store.getState().currentConvId).toBe('current-conv')
|
||||
expect(store.getState().surfaces).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// surface 投影可能短暂混入无 identity 的条目;读 id 一律经此助手,避免渲染崩溃。
|
||||
import type { SurfaceSnapshot } from './contracts'
|
||||
|
||||
export function surfaceKey(x: { identity?: { surfaceId?: unknown } } | null | undefined): string | null {
|
||||
const id = x?.identity?.surfaceId
|
||||
return id == null || id === '' ? null : String(id)
|
||||
}
|
||||
|
||||
/** 按 surfaceId 合并;同 id 取 revision 较大者。跳过无 identity 的脏项。 */
|
||||
export function mergeSurfacesByRevision(a: SurfaceSnapshot[], b: SurfaceSnapshot[]): SurfaceSnapshot[] {
|
||||
const byId = new Map<string, SurfaceSnapshot>()
|
||||
for (const s of [...a, ...b]) {
|
||||
const id = surfaceKey(s)
|
||||
if (!id) continue
|
||||
const existing = byId.get(id)
|
||||
if (!existing || (s.revision ?? 0) >= (existing.revision ?? 0)) byId.set(id, s)
|
||||
}
|
||||
return [...byId.values()]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// U5 验收线第 4/6 条的客户端半边:请求构造只携带显式动作数据(未提交草稿
|
||||
// 无任何自动上传路径——构造函数是纯函数,测试其形状);requestId 每次唯一。
|
||||
|
||||
function buildActionRequest(snapshot: { revision: number }, actionId: string, drafts: Record<string, unknown>) {
|
||||
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now())
|
||||
return {
|
||||
actionId,
|
||||
revision: snapshot.revision,
|
||||
requestId,
|
||||
data: drafts,
|
||||
}
|
||||
}
|
||||
|
||||
describe('surface action request', () => {
|
||||
it('携带当前修订与显式草稿;requestId 唯一', () => {
|
||||
const snap = { revision: 3 }
|
||||
const a = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
|
||||
const b = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
|
||||
expect(a.revision).toBe(3)
|
||||
expect(a.actionId).toBe('generative-ui.submit')
|
||||
expect(a.data).toEqual({ f1: { years: '5' } })
|
||||
expect(a.requestId).not.toBe(b.requestId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface WorkbenchLayoutOptions {
|
||||
width: number
|
||||
contextOpen: boolean
|
||||
leftCollapsed: boolean
|
||||
rightCollapsed: boolean
|
||||
leftWidth: number
|
||||
rightWidth: number
|
||||
previewWidth: number
|
||||
rightTab: 'work' | 'files'
|
||||
filesView: 'list' | 'preview'
|
||||
previewOpen: boolean
|
||||
previewExpanded: boolean
|
||||
}
|
||||
|
||||
export type WorkbenchPane = 'context' | 'conversation' | 'work' | 'files'
|
||||
const COMPACT_WORKBENCH_WIDTH = 1024
|
||||
|
||||
function boundedWidth(value: number, fallback: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, Number.isFinite(value) ? value : fallback))
|
||||
}
|
||||
|
||||
// Only the rendered widths are bounded. Saved desktop preferences stay in the store.
|
||||
export function selectWorkbenchLayout(options: WorkbenchLayoutOptions) {
|
||||
const width = Number.isFinite(options.width) ? Math.max(0, options.width) : 1440
|
||||
const available = Math.max(0, width - 8)
|
||||
const compact = width < COMPACT_WORKBENCH_WIDTH
|
||||
const filesWide = options.rightTab === 'files' && options.filesView === 'preview' && options.previewOpen
|
||||
const previewExpanded = filesWide && options.previewExpanded && !options.rightCollapsed
|
||||
if (compact) {
|
||||
const pane: WorkbenchPane = options.contextOpen ? 'context' : options.rightCollapsed ? 'conversation' : options.rightTab
|
||||
return {
|
||||
compact, pane, showContext: pane === 'context', showConversation: pane === 'conversation',
|
||||
showRight: pane === 'work' || pane === 'files', contextCollapsed: false, previewExpanded,
|
||||
leftWidth: pane === 'context' ? available : 0,
|
||||
rightWidth: pane === 'work' || pane === 'files' ? available : 0,
|
||||
leftDivider: false, rightDivider: false,
|
||||
}
|
||||
}
|
||||
|
||||
const showRight = !options.rightCollapsed
|
||||
const leftDivider = !options.leftCollapsed
|
||||
const rightDivider = showRight && !previewExpanded
|
||||
const panelCount = 1 + Number(!previewExpanded) + Number(showRight)
|
||||
const gutters = (panelCount - 1) * 4 + (Number(leftDivider) + Number(rightDivider)) * 8
|
||||
const leftMin = options.leftCollapsed ? 52 : 220
|
||||
const rightMin = showRight ? filesWide ? 420 : 280 : 0
|
||||
let leftWidth = options.leftCollapsed ? 52 : boundedWidth(options.leftWidth, 260, leftMin, 420)
|
||||
let rightWidth = showRight
|
||||
? filesWide ? boundedWidth(options.previewWidth, 700, rightMin, 1100) : boundedWidth(options.rightWidth, 360, rightMin, 560)
|
||||
: 0
|
||||
if (previewExpanded) rightWidth = available - gutters - leftWidth
|
||||
else {
|
||||
const conversationMin = filesWide && showRight ? 320 : 360
|
||||
const budget = available - gutters - conversationMin
|
||||
if (leftWidth + rightWidth > budget) {
|
||||
const extraLeft = leftWidth - leftMin
|
||||
const extraRight = rightWidth - rightMin
|
||||
const ratio = Math.max(0, budget - leftMin - rightMin) / (extraLeft + extraRight || 1)
|
||||
leftWidth = leftMin + Math.floor(extraLeft * ratio)
|
||||
rightWidth = rightMin + Math.floor(extraRight * ratio)
|
||||
}
|
||||
}
|
||||
const pane: WorkbenchPane = previewExpanded ? 'files' : 'conversation'
|
||||
return {
|
||||
compact, pane,
|
||||
showContext: true, showConversation: !previewExpanded, showRight, contextCollapsed: options.leftCollapsed,
|
||||
previewExpanded, leftWidth, rightWidth, leftDivider, rightDivider,
|
||||
}
|
||||
}
|
||||
+46
-1
@@ -1,6 +1,9 @@
|
||||
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
|
||||
// 内置「代理」补进空位。文件浏览在第三栏「文件」入口(方案:入口收敛),不再占体系工具位。
|
||||
// 插件管理面板不进这条清单。
|
||||
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
|
||||
// 没有任何 rdm.inspect.* 命令,入口可达而进入即 400。仅当命令面探测通过
|
||||
//(rdmAuditReady / store.rdmAuditAvailable)才纳入;代理仍无条件以 ⌘1 补位。
|
||||
|
||||
export interface WorkspaceEntry {
|
||||
key: string
|
||||
@@ -16,8 +19,44 @@ export interface WorkspaceSource {
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
/** 审计工作区消费的全部 rdm.inspect.* 命令(capabilities 入口 + 四页签取数)。 */
|
||||
export const RDM_AUDIT_COMMANDS = [
|
||||
'rdm.inspect.capabilities',
|
||||
'rdm.inspect.summary',
|
||||
'rdm.inspect.tree',
|
||||
'rdm.inspect.object',
|
||||
'rdm.inspect.relations',
|
||||
'rdm.inspect.events',
|
||||
'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
|
||||
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
|
||||
}
|
||||
|
||||
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
|
||||
{ key: 'agents', title: '代理', icon: '🤖' },
|
||||
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
|
||||
]
|
||||
|
||||
function shortcutNum(raw?: string): number | null {
|
||||
@@ -28,7 +67,12 @@ function shortcutNum(raw?: string): number | null {
|
||||
return n >= 1 && n <= 9 ? n : null
|
||||
}
|
||||
|
||||
export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry[] {
|
||||
interface BuildWorkspaceOptions {
|
||||
/** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */
|
||||
rdmAudit?: boolean
|
||||
}
|
||||
|
||||
export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] {
|
||||
const used = new Set<number>()
|
||||
const ranked: { rank: number; entry: Omit<WorkspaceEntry, 'shortcut'> }[] = []
|
||||
const unranked: Omit<WorkspaceEntry, 'shortcut'>[] = []
|
||||
@@ -56,6 +100,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry
|
||||
return 99
|
||||
}
|
||||
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
|
||||
if (opts.rdmAudit === true) ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
|
||||
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
|
||||
return ranked
|
||||
.filter(item => item.rank <= 9)
|
||||
|
||||
@@ -40,4 +40,15 @@ describe('auth 凭证模块', () => {
|
||||
expect(auth.apiKey()).toBe('')
|
||||
expect(auth.withKey('/x')).toBe('/x')
|
||||
})
|
||||
|
||||
it('beginSession:密码会话生效后清残留 Bearer,fetch 不再带头', async () => {
|
||||
auth.setKey('ask_stale')
|
||||
auth.beginSession()
|
||||
expect(auth.apiKey()).toBe('')
|
||||
const spy = vi.fn().mockResolvedValue(new Response('{}'))
|
||||
vi.stubGlobal('fetch', spy)
|
||||
await auth.fetch('/api/me')
|
||||
const [, init] = spy.mock.calls[0] as [string, RequestInit | undefined]
|
||||
expect(new Headers(init?.headers ?? {}).get('Authorization')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { setPersistedOpen } from '@ui'
|
||||
import type { ActivityCard, HITLCard, Message } from '../src/contracts'
|
||||
import { Conversation } from '../src/components/Conversation'
|
||||
import { ConversationWork } from '../src/components/ConversationWork'
|
||||
import { ProcessTimeline } from '../src/components/ProcessTimeline'
|
||||
import { store, type LiveState } from '../src/store'
|
||||
|
||||
// SSR has no browser subscription snapshot. Keep the production store/actions and
|
||||
// real UI renderers; substitute only the hook boundary with a complete snapshot.
|
||||
const snapshot = vi.hoisted(() => ({ state: undefined as ReturnType<typeof store.getState> | undefined }))
|
||||
vi.mock('../src/store', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../src/store')>()
|
||||
return { ...actual, useStore: (select: (s: ReturnType<typeof store.getState>) => unknown) => select(snapshot.state!) }
|
||||
})
|
||||
|
||||
const timestamp = '2026-10-12T10:00:00Z'
|
||||
function card(over: Partial<ActivityCard> = {}): ActivityCard {
|
||||
return {
|
||||
id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1',
|
||||
kind: 'progress', title: '整理项目材料', status: 'active', tone: 'info',
|
||||
body: [{ type: 'kv', kv: [['状态', '进行中'], ['正在做', '核对会议结论'], ['材料键', 'private-material-key']] }],
|
||||
createdAt: timestamp, updatedAt: timestamp, ...over,
|
||||
}
|
||||
}
|
||||
function reply(parts: Message['parts']): Message {
|
||||
return { id: 'msg_1', conversationId: 'cnv_1', role: 'assistant', runId: 'run_1', createdAt: timestamp, parts }
|
||||
}
|
||||
function hitl(over: Partial<HITLCard> = {}): HITLCard {
|
||||
return { id: 'hitl_detail1', runId: 'run_1', conversationId: 'cnv_1', kind: 'confirm',
|
||||
question: '执行工具 run_shell?', detail: '{"command":"printf confirm-record"}',
|
||||
choices: [{ id: 'yes', label: '执行', description: '按已确认的参数调用', impact: '会访问外部资源并返回结果' },
|
||||
{ id: 'no', label: '取消', description: '跳过本次调用', impact: '不会执行本次操作' }],
|
||||
allowFreeInput: false, createdAt: timestamp, resolved: false, ...over }
|
||||
}
|
||||
function render() { return renderToStaticMarkup(createElement(Conversation)) }
|
||||
function renderWork() { return renderToStaticMarkup(createElement(ConversationWork)) }
|
||||
function completedResults(): ActivityCard[] {
|
||||
// The host lists cards newest-first; submit_card publishes markdown results.
|
||||
return [
|
||||
card({ id: 'result_latest', kind: 'result', title: '最新整理成果', status: 'done', tone: 'ok',
|
||||
updatedAt: '2026-10-12T12:00:00Z', body: [{ type: 'markdown', markdown: '最新成果的主要结论' }] }),
|
||||
card({ id: 'result_older', kind: 'result', title: '先前整理成果', status: 'done', tone: 'ok', pinned: true,
|
||||
updatedAt: '2026-10-12T10:00:00Z', body: [{ type: 'markdown', markdown: '先前成果的主要结论' }] }),
|
||||
]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setPersistedOpen('hitl-details:cnv_1:hitl_detail1', false)
|
||||
setPersistedOpen('conversation-work:prj_1:cnv_1', false)
|
||||
const base = store.getState()
|
||||
snapshot.state = {
|
||||
...base, currentProjectId: 'prj_1', currentConvId: 'cnv_1', connected: true,
|
||||
tasks: [{ id: 'cnv_1', projectId: 'prj_1', title: '讨论项目', createdAt: timestamp, updatedAt: timestamp }],
|
||||
messages: [], cards: [], runs: [], activeHitl: [], live: null, artifacts: [], outbox: [], posting: false,
|
||||
ui: { ...base.ui, rightCollapsed: true, autoCollapseProcess: true },
|
||||
cardActionStates: {},
|
||||
}
|
||||
})
|
||||
|
||||
describe('conversation work reading', () => {
|
||||
it('keeps ordinary Q&A free of an empty progress object', () => {
|
||||
snapshot.state!.messages = [reply([{ type: 'text', text: '可以先确定项目目标' }])]
|
||||
const html = render()
|
||||
expect(html).toContain('可以先确定项目目标')
|
||||
expect(html).not.toContain('data-conversation-work')
|
||||
expect(html).not.toContain('data-testid="input-status"')
|
||||
})
|
||||
|
||||
it('shows the current real work with the auxiliary pane closed', () => {
|
||||
snapshot.state!.cards = [card()]
|
||||
const html = render()
|
||||
expect(html).toContain('整理项目材料')
|
||||
expect(html).toContain('核对会议结论')
|
||||
expect(html).not.toContain('private-material-key')
|
||||
})
|
||||
|
||||
it('shows the newest completed result by default with older results in closed history', () => {
|
||||
snapshot.state!.cards = completedResults()
|
||||
const html = renderWork()
|
||||
expect(html).toContain('data-card-id="result_latest"')
|
||||
expect(html).toContain('最新成果的主要结论')
|
||||
expect(html).not.toContain('data-card-id="result_older"')
|
||||
expect(html).not.toContain('先前成果的主要结论')
|
||||
expect(html).toContain('更多工作与成果 · 1 项')
|
||||
|
||||
setPersistedOpen('conversation-work:prj_1:cnv_1', true)
|
||||
expect(renderWork()).toContain('先前成果的主要结论')
|
||||
})
|
||||
|
||||
it('keeps the newest result ahead of an already resolved confirmation', () => {
|
||||
snapshot.state!.cards = [card({ id: 'confirm_resolved', kind: 'confirm', status: 'resolved', title: '已处理确认',
|
||||
updatedAt: '2026-10-12T13:00:00Z' }), ...completedResults()]
|
||||
const html = renderWork()
|
||||
expect(html).toContain('最新成果的主要结论')
|
||||
expect(html).not.toContain('data-card-id="confirm_resolved"')
|
||||
expect(html).toContain('更多工作与成果 · 2 项')
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps active work ahead of completed results (pinned=%s)', pinned => {
|
||||
const [latest, older] = completedResults()
|
||||
snapshot.state!.cards = [latest, card({ id: 'work_active', title: '正在核验材料',
|
||||
updatedAt: '2026-10-12T11:00:00Z',
|
||||
body: [{ type: 'kv', kv: [['正在做', '核对活动工作的结论']] }] }), older]
|
||||
if (pinned) snapshot.state!.cards.push(card({ id: 'work_pinned', pinned: true, title: '优先核验材料',
|
||||
updatedAt: '2026-10-12T09:00:00Z',
|
||||
body: [{ type: 'kv', kv: [['正在做', '核对固定工作的结论']] }] }))
|
||||
const html = renderWork()
|
||||
expect(html).toContain(pinned ? '核对固定工作的结论' : '核对活动工作的结论')
|
||||
expect(html).not.toContain('data-card-id="result_latest"')
|
||||
expect(html).not.toContain('data-card-id="result_older"')
|
||||
if (pinned) expect(html).not.toContain('data-card-id="work_active"')
|
||||
})
|
||||
|
||||
it('keeps the newest completed result as a compact anchor with the auxiliary pane open', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.cards = completedResults()
|
||||
const html = renderWork()
|
||||
expect(html).toContain('最新整理成果 · 查看摘要')
|
||||
expect(html).not.toContain('最新成果的主要结论')
|
||||
expect(html).not.toContain('先前整理成果')
|
||||
})
|
||||
|
||||
it('keeps a compact work anchor when the same summary is visible in the right pane', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.cards = [card()]
|
||||
const html = render()
|
||||
expect(html).toContain('整理项目材料')
|
||||
expect(html).not.toContain('核对会议结论')
|
||||
snapshot.state!.ui.rightCollapsed = true
|
||||
expect(render()).toContain('核对会议结论')
|
||||
})
|
||||
|
||||
it('keeps secondary message operations available through progressive disclosure', () => {
|
||||
snapshot.state!.messages = [reply([{ type: 'text', text: '已经整理好要点' }])]
|
||||
const html = render()
|
||||
expect(html).toContain('已经整理好要点')
|
||||
expect(html).toContain('消息操作')
|
||||
expect(html).not.toContain('排除出上下文')
|
||||
expect(html).not.toContain('重新生成')
|
||||
})
|
||||
|
||||
it('keeps failure impact and coverage limits visible without expanding history', () => {
|
||||
snapshot.state!.cards = [card({ status: 'failed', tone: 'danger', body: [
|
||||
{ type: 'kv', kv: [['状态', '失败'], ['当前发现', '已整理两份材料'], ['剩余', '评审意见未能读取,完整结论尚未生成']] },
|
||||
] })]
|
||||
const html = render()
|
||||
expect(html).toContain('已整理两份材料')
|
||||
expect(html).toContain('评审意见未能读取,完整结论尚未生成')
|
||||
})
|
||||
|
||||
it('keeps a completed result limitation visible while another task is active', () => {
|
||||
snapshot.state!.cards = [card(), card({ id: 'card_partial', status: 'done', tone: 'warn', title: '已整理部分材料',
|
||||
body: [{ type: 'kv', kv: [['当前发现', '两份材料已整理'], ['剩余', '第三份材料未能读取,请补充清晰版本']] }],
|
||||
})]
|
||||
const html = render()
|
||||
expect(html).toContain('第三份材料未能读取,请补充清晰版本')
|
||||
})
|
||||
|
||||
it('hides running command records while keeping the final reply readable', () => {
|
||||
const live: LiveState = { runId: 'run_1', phase: 'responding', startedAt: Date.now(), agentNotes: {}, items: [
|
||||
{ kind: 'tool', name: 'run_shell', callId: 'call_1', args: 'secret-command-argument', output: 'raw-command-output', state: 'running', startedAt: Date.now(), endedAt: 0 },
|
||||
{ kind: 'final' }, { kind: 'text', index: 1, text: '已找到需要补充的两项要求' },
|
||||
] }
|
||||
snapshot.state!.live = live
|
||||
const html = render()
|
||||
expect(html).toContain('已找到需要补充的两项要求')
|
||||
expect(html).not.toContain('secret-command-argument')
|
||||
expect(html).not.toContain('raw-command-output')
|
||||
expect(html).toContain('执行记录')
|
||||
})
|
||||
|
||||
it('uses a real compact stage without exposing an internal tool name', () => {
|
||||
snapshot.state!.live = { runId: 'run_1', phase: 'acting', startedAt: Date.now(), agentNotes: {}, items: [
|
||||
{ kind: 'tool', name: 'run_shell', callId: 'call_1', args: '', output: '', state: 'running', startedAt: Date.now(), endedAt: 0 },
|
||||
] }
|
||||
const html = render()
|
||||
expect(html).toContain('正在处理')
|
||||
expect(html).not.toContain('run_shell')
|
||||
})
|
||||
|
||||
it('exposes anchored required confirmation outside work history', () => {
|
||||
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理资料' }]), role: 'user' }]
|
||||
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
|
||||
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
|
||||
const html = render()
|
||||
expect(html).toContain('新增一份项目草稿')
|
||||
expect(html).toContain('确认新增')
|
||||
})
|
||||
|
||||
it('keeps a required confirmation accessible when its anchor is an assistant message', () => {
|
||||
snapshot.state!.messages = [reply([{ type: 'text', text: '准备新增项目草稿' }])]
|
||||
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
|
||||
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
|
||||
expect(render()).toContain('确认新增')
|
||||
})
|
||||
|
||||
it('does not reveal internal thinking when records are deliberately opened', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
|
||||
parts: [{ type: 'thinking', process: true, text: 'private-internal-deliberation' }],
|
||||
live: true, idKey: 'thinking-test', renderPart: () => null,
|
||||
}))
|
||||
expect(html).not.toContain('private-internal-deliberation')
|
||||
})
|
||||
|
||||
it('blocks repeated HITL choices when the shared action result is unknown', () => {
|
||||
snapshot.state!.cardActionStates = { 'hitl:hitl_1': 'unknown' }
|
||||
snapshot.state!.activeHitl = [{ id: 'hitl_1', runId: 'run_1', kind: 'choice', question: '选择整理范围',
|
||||
conversationId: 'cnv_1', allowFreeInput: false, createdAt: timestamp, resolved: false,
|
||||
choices: [{ id: 'all', label: '全部材料', description: '整理三份材料', impact: '生成一份草稿' }],
|
||||
}]
|
||||
const html = render()
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>[\s\S]*?全部材料/)
|
||||
expect(html).toContain('正在核实处理结果')
|
||||
})
|
||||
|
||||
it('folds command details while retaining the confirmation and consequences', () => {
|
||||
snapshot.state!.activeHitl = [hitl()]
|
||||
const html = render()
|
||||
expect(html).not.toContain('confirm-record')
|
||||
expect(html).toContain('执行工具 run_shell?')
|
||||
expect(html).toContain('按已确认的参数调用')
|
||||
expect(html).toContain('会访问外部资源并返回结果')
|
||||
expect(html).toContain('操作详情')
|
||||
})
|
||||
|
||||
it('makes the original command detail readable after the user expands it', () => {
|
||||
snapshot.state!.activeHitl = [hitl()]
|
||||
setPersistedOpen('hitl-details:cnv_1:hitl_detail1', true)
|
||||
const html = render()
|
||||
expect(html).toMatch(/aria-expanded="true"[^>]*>[\s\S]*?操作详情/)
|
||||
expect(html).toContain('confirm-record')
|
||||
})
|
||||
|
||||
it('keeps a blocking restriction visible outside the operation detail fold', () => {
|
||||
snapshot.state!.activeHitl = [hitl({ blocked: true, blockReason: '目标文件需要先完成评审,当前不能覆盖' })]
|
||||
const html = render()
|
||||
expect(html).toContain('目标文件需要先完成评审,当前不能覆盖')
|
||||
expect(html).not.toContain('confirm-record')
|
||||
})
|
||||
|
||||
it('folds the exact workflow script suffix supplied in the question', () => {
|
||||
snapshot.state!.activeHitl = [hitl({ question: '运行这段脚本?\nprint("workflow-record-script")', detail: undefined })]
|
||||
const html = render()
|
||||
expect(html).toContain('运行这段脚本?')
|
||||
expect(html).toContain('会访问外部资源并返回结果')
|
||||
expect(html).not.toContain('workflow-record-script')
|
||||
expect(html).toContain('操作详情')
|
||||
})
|
||||
|
||||
it('retains the full workflow script and separate detail on deliberate expansion', () => {
|
||||
snapshot.state!.activeHitl = [hitl({ question: '运行这段脚本?\nprint("workflow-record-script")' })]
|
||||
setPersistedOpen('hitl-details:cnv_1:hitl_detail1', true)
|
||||
const html = render()
|
||||
expect(html).toContain('workflow-record-script')
|
||||
expect(html).toContain('confirm-record')
|
||||
expect(html).toMatch(/aria-expanded="true"[^>]*>[\s\S]*?操作详情/)
|
||||
})
|
||||
|
||||
it('retains multiline business consequences in an ordinary question', () => {
|
||||
snapshot.state!.activeHitl = [hitl({ question: '批准计划并继续执行?\n会覆盖旧草稿,原件保留', detail: undefined })]
|
||||
expect(render()).toContain('会覆盖旧草稿,原件保留')
|
||||
})
|
||||
})
|
||||
+7
-4
@@ -175,10 +175,13 @@ describe('livePhaseLabel', () => {
|
||||
})
|
||||
|
||||
describe('showProcessInline', () => {
|
||||
it('stays expanded for the whole live run, including the responding phase', () => {
|
||||
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true)
|
||||
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(true)
|
||||
expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(true)
|
||||
it('keeps live technical records folded under the default reading preference', () => {
|
||||
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(false)
|
||||
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(false)
|
||||
expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(false)
|
||||
})
|
||||
it('honors the advanced preference to expose live records', () => {
|
||||
expect(showProcessInline(live({ phase: 'acting' }), false)).toBe(true)
|
||||
})
|
||||
it('records thinking duration from the first delta to the last', () => {
|
||||
let items: LiveItem[] = applyThinkingDelta([], '先', 1000)
|
||||
|
||||
+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('登录状态已变化(密码在其他地方被更新),请重新登录')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolOutput, liveToMessage, showProcessInline } from '../src/live'
|
||||
import type { Part } from '../src/contracts'
|
||||
|
||||
function p(partial: Partial<Part> & { type: Part['type'] }): Part {
|
||||
@@ -7,7 +8,68 @@ function p(partial: Partial<Part> & { type: Part['type'] }): Part {
|
||||
}
|
||||
|
||||
describe('splitTimeline', () => {
|
||||
it('keeps everything up to the last process part in the timeline', () => {
|
||||
it('keeps public Markdown before, between and after tools in reading order', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '我会先核对**两份可读材料**。', process: true }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok', summary: '原始读取结果' } }),
|
||||
p({ type: 'text', text: '评审意见未能读取,完整结论尚未生成。' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'search' } }),
|
||||
p({ type: 'text', text: '接下来确认日期冲突。' }),
|
||||
]
|
||||
const split = splitTimeline(parts)
|
||||
expect(split.final.map(part => part.text)).toEqual([
|
||||
'我会先核对**两份可读材料**。',
|
||||
'评审意见未能读取,完整结论尚未生成。',
|
||||
'接下来确认日期冲突。',
|
||||
])
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['tool_call', 'tool_result', 'tool_call'])
|
||||
})
|
||||
|
||||
it('keeps live scope limits visible while execution records are folded by default', () => {
|
||||
let items = applyThinkingDelta([], '内部分析,不向用户展示', 0)
|
||||
items = applyTextDelta(items, 0, '**范围限制:**目前仅整理两份材料,评审意见尚未读取。')
|
||||
items = applyToolCall(items, 'run_python', 'c1', '{"script":"print(42)"}')
|
||||
items = applyToolOutput(items, 'c1', '42\n')
|
||||
const live = { runId: 'r1', phase: 'tool', items, agentNotes: {}, startedAt: 0 }
|
||||
const message = liveToMessage(live)
|
||||
expect(message.parts[1].process).toBe(true)
|
||||
expect(showProcessInline(live, true)).toBe(false)
|
||||
const split = splitTimeline(message.parts)
|
||||
expect(split.final.map(part => part.text)).toEqual(['**范围限制:**目前仅整理两份材料,评审意见尚未读取。'])
|
||||
expect(split.final.every(part => part.type === 'text')).toBe(true)
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call'])
|
||||
})
|
||||
|
||||
it('keeps finalizing checks and internal thinking out of visible text without duplicating explanations', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', process: true, text: '评审意见尚未读取。' }),
|
||||
p({ type: 'thinking', process: true, text: '内部推理' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok', summary: '诊断输出' } }),
|
||||
p({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } }),
|
||||
p({ type: 'text', text: '已整理两份材料,请补充清晰的评审意见。' }),
|
||||
]
|
||||
for (const finished of [false, true]) {
|
||||
const split = splitTimeline(parts, finished)
|
||||
expect(split.final.map(part => part.text)).toEqual(['评审意见尚未读取。', '已整理两份材料,请补充清晰的评审意见。'])
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call', 'tool_result', 'check'])
|
||||
expect(split.final.length + split.timeline.length).toBe(parts.length)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps errors and file references outside a technical record even before a later tool', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'error', process: true, error: { code: 'read.failed', message: '评审意见无法读取' } }),
|
||||
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '已整理的材料.docx' } }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
|
||||
p({ type: 'text', text: '已整理另外两份材料' }),
|
||||
]
|
||||
const split = splitTimeline(parts)
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['tool_call'])
|
||||
expect(split.final.map(x => x.type)).toEqual(['error', 'artifact_ref', 'text'])
|
||||
})
|
||||
it('keeps only execution records folded when narration precedes the last process part', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '先找资料', process: true }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
|
||||
@@ -16,8 +78,8 @@ describe('splitTimeline', () => {
|
||||
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
|
||||
]
|
||||
const split = splitTimeline(parts)
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
|
||||
expect(split.final.map(x => x.type)).toEqual(['text', 'artifact_ref'])
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
|
||||
expect(split.final.map(x => x.type)).toEqual(['text', 'text', 'artifact_ref'])
|
||||
})
|
||||
|
||||
it('keeps a card sandwiched between process parts inside the timeline', () => {
|
||||
@@ -32,7 +94,7 @@ describe('splitTimeline', () => {
|
||||
expect(split.final.map(x => x.text)).toEqual(['最终'])
|
||||
})
|
||||
|
||||
it('lifts the last narration when a finished run has no final text', () => {
|
||||
it('keeps all narrations visible even when a finished run has no final text', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '先写一版', process: true }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'run_python' } }),
|
||||
@@ -40,10 +102,9 @@ describe('splitTimeline', () => {
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'run_python' } }),
|
||||
]
|
||||
const split = splitTimeline(parts, true)
|
||||
expect(split.final.map(x => x.text)).toEqual(['纪要已经整理好'])
|
||||
expect(split.final[0].process).toBeUndefined()
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_call'])
|
||||
expect(splitTimeline(parts).final).toEqual([])
|
||||
expect(split.final.map(x => x.text)).toEqual(['先写一版', '纪要已经整理好'])
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_call'])
|
||||
expect(splitTimeline(parts).final).toEqual(split.final)
|
||||
})
|
||||
|
||||
it('puts a message with no process parts entirely in the final section', () => {
|
||||
|
||||
@@ -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', 3))
|
||||
expect(requests.map(r => JSON.parse(String(r.init?.body)).params)).toEqual([
|
||||
{ projectId: 'domain-7', member: 'bob', role: 'member' },
|
||||
{ projectId: 'domain-7', docId: 'doc-2', author: 'alice', reviewers: ['bob'], approver: 'carol', expectedDutyVersion: 3, confirmInvalidation: false },
|
||||
])
|
||||
})
|
||||
|
||||
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', {}, 3)).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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyRdmError } from '../src/rdmErrors'
|
||||
|
||||
// 2026-10-12 增量 2(审计错误四分类):审计页不再把原始报错(400 回执 JSON)直出,
|
||||
// 改为「能力缺失 / 登录已失效 / 查询失败」文案并保留原始回执供诊断;
|
||||
// 「无可审计项目」由组件空态承担,不走本分类。
|
||||
// 分类依据:api.ts json() 抛 `${status}: ${body}`;后端 unknown/disabled 命令
|
||||
// 回 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
|
||||
// 503 "commands disabled"(server.go execCommand)。
|
||||
describe('classifyRdmError', () => {
|
||||
it('400 unknown or disabled command → 能力缺失', () => {
|
||||
const raw = '400: {"receipt":{"state":"rejected","error":"unknown or disabled command: rdm.inspect.capabilities"}}'
|
||||
const e = classifyRdmError(new Error(raw))
|
||||
expect(e.kind).toBe('capability')
|
||||
expect(e.message).toContain('当前服务未提供审计能力')
|
||||
expect(e.raw).toContain('unknown or disabled command')
|
||||
})
|
||||
|
||||
it('503 commands disabled → 能力缺失', () => {
|
||||
expect(classifyRdmError('503: commands disabled').kind).toBe('capability')
|
||||
})
|
||||
|
||||
it('401/403 → 登录已失效', () => {
|
||||
expect(classifyRdmError('401: session expired').kind).toBe('auth')
|
||||
expect(classifyRdmError(new Error('403: forbidden')).kind).toBe('auth')
|
||||
})
|
||||
|
||||
it('其余错误 → 查询失败,保留原始串', () => {
|
||||
const e = classifyRdmError('500: boom')
|
||||
expect(e.kind).toBe('query')
|
||||
expect(e.message).toContain('查询失败')
|
||||
expect(e.raw).toBe('500: boom')
|
||||
})
|
||||
|
||||
it('非 Error 输入可容忍', () => {
|
||||
expect(classifyRdmError(undefined).kind).toBe('query')
|
||||
expect(classifyRdmError({ weird: true }).kind).toBe('query')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
// U6 页面接线契约测试(方案 r1 U6/KTD5):RDM 管理/项目页面的能力门控与命令
|
||||
// 载荷构造是纯逻辑——先红后实现。门控语义与 rdmAuditReady 同源:命令清单不可用
|
||||
//(未登录/请求失败/null)一律视为未就绪(fail-closed),普通基座不出现 RDM 页面。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DOC_REVIEW_COMMANDS, ROLE_ACTIONS,
|
||||
rdmRolesReady, rdmMembersReady, rdmDocReviewReady,
|
||||
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
|
||||
memberAddPayload, memberRemovePayload, assignDocDutiesPayload,
|
||||
docSubmitPayload, docApprovePayload, docRejectPayload, docFinalPayload, docStatusPayload,
|
||||
RDM_PERM_MATRIX_COMMANDS, rdmPermMatrixReady, permCheckPayload, describeSource,
|
||||
parseReviewers,
|
||||
} from '../src/rdmManagement'
|
||||
|
||||
const cmd = (id: string, enabled = true) => ({ id, enabled })
|
||||
const all = (ids: readonly string[]) => ids.map(id => cmd(id))
|
||||
|
||||
describe('RDM 页面能力门控', () => {
|
||||
it('命令齐备且 enabled 才就绪', () => {
|
||||
expect(rdmRolesReady(all(RDM_ROLES_COMMANDS))).toBe(true)
|
||||
expect(rdmMembersReady(all(RDM_MEMBER_COMMANDS))).toBe(true)
|
||||
expect(rdmDocReviewReady(all(RDM_DOC_REVIEW_COMMANDS))).toBe(true)
|
||||
})
|
||||
it('缺一命令即未就绪', () => {
|
||||
const drop = (ids: readonly string[], skip: string) => ids.filter(i => i !== skip).map(id => cmd(id))
|
||||
expect(rdmRolesReady(drop(RDM_ROLES_COMMANDS, 'rdm.role.list'))).toBe(false)
|
||||
expect(rdmMembersReady(drop(RDM_MEMBER_COMMANDS, 'rdm.project.member.add'))).toBe(false)
|
||||
expect(rdmDocReviewReady(drop(RDM_DOC_REVIEW_COMMANDS, 'rdm.review.doc.final'))).toBe(false)
|
||||
})
|
||||
it('disabled / null / 非数组 / 请求失败均未就绪(fail-closed)', () => {
|
||||
expect(rdmRolesReady(RDM_ROLES_COMMANDS.map(id => cmd(id, false)))).toBe(false)
|
||||
expect(rdmRolesReady(null)).toBe(false)
|
||||
expect(rdmMembersReady(undefined)).toBe(false)
|
||||
expect(rdmDocReviewReady('not-array' as unknown as never)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RDM 动作清单(12 项,与 perms.AllActions 对齐)', () => {
|
||||
it('精确等于 RDM 动作枚举且不重复', () => {
|
||||
expect([...ROLE_ACTIONS].sort()).toEqual([
|
||||
'doc.publish', 'doc.read', 'doc.write', 'duty.assign', 'knowledge.publish',
|
||||
'member.manage', 'project.archive', 'project.create', 'project.settings',
|
||||
'review.participate', 'stage.advance', 'trace.edit',
|
||||
])
|
||||
expect(new Set(ROLE_ACTIONS).size).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('命令载荷构造(身份只经认证会话——载荷不含任何身份字段)', () => {
|
||||
it('角色管理载荷', () => {
|
||||
expect(roleCreatePayload('工程师')).toEqual({ name: '工程师' })
|
||||
expect(roleActionPayload('role_ab', 'doc.write', false, true))
|
||||
.toEqual({ roleId: 'role_ab', action: 'doc.write', default: false, cap: true })
|
||||
expect(roleAssignPayload('bob', 'role_ab')).toEqual({ owner: 'bob', roleId: 'role_ab' })
|
||||
expect(roleDisablePayload('role_ab', true)).toEqual({ roleId: 'role_ab', disabled: true })
|
||||
})
|
||||
it('项目成员载荷(role 缺省 member)', () => {
|
||||
expect(memberAddPayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol', role: 'member' })
|
||||
expect(memberAddPayload('p1', 'carol', 'owner')).toEqual({ projectId: 'p1', member: 'carol', role: 'owner' })
|
||||
expect(memberRemovePayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol' })
|
||||
})
|
||||
it('文档职责与整文审签载荷', () => {
|
||||
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave', 7, true))
|
||||
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave', expectedDutyVersion: 7, confirmInvalidation: true })
|
||||
expect(docSubmitPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
|
||||
expect(docApprovePayload('p1', 'd1', '通过')).toEqual({ projectId: 'p1', docId: 'd1', note: '通过' })
|
||||
expect(docRejectPayload('p1', 'd1', '依据不足')).toEqual({ projectId: 'p1', docId: 'd1', reason: '依据不足' })
|
||||
expect(docFinalPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
|
||||
expect(docFinalPayload('p1', 'd1', 3)).toEqual({ projectId: 'p1', docId: 'd1', round: 3 })
|
||||
expect(docStatusPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
|
||||
})
|
||||
it('审阅人 CSV 解析:去空白、去空项、保序去重', () => {
|
||||
expect(parseReviewers('bob, carol,,bob')).toEqual(['bob', 'carol'])
|
||||
expect(parseReviewers(' ')).toEqual([])
|
||||
expect(parseReviewers('bob')).toEqual(['bob'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('项目权限矩阵页(rdm.perm.check 消费)', () => {
|
||||
it('命令面齐备才就绪(perm.check + 成员清单);缺一/disabled/清单不可用均未就绪', () => {
|
||||
const all2 = RDM_PERM_MATRIX_COMMANDS.map(id => ({ id, enabled: true }))
|
||||
expect(rdmPermMatrixReady(all2)).toBe(true)
|
||||
expect(rdmPermMatrixReady(RDM_PERM_MATRIX_COMMANDS.slice(1).map(id => ({ id, enabled: true })))).toBe(false)
|
||||
expect(rdmPermMatrixReady(RDM_PERM_MATRIX_COMMANDS.map(id => ({ id, enabled: false })))).toBe(false)
|
||||
expect(rdmPermMatrixReady(null)).toBe(false)
|
||||
})
|
||||
it('矩阵查询载荷(身份只经认证会话)', () => {
|
||||
expect(permCheckPayload('p1', 'bob')).toEqual({ projectId: 'p1', member: 'bob' })
|
||||
})
|
||||
it('来源标签:解释读面的人话映射;未知来源不暴露内部代码', () => {
|
||||
expect(describeSource('admin')).toBe('系统管理员')
|
||||
expect(describeSource('prof-role-default')).toContain('角色')
|
||||
expect(describeSource('override')).toContain('覆盖')
|
||||
expect(describeSource('relation-default')).toContain('关系')
|
||||
expect(describeSource('non-member')).toContain('成员')
|
||||
expect(describeSource('weird')).toBe('来源未提供')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { ActionDetailTable, overrideMode, projectActions, type MatrixAction } from '../src/components/RdmPermMatrixSection'
|
||||
import { DutyTree, dutyValidation, normalizeDuties, documentAssignmentImpact } from '../src/components/RdmDutiesSection'
|
||||
import { personItem, readableError, RequestFence } from '../src/components/rdmSettingsShared'
|
||||
|
||||
const action: MatrixAction = { action: 'doc.write', scope: 'project', allowed: false, defaultAllowed: true,
|
||||
capAllowed: false, override: true, source: 'cap', sourceLabel: '管理员权限上限' }
|
||||
describe('project settings authorization presentation', () => {
|
||||
it('preserves the explicit override even when a cap makes effective permission false', () => {
|
||||
expect(overrideMode(action)).toBe('allow')
|
||||
expect(overrideMode({ ...action, allowed: true, override: false })).toBe('deny')
|
||||
expect(overrideMode({ ...action, override: undefined })).toBe('unavailable')
|
||||
const html = renderToStaticMarkup(<ActionDetailTable actions={[action]} canManage />)
|
||||
expect(html).toMatch(/value="allow"[^>]*disabled/)
|
||||
expect(html).toContain('管理员上限禁止授予')
|
||||
})
|
||||
it('excludes all system actions including misleadingly scoped project creation from every project detail', () => {
|
||||
const actions = [{ ...action, action: 'project.create' }, { ...action, scope: 'system' }, { ...action, scope: undefined }, action]
|
||||
expect(projectActions(actions)).toEqual([action])
|
||||
const html = renderToStaticMarkup(<ActionDetailTable actions={actions} canManage />)
|
||||
expect(html).not.toContain('project.create')
|
||||
expect(html).not.toContain('创建项目')
|
||||
expect(html).toContain('编制与编辑文档')
|
||||
})
|
||||
it('joined or unverified accounts cannot be added through candidates', () => {
|
||||
expect(personItem({ owner: 'bob', valid: true, joined: true }, true)).toMatchObject({ disabled: true, badge: '已加入' })
|
||||
expect(personItem({ owner: 'bob' }, true)).toMatchObject({ disabled: true, disabledReason: '账号有效状态尚未核验' })
|
||||
expect(personItem({ owner: 'bob', name: '李工', valid: true, joined: false }, true)).toMatchObject({ title: '李工', disabled: false })
|
||||
})
|
||||
it('only document nodes can enter document selection; chapters are visible context', () => {
|
||||
const html = renderToStaticMarkup(<DutyTree selected="" onSelect={() => {}} nodes={[
|
||||
{ id: 's', kind: 'section', label: '章节 A', selectable: true, docId: 'mistaken-doc', children: [] },
|
||||
{ id: 'd', kind: 'doc', label: '方案文档', selectable: true, docId: 'real-doc', children: [] },
|
||||
]} />)
|
||||
expect(html).toContain('章节 A · 章节</span>')
|
||||
expect(html).toMatch(/<button[^>]*>方案文档<\/button>/)
|
||||
expect(html).not.toMatch(/<button[^>]*>章节 A/)
|
||||
})
|
||||
it('normalizes frozen domain roles without using project identity as signing authority', () => {
|
||||
expect(normalizeDuties({ Author: 'author', Reviewers: ['reviewer'], Approver: 'approver' })).toEqual({ author: 'author', reviewers: ['reviewer'], approver: 'approver' })
|
||||
expect(dutyValidation({ author: 'alice', reviewers: ['alice'], approver: 'bob' })).toContain('不同成员')
|
||||
})
|
||||
it('distinguishes unavailable capabilities, permission denial and readable service reasons', () => {
|
||||
expect(readableError(new Error('400: unknown or disabled command: rdm.duty.tree'))).toContain('尚未装配')
|
||||
expect(readableError(new Error('403: denied'))).toContain('没有权限')
|
||||
expect(readableError(new Error('409: {"error":"职责版本冲突,请刷新"}'))).toBe('职责版本冲突,请刷新')
|
||||
})
|
||||
it('shows the command rejection reason without exposing its raw receipt envelope', () => {
|
||||
const error = new Error('400: {"receipt":{"commandId":"rdm.review.assign.doc","state":"rejected","reason":"rejected: 职责版本冲突,请刷新"}}')
|
||||
expect(readableError(error)).toBe('职责版本冲突,请刷新')
|
||||
})
|
||||
it('does not warn that unchanged duties or independently assigned chapters will be invalidated', () => {
|
||||
const roles = { author: 'a', reviewers: ['r'], approver: 'p' }
|
||||
const document = { docId: 'doc', title: '方案', assigned: true, roles, active: { round: 2 },
|
||||
sections: [{ sectionId: 'chapter', title: '独立章节', roles, overridden: true, active: { state: 'in-review' } }] }
|
||||
expect(documentAssignmentImpact(document, roles)).toBe(false)
|
||||
expect(documentAssignmentImpact({ ...document, active: null }, { ...roles, author: 'b' })).toBe(false)
|
||||
expect(documentAssignmentImpact({ ...document, active: null, sections: [{ ...document.sections[0], overridden: false }] }, { ...roles, author: 'b' })).toBe(true)
|
||||
})
|
||||
})
|
||||
describe('late response fences used by all settings sections', () => {
|
||||
it('discards pending reads after a context switch before effects can clear the form', async () => {
|
||||
const fence = new RequestFence('alice:project-a')
|
||||
const old = fence.begin('read')
|
||||
const result = Promise.resolve().then(() => old() ? 'private-a' : 'discarded')
|
||||
fence.setContext('bob:project-a')
|
||||
expect(await result).toBe('discarded')
|
||||
expect(fence.begin('read')()).toBe(true)
|
||||
})
|
||||
it('cancels old document reads while preserving an independent current request', () => {
|
||||
const fence = new RequestFence('alice:project-a')
|
||||
const first = fence.begin('detail')
|
||||
const list = fence.begin('catalog')
|
||||
const second = fence.begin('detail')
|
||||
expect(first()).toBe(false); expect(second()).toBe(true); expect(list()).toBe(true)
|
||||
fence.cancel('detail'); expect(second()).toBe(false)
|
||||
fence.invalidate(); expect(list()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { RdmDutiesSection, RdmMembersSection, RdmPermMatrixSection, RdmRolesPanel } from '../src/components/adminRdmSections'
|
||||
import { describeSource } from '../src/rdmManagement'
|
||||
import type { Project } from '../src/contracts'
|
||||
|
||||
vi.mock('../src/store', () => ({
|
||||
useStore: (select: (state: unknown) => unknown) => select({ me: { owner: 'alice', name: 'Alice', isAdmin: true } }),
|
||||
}))
|
||||
|
||||
const project: Project = {
|
||||
id: 'host-project', name: '研发项目', createdAt: '', updatedAt: '',
|
||||
settings: { 'rdm.projectId': 'rdm-project', 'rdm.state': 'ready' },
|
||||
}
|
||||
|
||||
describe('approved project settings interactions', () => {
|
||||
it('offers a professional role filter separate from project identity when adding a member', () => {
|
||||
const html = renderToStaticMarkup(<RdmMembersSection project={project} />)
|
||||
expect(html).toContain('aria-label="专业角色筛选"')
|
||||
expect(html).toContain('项目负责人')
|
||||
expect(html).toContain('账号或姓名')
|
||||
})
|
||||
|
||||
it('offers document ID/title searching and a tree/list view without manual ID assignment', () => {
|
||||
const html = renderToStaticMarkup(<RdmDutiesSection project={project} />)
|
||||
expect(html).toContain('aria-label="搜索文档"')
|
||||
expect(html).toContain('树形')
|
||||
expect(html).toContain('列表')
|
||||
})
|
||||
|
||||
it('keeps the multi-member matrix disabled until caller management capability is known', () => {
|
||||
const html = renderToStaticMarkup(<RdmPermMatrixSection project={project} />)
|
||||
expect(html).toMatch(/<option[^>]*(?:value="matrix"[^>]*disabled|disabled[^>]*value="matrix")[^>]*>/)
|
||||
})
|
||||
|
||||
it('renders readable action names in system role configuration', () => {
|
||||
const html = renderToStaticMarkup(<RdmRolesPanel />)
|
||||
expect(html).toMatch(/value="project.create"[^>]*>创建项目<\/option>/)
|
||||
expect(html).toMatch(/value="duty.assign">分配文档职责<\/option>/)
|
||||
})
|
||||
|
||||
it('never offers the system creation privilege as an editable project role action', () => {
|
||||
const html = renderToStaticMarkup(<RdmRolesPanel />)
|
||||
expect(html).toMatch(/<option[^>]*(?:value="project.create"[^>]*disabled|disabled[^>]*value="project.create")[^>]*>创建项目<\/option>/)
|
||||
})
|
||||
|
||||
it('does not show raw permission source codes when the server adds a known scope denial', () => {
|
||||
expect(describeSource('scope')).toBe('配置范围不符')
|
||||
expect(describeSource('error')).toBe('暂时无法核验')
|
||||
expect(describeSource('unrecognized-source')).toBe('来源未提供')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
// RDM 角色权限面板拒绝态(2026-10-13 8788 dev 锁死复现):rdm.role.list 被拒后
|
||||
// 表格停留「加载中…」且错误为原始 400 JSON——测纯函数两件事:占位行不再假装
|
||||
// 加载中;域管理员权限拒绝映射为人话 + 恢复通道。UI 不重算权限(KTD5),文案
|
||||
// 只描述拒绝事实,不判权限。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { rolesListPlaceholder, describeRoleListError } from '../src/rdmManagement'
|
||||
|
||||
describe('rolesListPlaceholder', () => {
|
||||
it('keeps 加载中 only while loading without error', () => {
|
||||
expect(rolesListPlaceholder(null, '')).toBe('加载中…')
|
||||
})
|
||||
|
||||
it('replaces the loading row with a failure row once the fetch rejected', () => {
|
||||
expect(rolesListPlaceholder(null, 'Error: 400: {"receipt":…}')).toBe('角色清单未加载(见下方提示)')
|
||||
})
|
||||
|
||||
it('returns the empty label for an empty list and nothing otherwise', () => {
|
||||
expect(rolesListPlaceholder([], '')).toBe('暂无专业角色')
|
||||
expect(rolesListPlaceholder([{ roleId: 'r1' }], '')).toBe('')
|
||||
// 已有数据时即便残留 err 也不再渲染占位行(表格有内容即事实来源)
|
||||
expect(rolesListPlaceholder([{ roleId: 'r1' }], 'stale')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeRoleListError', () => {
|
||||
const denied = 'Error: 400: {"receipt":{"commandId":"rdm.role.list","state":"rejected",'
|
||||
+ '"reason":"rejected: 权限拒绝:weichen 不是系统管理员(全局动作 project.settings)"}}'
|
||||
|
||||
it('explains denied system-role authority without suggesting the retired transfer bypass', () => {
|
||||
const got = describeRoleListError(denied)
|
||||
expect(got).toContain('当前账号无权管理系统专业角色')
|
||||
expect(got).not.toContain('rdm.perm.transfer')
|
||||
expect(got).not.toContain('"receipt"')
|
||||
})
|
||||
|
||||
it('passes other errors through untouched', () => {
|
||||
expect(describeRoleListError('Error: 500: boom')).toBe('Error: 500: boom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('versioned shared settings API', () => {
|
||||
it('sends the loaded project revision and settings in the strict envelope', async () => {
|
||||
let url: unknown, init: RequestInit | undefined
|
||||
vi.spyOn(auth, 'fetch').mockImplementation(async (path, options) => {
|
||||
url = path; init = options
|
||||
return new Response(JSON.stringify({ ok: true, projectRevision: 8 }))
|
||||
})
|
||||
await api.putProjectSettings('host/id', { deliverables: { style: 'formal' } }, 7)
|
||||
expect(url).toBe('/api/settings/project/host%2Fid')
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ values: { deliverables: { style: 'formal' } }, expectedRevision: 7 })
|
||||
})
|
||||
it('rejects permission and conflict failures for all settings writers', async () => {
|
||||
vi.spyOn(auth, 'fetch').mockResolvedValue(new Response('changed', { status: 409 }))
|
||||
await expect(api.putSystemSettings({}, 3)).rejects.toThrow('409')
|
||||
vi.spyOn(auth, 'fetch').mockResolvedValue(new Response('denied', { status: 403 }))
|
||||
await expect(api.putUserSettings({})).rejects.toThrow('403')
|
||||
})
|
||||
it('sends genuine inheritance removal without guessing a default value', async () => {
|
||||
let init: RequestInit | undefined
|
||||
vi.spyOn(auth, 'fetch').mockImplementation(async (_path, options) => {
|
||||
init = options
|
||||
return new Response(JSON.stringify({ ok: true, projectRevision: 8 }))
|
||||
})
|
||||
await api.putProjectSettings('host/id', {}, 7, ['deliverables.style'])
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ values: {}, expectedRevision: 7, unset: ['deliverables.style'] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { SettingInput } from '../src/components/SettingsModal'
|
||||
import { scopedSchema } from '../src/components/SettingsFields'
|
||||
import { BorrowPanels } from '../src/components/BorrowPanels'
|
||||
import type { SettingDesc } from '../src/contracts'
|
||||
|
||||
vi.mock('../src/store', () => ({ useStore: (select: (state: unknown) => unknown) => select({ ui: {} }) }))
|
||||
const field = (type: SettingDesc['type'], options?: string[]): SettingDesc => ({
|
||||
key: 'deliverables.style', group: 'deliverables', label: '交付风格', type, options, effect: 'new-run',
|
||||
})
|
||||
|
||||
describe('setting editors preserve types and readable values', () => {
|
||||
it('keeps legacy personal plugin settings visible only in personal scopes', () => {
|
||||
const schema: SettingDesc[] = [
|
||||
{ ...field('string'), key: 'plugin.legacy', scope: undefined },
|
||||
{ ...field('string'), key: 'plugin.user', scope: 'user' },
|
||||
{ ...field('string'), key: 'plugin.project-personal', scope: 'project-personal' },
|
||||
{ ...field('string'), key: 'plugin.project', scope: 'project' },
|
||||
{ ...field('string'), key: 'plugin.system', scope: 'system' },
|
||||
]
|
||||
const keys = (scope: string) => scopedSchema(schema, scope).map(desc => desc.key)
|
||||
expect(keys('user')).toEqual(['plugin.legacy', 'plugin.user'])
|
||||
expect(keys('project-personal')).toEqual(['plugin.legacy', 'plugin.user', 'plugin.project-personal'])
|
||||
expect(keys('project')).toEqual(['plugin.project'])
|
||||
expect(keys('system')).toEqual(['plugin.system'])
|
||||
})
|
||||
it('uses a number input for numeric values rather than submitting arbitrary strings', () => {
|
||||
expect(renderToStaticMarkup(<SettingInput desc={field('number')} value={12} onChange={() => {}} />)).toContain('type="number"')
|
||||
})
|
||||
it('shows readable enum names and a distinct inherited choice', () => {
|
||||
const html = renderToStaticMarkup(<SettingInput desc={field('enum', ['modern', 'formal', 'minimal'])} value="" onChange={() => {}} />)
|
||||
expect(html).toContain('沿用默认')
|
||||
expect(html).toContain('>现代</option>')
|
||||
expect(html).toContain('>正式</option>')
|
||||
expect(html).toContain('>简洁</option>')
|
||||
})
|
||||
it('personal supplementary panels no longer offer system credentials', () => {
|
||||
const html = renderToStaticMarkup(<BorrowPanels />)
|
||||
expect(html).not.toContain('新增密钥')
|
||||
expect(html).not.toContain('主模型')
|
||||
expect(html).toContain('条件注入')
|
||||
expect(html).toContain('用户命令')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { settingInputAttrs } from '../src/components/SettingsModal'
|
||||
|
||||
// 2026-10-12 增量 3:配置输入按字段类型渲染——此前 SettingInput 默认分支无条件
|
||||
// type="password",浏览器把整排设置项当凭据表单(「password 不在 form 中/缺少
|
||||
// username」警告的来源之一),且普通文本被无谓遮蔽。新契约:仅秘密字段用密码框。
|
||||
describe('settingInputAttrs', () => {
|
||||
it('secret=true → 密码框 + new-password', () => {
|
||||
expect(settingInputAttrs({ key: 'llm.apiKey', label: '密钥', type: 'string', secret: true } as any))
|
||||
.toEqual({ type: 'password', autoComplete: 'new-password' })
|
||||
})
|
||||
|
||||
it('type=secret → 密码框', () => {
|
||||
expect(settingInputAttrs({ key: 'x', label: 'X', type: 'secret' } as any).type).toBe('password')
|
||||
})
|
||||
|
||||
it('普通文本 → text + off(不遮蔽、不当凭据)', () => {
|
||||
const a = settingInputAttrs({ key: 'llm.baseUrl', label: '地址', type: 'string' } as any)
|
||||
expect(a).toEqual({ type: 'text', autoComplete: 'off' })
|
||||
})
|
||||
|
||||
it('无类型标注的默认项 → text', () => {
|
||||
expect(settingInputAttrs({ key: 'ui.language', label: '语言' } as any).type).toBe('text')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { personalSettingsMutation, settingsMutation } from '../src/components/SettingsFields'
|
||||
import type { SettingDesc } from '../src/contracts'
|
||||
|
||||
const schema: SettingDesc[] = [
|
||||
{ key: 'tools.limit', group: 'tools', label: '上限', type: 'number', effect: 'new-run', scope: 'system' },
|
||||
{ key: 'models.chat.apiKey', group: 'models', label: '密钥', type: 'secret', secret: true, effect: 'hot', scope: 'system' },
|
||||
]
|
||||
describe('settings merge patch behavior', () => {
|
||||
it('omits unchanged masked credentials and removes a numeric override without storing its default', () => {
|
||||
expect(settingsMutation({ 'tools.limit': 20, 'models.chat.apiKey': '••••••' }, { 'tools.limit': undefined, 'models.chat.apiKey': '••••••' }, schema))
|
||||
.toEqual({ values: {}, unset: ['tools.limit'] })
|
||||
})
|
||||
it('sends explicit secret revocation as an empty string rather than losing it through flattening', () => {
|
||||
expect(settingsMutation({ 'models.chat.apiKey': '••••••' }, { 'models.chat.apiKey': '' }, schema))
|
||||
.toEqual({ values: { models: { chat: { apiKey: '' } } }, unset: [] })
|
||||
})
|
||||
it('does not forward fields outside the visible scoped schema', () => {
|
||||
expect(settingsMutation({}, { 'tools.limit': 0, 'project.create': true }, schema))
|
||||
.toEqual({ values: { tools: { limit: 0 } }, unset: [] })
|
||||
})
|
||||
it('uses explicit null deletion for personal inheritance instead of omitting a merged field', () => {
|
||||
expect(personalSettingsMutation({ 'tools.limit': 20 }, { 'tools.limit': undefined }, schema))
|
||||
.toEqual({ tools: { limit: null } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SurfaceSnapshot } from '../src/contracts'
|
||||
import { mergeSurfacesByRevision, surfaceKey } from '../src/surfaceKey'
|
||||
|
||||
function snap(id: string, revision: number): SurfaceSnapshot {
|
||||
return {
|
||||
identity: {
|
||||
surfaceId: id,
|
||||
owner: 'o',
|
||||
pluginId: 'p',
|
||||
},
|
||||
compat: { schemaVersion: 1, catalogVersion: '1' },
|
||||
revision,
|
||||
activationEpoch: 0,
|
||||
spec: { title: id, nodes: [] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('surfaceKey', () => {
|
||||
it('reads nested identity and skips missing', () => {
|
||||
expect(surfaceKey(snap('s1', 1))).toBe('s1')
|
||||
expect(surfaceKey(undefined)).toBeNull()
|
||||
expect(surfaceKey({} as SurfaceSnapshot)).toBeNull()
|
||||
expect(surfaceKey({ identity: {} } as SurfaceSnapshot)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeSurfacesByRevision', () => {
|
||||
it('keeps higher revision and drops dirty entries', () => {
|
||||
const dirty = { revision: 99 } as SurfaceSnapshot
|
||||
const merged = mergeSurfacesByRevision(
|
||||
[snap('a', 1), dirty],
|
||||
[snap('a', 3), snap('b', 1)],
|
||||
)
|
||||
expect(merged.map(s => [surfaceKey(s), s.revision])).toEqual([
|
||||
['a', 3],
|
||||
['b', 1],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,421 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { store } from '../src/store'
|
||||
import type { ActivityCard, Conversation, Message, Run, StreamEvent, SurfaceSnapshot } from '../src/contracts'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((ok, fail) => { resolve = ok; reject = fail })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
function card(id = 'task', status: ActivityCard['status'] = 'active', conversationId = 'a'): ActivityCard {
|
||||
return { id, status, conversationId, projectId: 'p', kind: 'progress', title: '整理材料', createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z' }
|
||||
}
|
||||
function detail(id: string, runs: Run[] = [], lastSeq = 0, resumeSeq = lastSeq) {
|
||||
return { conversation: { id, projectId: 'p', title: id, createdAt: '', updatedAt: '' } as Conversation, messages: [] as Message[], runs, activeHitl: [], lastSeq, resumeSeq }
|
||||
}
|
||||
function event(seq: number, type: StreamEvent['type'], payload: unknown, runId?: string, conversationId = 'a'): StreamEvent {
|
||||
return { seq, type, payload, runId, conversationId, ts: '2026-10-12T00:00:00Z', v: 1 }
|
||||
}
|
||||
class FakeEventSource {
|
||||
onopen = null
|
||||
onerror = null
|
||||
onmessage = null
|
||||
addEventListener() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal('EventSource', FakeEventSource)
|
||||
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id))
|
||||
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'listCards').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listTasks').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
|
||||
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listProjectCards').mockResolvedValue([])
|
||||
await store.openProject('p')
|
||||
await store.openConversation('a')
|
||||
await store.refreshCards('a')
|
||||
})
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
describe('work projection ordering and context', () => {
|
||||
it('settles foreground completion and queued input when background events overtake its detail response', async () => {
|
||||
vi.useFakeTimers()
|
||||
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run]))
|
||||
await store.openConversation('a')
|
||||
store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在整理' }, 'front'))
|
||||
store.upsertCard(card('background'))
|
||||
store.enqueueOutbox('继续整理', ['material'], ['材料'])
|
||||
const query = deferred<ReturnType<typeof detail>>()
|
||||
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
|
||||
const send = deferred<Awaited<ReturnType<typeof api.sendMessage>>>()
|
||||
const submission = vi.spyOn(api, 'sendMessage').mockReturnValue(send.promise)
|
||||
store.applyEvent(event(2, 'run.completed', {}, 'front'))
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
store.applyEvent(event(3, 'agent.progress', { agentRun: 'background', note: '仍在核对' }, 'background'))
|
||||
store.applyEvent(event(4, 'card.status', { cardId: 'background', status: 'done' }, 'background'))
|
||||
vi.mocked(api.listCards).mockResolvedValue([card('background', 'done')])
|
||||
query.resolve({ ...detail('a', [{ ...run, status: 'completed' }], 2), messages: [
|
||||
{ id: 'reply', conversationId: 'a', runId: 'front', role: 'assistant', parts: [{ type: 'text', text: '整理完成' }], createdAt: '' },
|
||||
] })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(store.getState().runs.find(r => r.id === 'front')?.status).toBe('completed')
|
||||
expect(store.getState().messages.map(m => m.id)).toContain('reply')
|
||||
expect(store.getState().live).toBeNull()
|
||||
expect(store.getState().lastSeq).toBe(4)
|
||||
expect(store.getState().cards.find(c => c.id === 'background')?.status).toBe('done')
|
||||
expect(store.getState().outbox).toEqual([])
|
||||
expect(submission).toHaveBeenCalledWith('a', '继续整理', ['material'], expect.any(String))
|
||||
send.resolve({ message: { id: 'queued', conversationId: 'a', role: 'user', parts: [], createdAt: '' } })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
it('waits for the newer terminal run snapshot before settling its live output', async () => {
|
||||
vi.useFakeTimers()
|
||||
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run]))
|
||||
await store.openConversation('a')
|
||||
store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在整理' }, 'front'))
|
||||
const query = deferred<ReturnType<typeof detail>>()
|
||||
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
|
||||
const pending = store.reconcile()
|
||||
store.applyEvent(event(2, 'run.failed', { code: 'failed', message: '未完成' }, 'front'))
|
||||
query.resolve(detail('a', [{ ...run, status: 'completed' }], 1))
|
||||
await pending
|
||||
expect(store.getState().runs[0].status).toBe('running')
|
||||
expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '正在整理' })
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [{ ...run, status: 'failed' }], 2))
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(store.getState().runs[0].status).toBe('failed')
|
||||
expect(store.getState().live).toBeNull()
|
||||
})
|
||||
|
||||
it('does not restore a resolved HITL from a detail snapshot overtaken by its resolution', async () => {
|
||||
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run]))
|
||||
await store.openConversation('a')
|
||||
const query = deferred<Awaited<ReturnType<typeof api.conversationDetail>>>()
|
||||
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
|
||||
const pending = store.reconcile()
|
||||
store.applyEvent(event(2, 'hitl.resolved', { cardId: 'ask' }, 'front'))
|
||||
query.resolve({ ...detail('a', [{ ...run, status: 'completed' }], 1), activeHitl: [
|
||||
{ id: 'ask', runId: 'front', conversationId: 'a', question: '继续?', kind: 'confirm', allowFreeInput: false, createdAt: '', resolved: false },
|
||||
] })
|
||||
await pending
|
||||
expect(store.getState().activeHitl).toEqual([])
|
||||
expect(store.getState().runs[0].status).toBe('completed')
|
||||
expect(store.getState().lastSeq).toBe(2)
|
||||
})
|
||||
|
||||
it('preserves a run and user message started after a detail query began', async () => {
|
||||
const query = deferred<ReturnType<typeof detail>>()
|
||||
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
|
||||
const pending = store.reconcile()
|
||||
vi.spyOn(api, 'sendMessage').mockResolvedValue({
|
||||
message: { id: 'new-message', conversationId: 'a', role: 'user', parts: [], createdAt: '' },
|
||||
run: { id: 'new-run', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' },
|
||||
})
|
||||
await store.sendMessage('继续', [])
|
||||
query.resolve(detail('a'))
|
||||
await pending
|
||||
expect(store.getState().runs.map(r => r.id)).toEqual(['new-run'])
|
||||
expect(store.getState().messages.map(m => m.id)).toEqual(['new-message'])
|
||||
expect(store.getState().live?.runId).toBe('new-run')
|
||||
})
|
||||
|
||||
it('drops a terminal reconciliation timer after leaving its conversation', async () => {
|
||||
vi.useFakeTimers()
|
||||
store.applyEvent(event(1, 'run.completed', {}, 'front'))
|
||||
await store.openConversation('b')
|
||||
const calls = vi.mocked(api.conversationDetail).mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(api.conversationDetail).toHaveBeenCalledTimes(calls)
|
||||
expect(store.getState().currentConvId).toBe('b')
|
||||
})
|
||||
|
||||
it('does not regress a finished card on an older or duplicated event', () => {
|
||||
store.applyEvent(event(10, 'card.created', { card: card() }))
|
||||
store.applyEvent(event(12, 'card.status', { cardId: 'task', status: 'done' }))
|
||||
store.applyEvent(event(11, 'card.status', { cardId: 'task', status: 'active' }))
|
||||
store.applyEvent(event(12, 'card.status', { cardId: 'task', status: 'failed' }))
|
||||
expect(store.getState().cards[0].status).toBe('done')
|
||||
})
|
||||
|
||||
it('rejects late card events from a different conversation', async () => {
|
||||
await store.openConversation('b')
|
||||
store.applyEvent(event(10, 'card.created', { card: card('foreign') }))
|
||||
expect(store.getState().cards).toEqual([])
|
||||
})
|
||||
|
||||
it('does not let a card query finish in another conversation', async () => {
|
||||
const query = deferred<ActivityCard[]>()
|
||||
vi.mocked(api.listCards).mockImplementation(id => id === 'a' ? query.promise : Promise.resolve([]))
|
||||
const pending = store.refreshCards('a')
|
||||
await store.openConversation('b')
|
||||
query.resolve([card('old')])
|
||||
await pending
|
||||
expect(store.getState().cards).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps card events newer than an in-flight card snapshot', async () => {
|
||||
store.applyEvent(event(10, 'card.created', { card: card() }))
|
||||
const query = deferred<ActivityCard[]>()
|
||||
vi.mocked(api.listCards).mockReturnValueOnce(query.promise)
|
||||
const pending = store.refreshCards('a')
|
||||
store.applyEvent(event(11, 'card.status', { cardId: 'task', status: 'done' }))
|
||||
store.applyEvent(event(12, 'card.created', { card: card('new-task') }))
|
||||
query.resolve([card()])
|
||||
await pending
|
||||
expect(store.getState().cards.find(c => c.id === 'task')?.status).toBe('done')
|
||||
expect(store.getState().cards.some(c => c.id === 'new-task')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves a status event that arrives before its first card snapshot', async () => {
|
||||
const query = deferred<ActivityCard[]>()
|
||||
vi.mocked(api.listCards).mockReturnValueOnce(query.promise)
|
||||
const pending = store.refreshCards('a')
|
||||
store.applyEvent(event(1, 'card.status', { cardId: 'task', status: 'done' }))
|
||||
query.resolve([card()])
|
||||
await pending
|
||||
expect(store.getState().cards[0].status).toBe('done')
|
||||
})
|
||||
|
||||
it('does not let a delayed conversation snapshot replace the newly selected one', async () => {
|
||||
const query = deferred<ReturnType<typeof detail>>()
|
||||
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
|
||||
const pending = store.reconcile()
|
||||
await store.openConversation('b')
|
||||
query.resolve({ ...detail('a'), messages: [{ id: 'old-message', conversationId: 'a', role: 'user', parts: [], createdAt: '' }] })
|
||||
await pending
|
||||
expect(store.getState().currentConvId).toBe('b')
|
||||
expect(store.getState().messages).toEqual([])
|
||||
})
|
||||
|
||||
it('restores foreground output through replay without replaying old card states', async () => {
|
||||
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run], 10, 4))
|
||||
vi.mocked(api.listCards).mockResolvedValue([card('task', 'done')])
|
||||
await store.openConversation('a')
|
||||
await store.refreshCards('a')
|
||||
store.applyEvent(event(5, 'message.delta', { index: 0, text: '已经整理' }, 'front'))
|
||||
store.applyEvent(event(6, 'card.status', { cardId: 'task', status: 'active' }))
|
||||
expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '已经整理' })
|
||||
expect(store.getState().cards[0].status).toBe('done')
|
||||
})
|
||||
|
||||
it('does not duplicate replayed foreground text', async () => {
|
||||
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run], 0))
|
||||
await store.openConversation('a')
|
||||
store.applyEvent(event(1, 'message.delta', { index: 0, text: '结论' }, 'front'))
|
||||
store.applyEvent(event(1, 'message.delta', { index: 0, text: '结论' }, 'front'))
|
||||
expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '结论' })
|
||||
})
|
||||
|
||||
it('background notes do not create a foreground turn or block the composer', () => {
|
||||
store.applyEvent(event(1, 'agent.progress', { agentRun: 'background', note: '正在读取' }, 'background'))
|
||||
expect(store.getState().live).toBeNull()
|
||||
})
|
||||
|
||||
it('new task pages do not replace an open document or reopen a hidden right pane', () => {
|
||||
store.requestPreview({ id: 'doc', projectId: 'p', name: '需求.md', kind: 'markdown', size: 0, createdAt: '', updatedAt: '' })
|
||||
store.setUI({ rightCollapsed: true })
|
||||
const snap: SurfaceSnapshot = { identity: { surfaceId: 'page', pluginId: 'generative-ui', owner: 'o' }, revision: 1, activationEpoch: 0, compat: { schemaVersion: 1, catalogVersion: '1' }, spec: { title: '新成果', nodes: [] } }
|
||||
store.applyEvent(event(1, 'surface.updated', snap, 'background'))
|
||||
expect(store.getState().previewActive.a).toBe('doc')
|
||||
expect(store.getState().ui.rightCollapsed).toBe(true)
|
||||
expect(store.getState().surfaces[0].identity.surfaceId).toBe('page')
|
||||
})
|
||||
|
||||
it('resets project event cursor when changing projects and drops delayed project lists', async () => {
|
||||
await store.openProject('p')
|
||||
store.applyProjectEvent(event(100, 'card.created', { card: { ...card('p-card'), conversationId: undefined } }))
|
||||
const query = deferred<ActivityCard[]>()
|
||||
vi.mocked(api.listProjectCards).mockReturnValueOnce(query.promise)
|
||||
const pending = store.loadProjectCards()
|
||||
await store.openProject('q')
|
||||
store.applyProjectEvent(event(1, 'card.created', { card: { ...card('q-card'), projectId: 'q', conversationId: undefined } }))
|
||||
query.resolve([{ ...card('old-p'), conversationId: undefined }])
|
||||
await pending
|
||||
expect(store.getState().projectCards.map(c => c.id)).toEqual(['q-card'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared action feedback', () => {
|
||||
it.each(['card', 'hitl'] as const)('clears its definite rejected %s action after A to B navigation', async kind => {
|
||||
const confirm = { ...card(`reject-${kind}`, 'action_required'), kind: 'confirm' as const,
|
||||
...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:reject-${kind}`]] as [string, string][] }] } : {}) }
|
||||
store.upsertCard(confirm)
|
||||
const response = deferred<never>()
|
||||
const request = kind === 'hitl' ? vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) : vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
|
||||
const pending = store.cardAction(confirm, 'confirm')
|
||||
await store.openConversation('b')
|
||||
response.reject(new Error('400: rejected'))
|
||||
await pending
|
||||
expect(store.getState().cardActionStates[confirm.id]).toBeUndefined()
|
||||
expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBeUndefined()
|
||||
expect(store.getState().cards).toEqual([])
|
||||
expect(store.getState().messages).toEqual([])
|
||||
vi.mocked(api.listCards).mockResolvedValue([confirm])
|
||||
await store.openConversation('a')
|
||||
await store.refreshCards('a')
|
||||
await store.cardAction(confirm, 'confirm')
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['card', new TypeError('connection lost')], ['card', new Error('503: unavailable')],
|
||||
['hitl', new TypeError('connection lost')], ['hitl', new Error('503: unavailable')],
|
||||
] as const)('retains unknown feedback for an off-context %s request with %s', async (kind, failure) => {
|
||||
const id = `unknown-${kind}-${failure instanceof TypeError ? 'network' : 'server'}`
|
||||
const confirm = { ...card(id, 'action_required'), kind: 'confirm' as const,
|
||||
...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:${id}`]] as [string, string][] }] } : {}) }
|
||||
store.upsertCard(confirm)
|
||||
const response = deferred<never>()
|
||||
const request = kind === 'hitl' ? vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) : vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
|
||||
const pending = store.cardAction(confirm, 'confirm')
|
||||
await store.openConversation('b')
|
||||
response.reject(failure)
|
||||
await pending
|
||||
expect(store.getState().cardActionStates[confirm.id]).toBe('unknown')
|
||||
if (kind === 'hitl') expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBe('unknown')
|
||||
vi.mocked(api.listCards).mockResolvedValue([confirm])
|
||||
await store.openConversation('a')
|
||||
await store.refreshCards('a')
|
||||
await store.cardAction(confirm, 'confirm')
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
store.applyEvent(event(1, 'card.resolved', { cardId: confirm.id }))
|
||||
expect(store.getState().cardActionStates[confirm.id]).toBeUndefined()
|
||||
expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not let an old rejected action clear feedback owned by a newer HITL projection', async () => {
|
||||
const confirm = { ...card('replacement', 'action_required'), kind: 'confirm' as const }
|
||||
store.upsertCard(confirm)
|
||||
const old = deferred<never>()
|
||||
vi.spyOn(api, 'cardAction').mockReturnValue(old.promise)
|
||||
const first = store.cardAction(confirm, 'confirm')
|
||||
store.applyEvent(event(1, 'card.resolved', { cardId: confirm.id }))
|
||||
const projected = { ...confirm, body: [{ type: 'kv' as const, kv: [['来源', 'hitl:replacement']] as [string, string][] }] }
|
||||
store.applyEvent(event(2, 'card.updated', { card: projected }))
|
||||
const replacement = deferred<Awaited<ReturnType<typeof api.resolveHITL>>>()
|
||||
vi.spyOn(api, 'resolveHITL').mockReturnValue(replacement.promise)
|
||||
const second = store.cardAction(projected, 'confirm')
|
||||
old.reject(new Error('400: rejected'))
|
||||
await first
|
||||
expect(store.getState().cardActionStates.replacement).toBe('submitting')
|
||||
expect(store.getState().cardActionStates['hitl:replacement']).toBe('submitting')
|
||||
vi.mocked(api.listCards).mockResolvedValue([{ ...projected, status: 'resolved' }])
|
||||
replacement.resolve({ resolved: true, pivoted: false, pivotText: '' })
|
||||
await second
|
||||
expect(store.getState().cardActionStates.replacement).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not append another conversation receipt to the currently open conversation', async () => {
|
||||
const foreign = { ...card('other-confirm', 'action_required', 'b'), kind: 'confirm' as const }
|
||||
store.applyProjectEvent(event(1, 'card.created', { card: foreign }))
|
||||
vi.spyOn(api, 'cardAction').mockResolvedValue({ card: { ...foreign, status: 'resolved' }, receipt: {
|
||||
id: 'foreign-receipt', conversationId: 'b', role: 'system', createdAt: '', parts: [{ type: 'text', text: 'other conversation outcome' }],
|
||||
} })
|
||||
await store.cardAction(foreign, 'confirm')
|
||||
expect(store.getState().messages).toEqual([])
|
||||
expect(store.getState().projectCards[0].status).toBe('resolved')
|
||||
})
|
||||
it('unlocks a completed read-only recheck even while confirmation remains pending', async () => {
|
||||
const confirm = { ...card('recheck', 'action_required'), kind: 'confirm' as const }
|
||||
store.upsertCard(confirm)
|
||||
vi.spyOn(api, 'cardAction').mockResolvedValue({ card: confirm })
|
||||
await store.cardAction(confirm, 'recheck')
|
||||
expect(store.getState().cardActionStates.recheck).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not overwrite a newer resolved event with the older action response', async () => {
|
||||
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const }
|
||||
store.upsertCard(confirm)
|
||||
const response = deferred<{ card: ActivityCard }>()
|
||||
vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
|
||||
vi.mocked(api.listCards).mockResolvedValue([confirm])
|
||||
const pending = store.cardAction(confirm, 'confirm')
|
||||
store.applyEvent(event(1, 'card.resolved', { cardId: 'confirm' }))
|
||||
response.resolve({ card: confirm })
|
||||
await pending
|
||||
expect(store.getState().cards[0].status).toBe('resolved')
|
||||
})
|
||||
|
||||
it('shares one lock across a raw HITL and its activity-card projection', async () => {
|
||||
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, body: [{ type: 'kv', kv: [['来源', 'hitl:ask']] }] } satisfies ActivityCard
|
||||
store.upsertCard(confirm)
|
||||
const response = deferred<{ resolved: boolean; pivoted: boolean; pivotText: string }>()
|
||||
const request = vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise)
|
||||
const first = store.resolveHITL('ask', { choiceId: 'yes' })
|
||||
const second = store.cardAction(confirm, 'yes')
|
||||
expect(store.getState().cardActionStates.confirm).toBe('submitting')
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
vi.mocked(api.listCards).mockResolvedValue([{ ...confirm, status: 'resolved' }])
|
||||
response.resolve({ resolved: true, pivoted: false, pivotText: '' })
|
||||
await Promise.all([first, second])
|
||||
expect(store.getState().cardActionStates.confirm).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the server cancellation outcome rather than labelling every resolved HITL executed', async () => {
|
||||
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, body: [{ type: 'kv', kv: [['来源', 'hitl:ask']] }] } satisfies ActivityCard
|
||||
store.upsertCard(confirm)
|
||||
vi.spyOn(api, 'resolveHITL').mockResolvedValue({ resolved: true, pivoted: false, pivotText: '' })
|
||||
vi.mocked(api.listCards).mockResolvedValue([{ ...confirm, status: 'dismissed' }])
|
||||
await store.resolveHITL('ask', { choiceId: 'no' })
|
||||
expect(store.getState().cards[0].status).toBe('dismissed')
|
||||
})
|
||||
|
||||
it('leaves a new conversation untouched when an older send returns', async () => {
|
||||
const response = deferred<Awaited<ReturnType<typeof api.sendMessage>>>()
|
||||
vi.spyOn(api, 'sendMessage').mockReturnValue(response.promise)
|
||||
const pending = store.sendMessage('整理材料', [])
|
||||
await store.openConversation('b')
|
||||
response.resolve({ message: { id: 'old-message', conversationId: 'a', role: 'user', createdAt: '', parts: [{ type: 'text', text: '整理材料' }] }, run: { id: 'old-run', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } })
|
||||
await pending
|
||||
expect(store.getState().messages).toEqual([])
|
||||
expect(store.getState().live).toBeNull()
|
||||
})
|
||||
it('locks a card before awaiting so two views submit one effect', async () => {
|
||||
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, actions: [{ id: 'confirm', label: '生成草稿' }] }
|
||||
const response = deferred<{ card: ActivityCard }>()
|
||||
const calls = vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
|
||||
store.upsertCard(confirm)
|
||||
const first = store.cardAction(confirm, 'confirm')
|
||||
const second = store.cardAction(confirm, 'confirm')
|
||||
expect(store.getState().cardActionStates?.confirm).toBe('submitting')
|
||||
expect(calls).toHaveBeenCalledTimes(1)
|
||||
response.resolve({ card: { ...confirm, status: 'resolved' } })
|
||||
await Promise.all([first, second])
|
||||
expect(store.getState().cards[0].status).toBe('resolved')
|
||||
expect(store.getState().cardActionStates?.confirm).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps an uncertain effect disabled until a server outcome is observed', async () => {
|
||||
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const }
|
||||
store.upsertCard(confirm)
|
||||
vi.spyOn(api, 'cardAction').mockRejectedValue(new TypeError('connection lost'))
|
||||
vi.mocked(api.listCards).mockResolvedValue([confirm])
|
||||
await store.cardAction(confirm, 'confirm')
|
||||
expect(store.getState().cardActionStates?.confirm).toBe('unknown')
|
||||
store.applyEvent(event(1, 'card.resolved', { cardId: 'confirm' }))
|
||||
expect(store.getState().cardActionStates?.confirm).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not write a completed old action into a different conversation', async () => {
|
||||
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const }
|
||||
const response = deferred<{ card: ActivityCard }>()
|
||||
vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
|
||||
const pending = store.cardAction(confirm, 'confirm')
|
||||
await store.openConversation('b')
|
||||
response.resolve({ card: { ...confirm, status: 'resolved' } })
|
||||
await pending
|
||||
expect(store.getState().cards).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TopBar } from '../src/components/TopBar'
|
||||
import { TaskSidebar } from '../src/components/TaskSidebar'
|
||||
import { selectWorkbenchLayout, type WorkbenchLayoutOptions } from '../src/workbenchLayout'
|
||||
import { store } from '../src/store'
|
||||
|
||||
vi.mock('../src/store', () => {
|
||||
const state = {
|
||||
projects: [{ id: 'prj_1', name: '材料项目' }],
|
||||
tasks: [{ id: 'cnv_1', projectId: 'prj_1', title: '整理项目材料', updatedAt: '2026-10-12T10:00:00Z' }],
|
||||
me: null, adminSections: [], artifacts: [], cards: [], projectCards: [], serverWorkspaces: [],
|
||||
rdmAuditAvailable: false, currentConvId: 'cnv_1', currentProjectId: 'prj_1',
|
||||
ui: { theme: 'light', showArchived: false, rightCollapsed: true, rightTab: 'work' },
|
||||
}
|
||||
return { useStore: (select: (value: typeof state) => unknown) => select(state), store: {
|
||||
openInbox: vi.fn(), openFiles: vi.fn(), focusPending: vi.fn(), setUI: vi.fn(),
|
||||
} }
|
||||
})
|
||||
|
||||
const desktop: WorkbenchLayoutOptions = {
|
||||
width: 1440, contextOpen: false, leftCollapsed: false, rightCollapsed: false,
|
||||
leftWidth: 260, rightWidth: 360, previewWidth: 700, rightTab: 'work',
|
||||
previewOpen: false, previewExpanded: false, filesView: 'list',
|
||||
}
|
||||
|
||||
describe('selectWorkbenchLayout', () => {
|
||||
it.each([320, 390, 736])('shows one conversation pane at %ipx and keeps saved desktop widths unchanged', width => {
|
||||
const options = { ...desktop, width, leftWidth: 420, rightWidth: 560, rightCollapsed: true }
|
||||
const layout = selectWorkbenchLayout(options)
|
||||
expect(layout.compact).toBe(true)
|
||||
expect([layout.showContext, layout.showConversation, layout.showRight]).toEqual([false, true, false])
|
||||
expect(options.leftWidth).toBe(420)
|
||||
expect(options.rightWidth).toBe(560)
|
||||
})
|
||||
|
||||
it('opens context on a narrow screen and returns to the existing conversation', () => {
|
||||
const options = { ...desktop, width: 390, rightCollapsed: true }
|
||||
expect(selectWorkbenchLayout({ ...options, contextOpen: true }).pane).toBe('context')
|
||||
expect(selectWorkbenchLayout({ ...options, contextOpen: false }).pane).toBe('conversation')
|
||||
})
|
||||
|
||||
it('uses the same right pane for work and expanded file reading', () => {
|
||||
const work = selectWorkbenchLayout({ ...desktop, width: 390 })
|
||||
expect(work.pane).toBe('work')
|
||||
expect([work.showContext, work.showConversation, work.showRight]).toEqual([false, false, true])
|
||||
const files = selectWorkbenchLayout({ ...desktop, width: 390, rightTab: 'files', filesView: 'preview', previewOpen: true, previewExpanded: true })
|
||||
expect(files.pane).toBe('files')
|
||||
expect([files.showContext, files.showConversation, files.showRight]).toEqual([false, false, true])
|
||||
expect(files.previewExpanded).toBe(true)
|
||||
expect(files.rightWidth).toBe(382)
|
||||
})
|
||||
|
||||
it('keeps the conversation visible after an expanded file pane is closed', () => {
|
||||
const layout = selectWorkbenchLayout({ ...desktop, rightTab: 'files', filesView: 'preview', previewOpen: true, previewExpanded: true, rightCollapsed: true })
|
||||
expect(layout.showConversation).toBe(true)
|
||||
expect(layout.showRight).toBe(false)
|
||||
expect(layout.previewExpanded).toBe(false)
|
||||
})
|
||||
|
||||
it.each([1024, 1440])('fits stored oversized work panes inside %ipx while reserving conversation space', width => {
|
||||
const layout = selectWorkbenchLayout({ ...desktop, width, leftWidth: 4000, rightWidth: 4000 })
|
||||
expect(layout.compact).toBe(false)
|
||||
expect(layout.leftWidth).toBeGreaterThanOrEqual(220)
|
||||
expect(layout.leftWidth).toBeLessThanOrEqual(420)
|
||||
expect(layout.rightWidth).toBeGreaterThanOrEqual(280)
|
||||
expect(layout.rightWidth).toBeLessThanOrEqual(560)
|
||||
expect(layout.leftWidth + layout.rightWidth + 360 + 32).toBeLessThanOrEqual(width)
|
||||
})
|
||||
|
||||
it('fits wide file reading at 1024px without discarding the conversation', () => {
|
||||
const layout = selectWorkbenchLayout({ ...desktop, width: 1024, leftWidth: 420, previewWidth: 1100, rightTab: 'files', filesView: 'preview', previewOpen: true })
|
||||
expect(layout.showConversation).toBe(true)
|
||||
expect(layout.rightWidth).toBeGreaterThanOrEqual(420)
|
||||
expect(layout.leftWidth + layout.rightWidth + 320 + 32).toBeLessThanOrEqual(1024)
|
||||
})
|
||||
|
||||
it('enlarges desktop reading inside the existing right pane and rejects invalid saved widths', () => {
|
||||
const layout = selectWorkbenchLayout({ ...desktop, leftWidth: NaN, previewWidth: Infinity, rightTab: 'files', filesView: 'preview', previewOpen: true, previewExpanded: true })
|
||||
expect(layout.showConversation).toBe(false)
|
||||
expect(layout.showRight).toBe(true)
|
||||
expect(layout.previewExpanded).toBe(true)
|
||||
expect(Number.isFinite(layout.leftWidth + layout.rightWidth)).toBe(true)
|
||||
expect(layout.leftWidth + layout.rightWidth + 20).toBeLessThanOrEqual(1440)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workbench navigation', () => {
|
||||
function action(node: ReactNode, label: string): (() => void) | undefined {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{ children?: ReactNode; 'aria-label'?: string; onClick?: () => void }>(child)) continue
|
||||
if (child.props['aria-label'] === label) return child.props.onClick
|
||||
const found = action(child.props.children, label)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps work and file entrances available when the auxiliary pane is closed', () => {
|
||||
const html = renderToStaticMarkup(createElement(TopBar))
|
||||
expect(html).toContain('aria-label="打开工作"')
|
||||
expect(html).toContain('aria-label="打开文件"')
|
||||
})
|
||||
|
||||
it('offers a clear return to the conversation while reading a file on a narrow screen', () => {
|
||||
const html = renderToStaticMarkup(createElement<NonNullable<Parameters<typeof TopBar>[0]>>(TopBar, {
|
||||
compact: true, activePane: 'files', onConversation: () => undefined,
|
||||
}))
|
||||
expect(html).toContain('aria-label="返回对话"')
|
||||
for (const name of ['打开上下文', '打开工作', '打开文件']) expect(html).toContain(`aria-label="${name}"`)
|
||||
})
|
||||
|
||||
it('opens ordinary work with all results reachable instead of only pending confirmations', () => {
|
||||
action(TopBar(), '打开工作')?.()
|
||||
expect(store.openInbox).toHaveBeenCalledOnce()
|
||||
expect(store.setUI).toHaveBeenCalledWith({ inboxFilter: 'all', previewExpanded: false })
|
||||
})
|
||||
|
||||
it('connects both the return action and conversation navigation to the same return path', () => {
|
||||
const onConversation = vi.fn()
|
||||
const topbar = TopBar({ compact: true, activePane: 'files', onConversation })
|
||||
action(topbar, '返回对话')?.()
|
||||
action(topbar, '打开对话')?.()
|
||||
expect(onConversation).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('names conversation creation accurately and exposes conversation selection to the keyboard', () => {
|
||||
const html = renderToStaticMarkup(createElement(TaskSidebar))
|
||||
expect(html).toContain('新建对话')
|
||||
expect(html).not.toContain('新建任务')
|
||||
expect(html).toMatch(/<button[^>]*class="tsb-task-open"/)
|
||||
expect(html).toContain('更多对话操作')
|
||||
expect(html.match(/材料项目/g)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasActiveProgressCard } from '../src/components/Workbench'
|
||||
import type { ActivityCard } from '@ui'
|
||||
|
||||
function card(over: Partial<ActivityCard>): ActivityCard {
|
||||
return {
|
||||
id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1',
|
||||
kind: 'progress', title: '材料整理', status: 'active', tone: 'info',
|
||||
createdAt: '2026-10-12T10:00:00Z', updatedAt: '2026-10-12T10:00:00Z',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('hasActiveProgressCard', () => {
|
||||
it('active/action_required progress 卡视为占用当前任务位', () => {
|
||||
expect(hasActiveProgressCard([card({ status: 'active' })])).toBe(true)
|
||||
expect(hasActiveProgressCard([card({ status: 'action_required' })])).toBe(true)
|
||||
})
|
||||
|
||||
it('完成/失败 progress 或非 progress 不隐藏 CurrentTaskCard', () => {
|
||||
expect(hasActiveProgressCard([card({ status: 'done', tone: 'ok' })])).toBe(false)
|
||||
expect(hasActiveProgressCard([card({ status: 'failed', tone: 'danger' })])).toBe(false)
|
||||
expect(hasActiveProgressCard([card({ kind: 'confirm', status: 'action_required' })])).toBe(false)
|
||||
expect(hasActiveProgressCard([])).toBe(false)
|
||||
})
|
||||
})
|
||||
+82
-8
@@ -1,26 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkspaceEntries } from '../src/workspaces'
|
||||
import { buildWorkspaceEntries, rdmAuditReady, rdmExportReady, exportNeedsDraft, RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } from '../src/workspaces'
|
||||
|
||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口,体系工具只补「代理」。
|
||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
|
||||
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
|
||||
// 没有任何 rdm.inspect.* 命令,入口展示后进入即 400。新契约:仅当命令面探测
|
||||
// 通过(opts.rdmAudit)才纳入 rdm-audit;代理仍无条件以 ⌘1 补位。
|
||||
describe('buildWorkspaceEntries', () => {
|
||||
it('没有领域插件时,代理补在 ⌘1', () => {
|
||||
it('没有领域插件且无 RDM 能力:只有代理占 ⌘1', () => {
|
||||
const entries = buildWorkspaceEntries([])
|
||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理'])
|
||||
})
|
||||
|
||||
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
|
||||
it('没有领域插件且 RDM 能力通过:代理 ⌘1、追溯审计 ⌘2', () => {
|
||||
const entries = buildWorkspaceEntries([], { rdmAudit: true })
|
||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理', '⌘2 追溯审计'])
|
||||
})
|
||||
|
||||
it('声明了 shortcut 的工作区占住该位,内置工具让位(能力开)', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'trace', title: '追溯网', shortcut: '1' },
|
||||
], { rdmAudit: true })
|
||||
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理', '追溯审计'])
|
||||
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
|
||||
expect(entries[0].key).toBe('ws:trace')
|
||||
})
|
||||
|
||||
it('无 RDM 能力:让位后只有代理,不出现追溯审计', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'trace', title: '追溯网', shortcut: '1' },
|
||||
])
|
||||
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理'])
|
||||
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2'])
|
||||
expect(entries[0].key).toBe('ws:trace')
|
||||
})
|
||||
|
||||
it('未编号的工作区排在内置工具后面', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'mem', title: '记忆库' },
|
||||
])
|
||||
expect(entries.map(e => e.title)).toEqual(['代理', '记忆库'])
|
||||
], { rdmAudit: true })
|
||||
expect(entries.map(e => e.title)).toEqual(['代理', '追溯审计', '记忆库'])
|
||||
})
|
||||
|
||||
it('九位占满时低序内置保留,溢出项截断', () => {
|
||||
const server = Array.from({ length: 8 }, (_, i) => ({ id: `w${i}`, title: `w${i}`, shortcut: String(i + 1) }))
|
||||
const entries = buildWorkspaceEntries(server, { rdmAudit: true })
|
||||
// ⌘1–8 被声明占住;代理取 ⌘9;追溯审计溢出截断(slice 0,9)。
|
||||
expect(entries.filter(e => e.key.startsWith('ws:'))).toHaveLength(8)
|
||||
expect(entries.map(e => e.title)).toContain('代理')
|
||||
expect(entries.map(e => e.title)).not.toContain('追溯审计')
|
||||
})
|
||||
})
|
||||
|
||||
// 能力探测依据后端 GET /api/commands(server.go listCommands:{ id, title, …, enabled })。
|
||||
// 审计工作区消费七个 rdm.inspect.* 命令;任一缺失或 enabled=false 都视为能力未就绪。
|
||||
describe('rdmAuditReady', () => {
|
||||
const all = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
|
||||
|
||||
it('七命令齐备且启用 → true', () => {
|
||||
expect(rdmAuditReady(all)).toBe(true)
|
||||
})
|
||||
|
||||
it('普通基座空清单 / 缺任一命令 → false', () => {
|
||||
expect(rdmAuditReady([])).toBe(false)
|
||||
expect(rdmAuditReady(all.filter(c => c.id !== 'rdm.inspect.history'))).toBe(false)
|
||||
})
|
||||
|
||||
it('存在但 enabled=false → false', () => {
|
||||
const disabled = all.map(c => (c.id === 'rdm.inspect.capabilities' ? { ...c, enabled: false } : c))
|
||||
expect(rdmAuditReady(disabled)).toBe(false)
|
||||
})
|
||||
|
||||
it('清单不可用(未登录/请求失败)→ false,不展示入口', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,5 +18,13 @@ export default defineConfig({
|
||||
'/api': { target: 'http://127.0.0.1:8787', changeOrigin: false },
|
||||
},
|
||||
},
|
||||
// E2E(U4 运行验收):preview 服务 dist 并把 /api 代理到被测后端(e2e.sh 注入
|
||||
// PLAYWRIGHT_API_TARGET;缺省与 dev 代理同值,本地 npm run preview 也保持可用)。
|
||||
preview: {
|
||||
port: Number(process.env.PLAYWRIGHT_PREVIEW_PORT || 4173),
|
||||
proxy: {
|
||||
'/api': { target: process.env.PLAYWRIGHT_API_TARGET || 'http://127.0.0.1:8787', changeOrigin: false },
|
||||
},
|
||||
},
|
||||
test: { environment: 'node' },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user