Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cb17d015d | ||
|
|
1719803a92 | ||
|
|
a1e7e4680f | ||
|
|
92864db8d3 | ||
|
|
fb1f9f246b | ||
|
|
2f8009b967 | ||
|
|
8b59998d3c | ||
|
|
c9ba3442fc | ||
|
|
ed23de28df | ||
|
|
83e81d12ac | ||
|
|
e503cc9e93 | ||
|
|
c3d8205839 | ||
|
|
dd159c24d0 | ||
|
|
a56ce3e1df | ||
|
|
10f8388bb8 | ||
|
|
5ede8df52a | ||
|
|
49ba8b1735 | ||
|
|
b594d2748e | ||
|
|
afd661c932 | ||
|
|
c9ef3c0f0a | ||
|
|
72f1a259e1 | ||
|
|
804170ec66 | ||
|
|
7759d8077f | ||
|
|
8d43b1de12 | ||
|
|
d66bd7941c | ||
|
|
a022257b40 | ||
|
|
3f6ced099c | ||
|
|
86770e35fb | ||
|
|
65ab7a8a44 | ||
|
|
119fff96e8 | ||
|
|
96ec03d461 | ||
|
|
17996cf4dd | ||
|
|
dbb5f0462f | ||
|
|
125261410c | ||
|
|
e895a4f6a8 | ||
|
|
46e99fedbb | ||
|
|
bcb81719ce | ||
|
|
d8e610aebe | ||
|
|
3bbb918795 |
@@ -0,0 +1,81 @@
|
||||
// 防再发守卫:管理员建号路径(UsersPanel)必须持续符合后端待初始化契约。
|
||||
// 背景:backend 8dfc223 将 POST /api/admin/users 改为严格 {owner, profile} 解码,
|
||||
// 前端未跟进导致部署后 UI 建号 400(json: unknown field "name")——单测与 mock
|
||||
// 都没有覆盖这条组合路径,本场景以真实浏览器把「填用户名 → 创建账号 →
|
||||
// 确认『待初始化』提示与用户表行」钉进组合验收线。
|
||||
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
|
||||
// 与 vite preview;种子账号 e2e-auditor 为基座管理员(bootstrap-admin)。
|
||||
// 环境契约(缺省值与 e2e.sh 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过):
|
||||
// PLAYWRIGHT_BASE_URL / E2E_OWNER / E2E_PASSWORD / E2E_PASSWORD2
|
||||
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, '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 entered = await page.locator('.tsb-user').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (!entered) {
|
||||
const gated = await page.getByText('修改密码').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-user')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('管理员建号:UI 请求新契约并给出待初始化提示', async ({ page }) => {
|
||||
await login(page)
|
||||
|
||||
// 账户菜单 → 系统管理…(全屏独立视图)→ 用户管理分区。
|
||||
await page.locator('.tsb-user').click()
|
||||
const adminEntry = page.getByText('系统管理…', { exact: true })
|
||||
await expect(adminEntry).toBeVisible()
|
||||
await adminEntry.click()
|
||||
await expect(page.locator('.az')).toBeVisible()
|
||||
const usersSection = page.getByText('用户管理', { exact: true }).first()
|
||||
if (await usersSection.count()) await usersSection.click()
|
||||
|
||||
// 建号表单:用户名每次运行唯一(重复运行不因 409 假红),显示名可空。
|
||||
const newOwner = `e2e-ui-${Date.now().toString(36)}`
|
||||
const userInput = page.getByPlaceholder('用户名(登录名)')
|
||||
await expect(userInput).toBeVisible()
|
||||
await userInput.fill(newOwner)
|
||||
await page.getByPlaceholder('显示名(可空)').fill('组合验收')
|
||||
|
||||
// 创建 → 契约断言一:请求体是严格 {owner, profile:{name}},无 password/isAdmin。
|
||||
const createBody = page.waitForRequest(
|
||||
req => req.url().endsWith('/api/admin/users') && req.method() === 'POST',
|
||||
).then(req => req.postDataJSON())
|
||||
await page.getByRole('button', { name: '创建账号', exact: true }).click()
|
||||
const body = await createBody
|
||||
expect(body).toEqual({ owner: newOwner, profile: { name: '组合验收' } })
|
||||
|
||||
// 契约断言二:页面创建后账号可直接使用,不再提示 CLI 初始化。
|
||||
await expect(page.locator('.ad-notice')).toContainText(`已创建 ${newOwner}`)
|
||||
|
||||
// 契约断言三:用户表出现该账号,状态为未设密码(HTTP 建号不能设密码/授权限)。
|
||||
const row = page.locator('.ad-table tbody tr', { hasText: newOwner })
|
||||
await expect(row).toBeVisible()
|
||||
await expect(row).toContainText('未设密码')
|
||||
await expect(row).not.toContainText('ADMIN')
|
||||
|
||||
// 界面说明与后端流程一致(激活 Key 由服务器本地 CLI 发放)。
|
||||
await expect(page.locator('.ad-create .ad-hint')).toContainText('初始密码')
|
||||
await expect(page.locator('.ad-create .ad-hint')).not.toContainText('user initialize-account')
|
||||
})
|
||||
+25
-29
@@ -1,7 +1,6 @@
|
||||
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
|
||||
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
|
||||
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
|
||||
// 新密码登录进工作台;重复兑换同一 Key 被拒。
|
||||
// 忘记密码唯一处置回归:管理员生成一次性登录密码 → 用户密码登录 → 强制改密
|
||||
//(one_time_password 门只要新密码 + 确认);
|
||||
// 用户行不再有恢复提案入口,登录页只保留用户名 + 密码,不提供一次性 Key 登录 tab。
|
||||
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
||||
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
||||
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
||||
@@ -53,60 +52,57 @@ async function adminLogin(page: Page) {
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
|
||||
test('管理员生成一次性登录密码:登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
||||
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
||||
test.setTimeout(120_000)
|
||||
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
|
||||
// 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。
|
||||
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()
|
||||
const row = zone.locator('tr', { hasText: USER })
|
||||
await expect(row.getByRole('button', { name: '恢复提案' })).toHaveCount(0)
|
||||
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
|
||||
const oneTimePassword = zone.locator('input[aria-label="一次性密码"]')
|
||||
await expect(oneTimePassword).toBeVisible({ timeout: 10_000 })
|
||||
await expect(zone.getByText('使用后必须强制改密')).toBeVisible()
|
||||
const password = await oneTimePassword.inputValue()
|
||||
expect(password.length).toBeGreaterThan(0)
|
||||
|
||||
// 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) 受限设密门:服务端确认用户名;无当前密码输入框。
|
||||
// 3) 用户使用一次性登录密码登录后进入 one_time_password 强制改密门:
|
||||
// 只填新密码 + 确认,不再要求当前密码。
|
||||
await passwordLogin(page, USER, password)
|
||||
const gate = page.locator('.lg-card')
|
||||
await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(gate.getByText(`账号 ${USER}`)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(gate.getByText('无需再次输入一次性密码')).toBeVisible()
|
||||
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
|
||||
const pw = gate.locator('input[type="password"]')
|
||||
await expect(pw).toHaveCount(2)
|
||||
await pw.nth(0).fill(USER_PW)
|
||||
await pw.nth(1).fill(USER_PW)
|
||||
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
|
||||
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||
// 4) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||
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) 新密码登录进工作台(旧初始密码已不可用一并验证)。
|
||||
// 5) 新密码登录进工作台。
|
||||
await passwordLogin(page, USER, USER_PW)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
|
||||
// 6) 登录页不再提供一次性 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 })
|
||||
await expect(page.getByRole('tab', { name: '一次性登录 Key' })).toHaveCount(0)
|
||||
await expect(page.getByPlaceholder('alk_…')).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// I4 组合验收(Playwright):文档驾驶舱七场景(rollout §3.I4 最小场景集)。
|
||||
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制(含 I1/I3 命令面)+ frontend dist,
|
||||
// 种子:审计演示项目(rdm-self + cockpit-e2e 结构装载;两个章节经草稿流落正文;
|
||||
// 一个章节留空;第二个账号 e2e-reader 不入项目做权限负例)。
|
||||
// 环境契约与 rdm-audit.playwright.ts 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||
const PASSWORD = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||
const READER = process.env.E2E_READER || 'e2e-reader'
|
||||
const READER_PASSWORD = process.env.E2E_READER_PASSWORD || 'E2eReader#2026'
|
||||
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
|
||||
|
||||
async function login(page: Page, user: string, password: string) {
|
||||
await page.goto('/')
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
|
||||
await page.getByPlaceholder('由管理员分配').fill(user)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(password)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
|
||||
// 侧栏入口两种形态:常规侧栏按钮(.tsb-tool);窄宽紧凑布局换图标栏
|
||||
// (.icon-only 内 title 以「文档驾驶舱」开头的按钮)——入口都可达。
|
||||
function cockpitEntry(page: Page) {
|
||||
return page.locator('.tsb-tool', { hasText: '文档驾驶舱' })
|
||||
.or(page.locator('.icon-only button[title^="文档驾驶舱"]')).first()
|
||||
}
|
||||
|
||||
async function loginOwner(page: Page) {
|
||||
await login(page, OWNER, PASSWORD)
|
||||
// 登录后身份纪元与能力探针仍可能各跳一次;应用在身份变更时会关闭已开
|
||||
// 工作区——等落定再点入口,避免 overlay 被身份刷新关掉(竞态实测)。
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
await page.waitForTimeout(1_500)
|
||||
}
|
||||
|
||||
async function openCockpit(page: Page) {
|
||||
// 窄宽是单窗格布局:登录后默认「对话」窗格,先切到「上下文」窗格。
|
||||
if (!(await cockpitEntry(page).isVisible().catch(() => false))) {
|
||||
await page.getByRole('button', { name: '打开上下文' }).click()
|
||||
}
|
||||
await cockpitEntry(page).click()
|
||||
await expect(page.locator('.cockpit-project')).toBeVisible({ timeout: 15_000 })
|
||||
// 默认选中首个项目(种子=审计演示项目)。
|
||||
await expect(page.locator('.cockpit-stats')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
async function openReaderOnDoc(page: Page, title: string) {
|
||||
await page.locator('.cockpit-tree-title', { hasText: title }).first().click()
|
||||
await page.getByRole('button', { name: '在阅读器中打开' }).click()
|
||||
await expect(page.locator('.cockpit-reader')).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe.serial('I4 文档驾驶舱组合验收', () => {
|
||||
// 场景 1:首次进入项目——统计带、结构树、全景可见(DV-R01/R02)。
|
||||
test('1 首屏:统计、树、全景可见且统计可判读', async ({ page }) => {
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
// placeholder
|
||||
await expect(page.locator('.cockpit-stat', { hasText: '完成度' })).toBeVisible()
|
||||
await expect(page.locator('.cockpit-stat', { hasText: '文档' })).toBeVisible()
|
||||
// 空 vs 不可算不得互相冒充:有数据项目的空缺计数显示数字。
|
||||
await expect(page.locator('.cockpit-stat', { hasText: '空缺节点' }).locator('.cockpit-stat-num')).toHaveText(/^\d+$/)
|
||||
await expect(page.locator('.cockpit-tree-title', { hasText: '验证主体报告' }).first()).toBeVisible()
|
||||
await expect(page.getByText('文档概览')).toBeVisible()
|
||||
await expect(page.locator('.cockpit-snap')).toContainText(/快照 wm:\d+/)
|
||||
})
|
||||
|
||||
// 场景 2:文档→章节,全景与阅读器双向定位(DV-R03)。
|
||||
test('2 定位:树选章节→阅读器正文;返回全景仍同一节点', async ({ page }) => {
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
// 展开目标文档(树按标题排序,branch 作用域防跨文档串扰),点「概述」。
|
||||
const branch = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
|
||||
await branch.locator('.cockpit-tree-caret').click()
|
||||
const sec = branch.locator('.cockpit-tree-title', { hasText: '概述' }).first()
|
||||
await expect(sec).toBeVisible()
|
||||
sec.click()
|
||||
await expect(page.locator('.cockpit-nodehead')).toBeVisible()
|
||||
// 全景→阅读器:同一 nodeRef(阅读器显示该节标题与正文)。
|
||||
await page.getByRole('button', { name: '在阅读器中打开' }).click()
|
||||
await expect(page.locator('.cockpit-reader')).toBeVisible()
|
||||
await expect(page.locator('.cockpit-nodetitle', { hasText: '概述' })).toBeVisible()
|
||||
const body = page.locator('.cockpit-reader-para', { hasText: '本报告' }).first()
|
||||
await expect(body).toBeVisible()
|
||||
// 阅读器→全景:返回后仍是选中节点(节点头仍在)。
|
||||
await page.getByRole('button', { name: '← 返回全景' }).click()
|
||||
await expect(page.locator('.cockpit-nodehead')).toBeVisible()
|
||||
})
|
||||
|
||||
// 场景 3:四面板展开并正确显示计数;受限与空态诚实(DV-R04)。
|
||||
test('3 面板:来源/历史/影响/冲突计数与空态、截断旗标', async ({ page }) => {
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
const branch = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
|
||||
await branch.locator('.cockpit-tree-caret').click()
|
||||
await branch.locator('.cockpit-tree-title', { hasText: '概述' }).first().click()
|
||||
const panel = page.locator('.cockpit-panel')
|
||||
await expect(panel).toBeVisible()
|
||||
// 来源:草稿落版本挂了依据切片(种子经 ingest 提供)。
|
||||
await panel.getByRole('tab', { name: '内容来源' }).click()
|
||||
await expect(panel.locator('.cockpit-panel-count')).toContainText(/来源 \d+ 条/)
|
||||
// 历史:已提交事件(structure + draft 链)。
|
||||
await panel.getByRole('tab', { name: '修改历史' }).click()
|
||||
await expect(panel.locator('.cockpit-panel-count')).toContainText('已提交事件')
|
||||
await expect(panel.locator('.cockpit-panel-row').first()).toBeVisible()
|
||||
// 影响:一跳;空/计数二态都诚实。
|
||||
await panel.getByRole('tab', { name: '影响对象' }).click()
|
||||
await expect(panel.locator('.cockpit-panel-count')).toContainText(/影响 \d+ 项/)
|
||||
// 冲突:种子无开放碰撞 → 明确「无开放冲突」而非空白。
|
||||
await panel.getByRole('tab', { name: '冲突与风险' }).click()
|
||||
await expect(panel.getByText('无开放冲突')).toBeVisible()
|
||||
// 版本块(I3):当前版本+差异折叠入口可见。
|
||||
await expect(page.locator('.cockpit-versions-toggle')).toBeVisible()
|
||||
})
|
||||
|
||||
// 场景 4:后台变更后所有投影一致(DV-R05)——测试经 API 采纳新版本,
|
||||
// 驾驶舱刷新后统计水位/树时间/详情版本块同步前进。
|
||||
test('4 一致性:API 采纳新版本后,刷新入口与各投影同步', async ({ page }) => {
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
const branch4 = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
|
||||
await branch4.locator('.cockpit-tree-caret').click()
|
||||
await branch4.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
|
||||
const snapBefore = await page.locator('.cockpit-snap').innerText()
|
||||
// 通过应用自身 API 落一个已提交变更(需求账本新增条目,唯一键防幂等跳过;
|
||||
// 采纳走高危确认卡、服务端不允许客户端伪造 Confirmed,故用低危提交面驱动)。
|
||||
await page.request.post('/api/commands/rdm.reqs.import', {
|
||||
data: { params: { projectId: await page.locator('.cockpit-project').inputValue(),
|
||||
items: [{ kind: 'UR', title: `UR-PROBE-${Date.now()} 一致性探针`, statement: '刷新一致性驱动条目', source: 'e2e-probe' }] } },
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
await page.getByRole('button', { name: '刷新' }).click()
|
||||
await expect(page.locator('.cockpit-snap')).not.toHaveText(snapBefore)
|
||||
// 版本块前进:出现新版本条目。
|
||||
await expect(page.locator('.cockpit-versions-toggle')).toContainText(/版本 v\d+/)
|
||||
})
|
||||
|
||||
// 场景 5:断线/失败保留旧快照并给出重试(DV-R08)——page.route 掐断命令面。
|
||||
test('5 失败:命令面断连时保留旧快照并出现重试入口', async ({ page }) => {
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
await expect(page.locator('.cockpit-stats')).toBeVisible()
|
||||
// 掐断后续命令面:切换项目触发重取 → 失败横幅 + 旧内容保留。
|
||||
await page.route('**/api/commands/rdm.cockpit.**', route => route.abort())
|
||||
await page.locator('.cockpit-refresh').click()
|
||||
await expect(page.locator('.cockpit-banner-err')).toBeVisible()
|
||||
await expect(page.locator('.cockpit-stats')).toBeVisible() // 旧快照未被清空
|
||||
await page.unroute('**/api/commands/rdm.cockpit.**')
|
||||
await page.locator('.cockpit-refresh').click()
|
||||
await expect(page.locator('.cockpit-banner-err')).toBeHidden()
|
||||
})
|
||||
|
||||
// 场景 6:权限负例——非成员账号看不到项目(无项目诚实空态;不泄露存在性)。
|
||||
test('6 权限:非成员账号无项目可见', async ({ page }) => {
|
||||
await login(page, READER, READER_PASSWORD)
|
||||
await page.locator('.tsb-tool', { hasText: '文档驾驶舱' }).click()
|
||||
await expect(page.getByText('暂无可见研发项目')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator('.cockpit-tree-title', { hasText: '验证主体报告' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
// 场景 7:窄屏、键盘、减少动效(DV-R07)。
|
||||
test('7 可用性:390 宽无横向溢出、键盘可达、减少动效可用', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
// 无横向溢出。
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
expect(overflow).toBeLessThanOrEqual(1)
|
||||
// 键盘:统计卡/树行可聚焦(tab 序含驾驶舱交互件)。
|
||||
await page.locator('.cockpit-tree-title').first().focus()
|
||||
await expect(page.locator('.cockpit-tree-title').first()).toBeFocused()
|
||||
// 18px 大字(html font-size 注入)不破坏统计带布局。
|
||||
await page.addStyleTag({ content: 'html{font-size:18px !important}' })
|
||||
await expect(page.locator('.cockpit-stats')).toBeVisible()
|
||||
const overflowBig = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
expect(overflowBig).toBeLessThanOrEqual(8)
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"exports": 43
|
||||
"exports": 51
|
||||
}
|
||||
|
||||
+24
-15
@@ -15,6 +15,20 @@ const BASE = ''
|
||||
* 自身命令面 fail-closed。 */
|
||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||
|
||||
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
|
||||
* passwordChangeMode 由服务端从会话派生;grant_setup 与 one_time_password
|
||||
*(管理员生成的一次性登录密码会话)免当前密码,current_required(管理员
|
||||
* 指定/生成的初始密码首登)必须校验当前密码。 */
|
||||
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
|
||||
|
||||
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
|
||||
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||||
export interface AccountRecord {
|
||||
userId: string; owner: string; profile: AccountProfile; profileVersion: number
|
||||
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
|
||||
initializationState?: string; initializationGeneration?: number
|
||||
}
|
||||
|
||||
export interface ExecEnvView {
|
||||
configured: boolean
|
||||
root?: string
|
||||
@@ -58,7 +72,7 @@ export const api = {
|
||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; 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()}`)
|
||||
@@ -66,14 +80,6 @@ export const api = {
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
// 一次性登录 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()
|
||||
@@ -83,12 +89,15 @@ export const api = {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
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)),
|
||||
// ---- 管理员:一次性登录 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)),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
|
||||
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||||
// 忘记密码的唯一处置:管理员生成一次性登录密码(明文只在本次响应出现);
|
||||
// 用户以该密码登录后进入 one_time_password 强制改密门,设置新密码后才放行。
|
||||
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; oneTimePassword: string; mustChangePassword: boolean }>(r)),
|
||||
// ---- 本人:可选资料(须完整密码会话)----
|
||||
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
|
||||
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
|
||||
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
|
||||
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
|
||||
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
|
||||
|
||||
@@ -28,7 +28,7 @@ function ProfilesPanelWrapper() {
|
||||
}
|
||||
|
||||
const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
|
||||
{ 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 /> },
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// 文档驾驶舱 I3:阅读器(连续正文 + 版本摘要/差异折叠)与右侧依据/影响面板。
|
||||
// 消费 I1/I3 命令面 rdm.cockpit.node(+versions)/node.history(Dev 仓 docs/superpowers
|
||||
// specs 2026-10-16 view-spec §5–§6)。不新增第四栏:右栏是选中节点的同一依据面,
|
||||
// 全景与阅读器共享 selectedNodeRef;面板刷新原位进行,不抢占滚动位置(DV-R05/§5)。
|
||||
// 折叠只用 @ui Collapsible;颜色/字号只消费 @ui/tokens.css。
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { Collapsible, EmptyState } from '@ui'
|
||||
import {
|
||||
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
|
||||
PANEL_TABS, type CockpitContentBlock, type CockpitHistorySummary,
|
||||
type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode,
|
||||
type CockpitVersionSummary, type Density, type PanelTabId,
|
||||
} from '../documentCockpit'
|
||||
|
||||
export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string }
|
||||
|
||||
interface NodeDetailShape {
|
||||
meta: CockpitMeta
|
||||
node: CockpitTreeNode
|
||||
body?: CockpitContentBlock[]
|
||||
children: CockpitTreeNode[]
|
||||
sources: { items: any[] }
|
||||
history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string }
|
||||
impacts: { items: any[] }
|
||||
conflicts: { items: any[] }
|
||||
versions?: CockpitVersionSummary[]
|
||||
}
|
||||
|
||||
function useNodeDetail(pid: string, sel?: CockpitNodeRef) {
|
||||
const [detail, setDetail] = useState<NodeDetailShape | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const gen = useRef(0)
|
||||
useEffect(() => {
|
||||
const g = ++gen.current
|
||||
setDetail(null); setErr('')
|
||||
if (!sel) return
|
||||
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id })
|
||||
.then(d => {
|
||||
if (g !== gen.current) return
|
||||
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
|
||||
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
|
||||
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
|
||||
}
|
||||
setDetail(d)
|
||||
})
|
||||
.catch(e => { if (g === gen.current) setErr(e.message) })
|
||||
return () => { gen.current++ }
|
||||
}, [pid, sel?.kind, sel?.id])
|
||||
return { detail, err }
|
||||
}
|
||||
|
||||
/** 版本块:当前版本 + 前一版本摘要;完整差异(行级标记)折叠。受限只给元数据。 */
|
||||
export function VersionBlock({ versions, density }: { versions?: CockpitVersionSummary[]; density: Density }) {
|
||||
if (!versions || versions.length === 0) return null
|
||||
const { current, previous, redacted } = versionSummary(versions)
|
||||
if (redacted || !current || current.content === undefined) {
|
||||
return <div className="empty-note">版本受限(仅元数据可见)</div>
|
||||
}
|
||||
const prev = previous && previous.content !== undefined ? previous : undefined
|
||||
return (
|
||||
<Collapsible
|
||||
id={`cockpit-versions-${current.version}`}
|
||||
trigger={<span className="cockpit-versions-toggle">版本 {current.version}{previous ? `(前一版 ${previous.version})` : '(首版)'}与完整差异</span>}
|
||||
>
|
||||
<div className={`cockpit-versions cockpit-density-${density}`}>
|
||||
{prev ? (
|
||||
<>
|
||||
<div className="cockpit-versions-head">当前 {current.version} · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
|
||||
<pre className="cockpit-diff">
|
||||
{diffLines(current.content || '', prev.content || '').map((l, i) => (
|
||||
<div key={i} className={`cockpit-diff-${l.flag === '=' ? 'same' : l.flag === '+' ? 'add' : 'del'}`}>{l.flag} {l.text}</div>
|
||||
))}
|
||||
</pre>
|
||||
<div className="cockpit-versions-head">前一版 {prev.version} · {prev.actor || '未知'} · {new Date(prev.at).toLocaleString('zh-CN')}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="cockpit-versions-head">首版 · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
/** 阅读器中栏:选中章节=该节连续正文+版本块;选中文档=全章节连续装配(每节详情按需拉取)。 */
|
||||
export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
||||
pid: string
|
||||
selection: ReaderSelection
|
||||
sections: CockpitTreeNode[]
|
||||
density: Density
|
||||
onBack: () => void
|
||||
}) {
|
||||
const { detail, err } = useNodeDetail(pid, selection.ref)
|
||||
const isSection = selection.ref.kind === 'section'
|
||||
const [sectionDetails, setSectionDetails] = useState<Record<string, NodeDetailShape | undefined>>({})
|
||||
const gen = useRef(0)
|
||||
// 文档阅读器:按树顺序装配全部章节详情(上限 20;失败章节给受限占位不拖垮整篇)。
|
||||
useEffect(() => {
|
||||
if (isSection || selection.ref.kind !== 'document') return
|
||||
const g = ++gen.current
|
||||
const list = sections.slice(0, 20)
|
||||
setSectionDetails({})
|
||||
for (const sec of list) {
|
||||
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: 'section', id: sec.ref.id })
|
||||
.then(d => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: d })) })
|
||||
.catch(() => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: undefined })) })
|
||||
}
|
||||
return () => { gen.current++ }
|
||||
}, [pid, isSection, selection.ref.id, sections])
|
||||
const anchorRef = useRef<HTMLDivElement | null>(null)
|
||||
useEffect(() => { anchorRef.current?.scrollIntoView({ block: 'start' }) }, [selection.ref.id])
|
||||
if (err) return <EmptyState icon="alert">阅读器加载失败:{err}</EmptyState>
|
||||
return (
|
||||
<div className={`cockpit-reader cockpit-density-${density}`}>
|
||||
<div className="cockpit-nodehead" ref={anchorRef}>
|
||||
<button type="button" className="cockpit-back" onClick={onBack}>← 返回全景</button>
|
||||
<span className="cockpit-nodetitle">{detail?.node?.title || (isSection ? '章节' : '文档')}</span>
|
||||
</div>
|
||||
{isSection && detail && (
|
||||
<>
|
||||
{renderBody(detail.body, detail.meta)}
|
||||
<VersionBlock versions={detail.versions} density={density} />
|
||||
</>
|
||||
)}
|
||||
{!isSection && (
|
||||
<>
|
||||
{sections.length === 0 && <div className="empty-note">该文档暂无章节</div>}
|
||||
{assembleReader(sections, sectionDetails).map(sec => (
|
||||
<section key={sec.ref.id} id={`sec-${sec.ref.id}`} className="cockpit-reader-section">
|
||||
<div className="cockpit-reader-sechead">
|
||||
<span className="cockpit-reader-sectitle">{sec.title}</span>
|
||||
{sec.blocks[0]?.kind === 'restricted'
|
||||
? <span className="cockpit-badge">内容受限({sec.blocks[0].reason || '权限'})</span>
|
||||
: sec.contentState === 'empty' && <span className="cockpit-badge">空</span>}
|
||||
</div>
|
||||
{sec.blocks.map((b, i) => b.kind === 'text'
|
||||
? <p key={i} className="cockpit-reader-para">{b.text}</p>
|
||||
: <p key={i} className="empty-note">内容受限——标题与状态可见,正文不展示</p>)}
|
||||
<VersionBlock versions={sec.versions} density={density} />
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta) {
|
||||
if (!body || body.length === 0) return <div className="empty-note">暂无正文</div>
|
||||
if (body[0].kind === 'restricted') {
|
||||
return <div className="empty-note">内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{body.map((b, i) => <p key={i} className="cockpit-reader-para">{b.text}</p>)}
|
||||
{meta.truncated && <div className="cockpit-truncated">详情已截断:{meta.truncationReasons?.join(';')}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 右栏:依据/历史/影响/冲突四标签(同一选中节点;刷新原位,不抢占滚动)。 */
|
||||
export function CockpitPanel({ pid, selection, tab, onTab, density }: {
|
||||
pid: string
|
||||
selection: ReaderSelection
|
||||
tab: PanelTabId
|
||||
onTab: (t: PanelTabId) => void
|
||||
density: Density
|
||||
}) {
|
||||
const { detail, err } = useNodeDetail(pid, selection.ref)
|
||||
const [historyItems, setHistoryItems] = useState<CockpitHistorySummary[]>([])
|
||||
const [historyMore, setHistoryMore] = useState(false)
|
||||
const [historyCursor, setHistoryCursor] = useState<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
setHistoryItems([]); setHistoryMore(false); setHistoryCursor(undefined)
|
||||
}, [selection.ref.kind, selection.ref.id])
|
||||
useEffect(() => {
|
||||
if (!detail?.history) return
|
||||
// 严格合并:只接受 seq 更新的条目,乱序/重复丢弃;不重置已渲染内容。
|
||||
setHistoryItems(prev => mergeHistoryStrict(prev, detail.history.items).items)
|
||||
setHistoryMore(detail.history.hasMore)
|
||||
setHistoryCursor(detail.history.nextCursor)
|
||||
}, [detail])
|
||||
const loadMoreHistory = useCallback(() => {
|
||||
if (!historyCursor) return
|
||||
api.rdmCommand<any>('rdm.cockpit.node.history', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id, cursor: historyCursor, limit: 20 })
|
||||
.then(page => {
|
||||
setHistoryItems(prev => mergeHistoryStrict(prev, page.items).items)
|
||||
setHistoryMore(page.hasMore)
|
||||
setHistoryCursor(page.nextCursor)
|
||||
})
|
||||
.catch(() => { /* 保留已渲染历史;可再次点击重试 */ })
|
||||
}, [pid, selection.ref.kind, selection.ref.id, historyCursor])
|
||||
const node = detail?.node
|
||||
return (
|
||||
<div className={`cockpit-panel cockpit-density-${density}`}>
|
||||
<div className="cockpit-panel-tabs" role="tablist" aria-label="依据与影响">
|
||||
{PANEL_TABS.map(t => (
|
||||
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id}
|
||||
className={`cockpit-panel-tab${tab === t.id ? ' cockpit-panel-tab-active' : ''}`}
|
||||
onClick={() => onTab(t.id)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{err && <div className="empty-note">依据面板加载失败:{err}</div>}
|
||||
{!err && !detail && <div className="empty-note">加载依据…</div>}
|
||||
{detail && tab === 'sources' && (
|
||||
<div className="cockpit-panel-body">
|
||||
<div className="cockpit-panel-count">来源 {node?.state.sourceCount ?? detail.sources.items.length} 条</div>
|
||||
{detail.sources.items.length === 0 && <div className="empty-note">无来源——章节正文未挂依据切片</div>}
|
||||
{detail.sources.items.map((s: any) => (
|
||||
<div key={s.ref.id} className="cockpit-panel-row">
|
||||
<span className="cockpit-panel-kind">{s.ref.kind}</span>
|
||||
<span className="cockpit-panel-label">{s.label}</span>
|
||||
{s.confidence && <span className="cockpit-badge">置信度 {s.confidence}</span>}
|
||||
{s.redactedReason
|
||||
? <span className="cockpit-badge">受限({s.redactedReason})</span>
|
||||
: s.verification && s.verification !== 'not_applicable' && <span className="cockpit-badge">{s.verification}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detail && tab === 'history' && (
|
||||
<div className="cockpit-panel-body">
|
||||
<div className="cockpit-panel-count">已提交事件(最新在前)</div>
|
||||
{historyItems.length === 0 && <div className="empty-note">暂无历史</div>}
|
||||
{historyItems.map(h => <div key={h.seq} className="cockpit-panel-row">{historyLine(h)}</div>)}
|
||||
{historyMore && <button type="button" className="cockpit-tree-more" onClick={loadMoreHistory}>加载更早历史</button>}
|
||||
</div>
|
||||
)}
|
||||
{detail && tab === 'impacts' && (
|
||||
<div className="cockpit-panel-body">
|
||||
<div className="cockpit-panel-count">影响 {node?.state.impactCount ?? detail.impacts.items.length} 项(一跳)</div>
|
||||
{detail.impacts.items.length === 0 && <div className="empty-note">无一跳影响对象</div>}
|
||||
{detail.impacts.items.map((r: any, i: number) => (
|
||||
<div key={`${r.ref.kind}-${r.ref.id}-${i}`} className="cockpit-panel-row">
|
||||
<span className="cockpit-panel-kind">{r.direction === 'incoming' ? '←' : '→'}</span>
|
||||
<span className="cockpit-panel-label">{r.ref.kind}:{r.ref.id}</span>
|
||||
<span className="cockpit-badge">{r.edgeType}</span>
|
||||
<span className="cockpit-badge">{r.edgeState}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detail && tab === 'conflicts' && (
|
||||
<div className="cockpit-panel-body">
|
||||
<div className="cockpit-panel-count">冲突 {node?.state.conflictCount ?? 0} 项</div>
|
||||
{detail.conflicts.items.length === 0 && <div className="empty-note">无开放冲突</div>}
|
||||
{detail.conflicts.items.map(c => (
|
||||
<div key={c.id} className="cockpit-panel-row cockpit-panel-conflict">
|
||||
<span className="cockpit-badge">{c.state === 'open' ? '开放' : c.state}</span>
|
||||
<div className="cockpit-conflict-side">甲方:{c.left.visibility === 'full' ? c.left.claim : `受限(${c.left.redactedReason || '权限'})`}</div>
|
||||
<div className="cockpit-conflict-side">乙方:{c.right.visibility === 'full' ? c.right.claim : `受限(${c.right.redactedReason || '权限'})`}</div>
|
||||
<div className="cockpit-changetime">{new Date(c.detectedAt).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// 文档驾驶舱 I2(rollout I2):顶部统计带 + 左侧结构树 + 中央结构全景。
|
||||
// 消费 I1 只读命令面 rdm.cockpit.*(Dev 仓 docs/superpowers/specs 2026-10-16
|
||||
// view-spec §1–§4;四项取舍冻结见 rollout §7——首屏只显示章节概览与统计,
|
||||
// 段落/正文摘要在选中与阅读器内按权限展示,本增量不含阅读器(I3))。
|
||||
// 异步所有者:generation + contextKey(项目+筛选);不匹配的旧响应直接丢弃。
|
||||
// 令牌驱动(DESIGN.md):颜色/字号只消费 @ui/tokens.css;折叠用 Collapsible。
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { EmptyState, SectionLabel } from '@ui'
|
||||
import { classifyRdmError } from '../rdmErrors'
|
||||
import {
|
||||
contextKey, loadPrefs, nodeSummaryLine, sameSnapshot, savePrefs,
|
||||
selectionSurvives, statDisplay, STAT_FILTERS, toggleFilter, treeBadges,
|
||||
type CockpitMeta, type CockpitNodeRef, type CockpitOverview,
|
||||
type CockpitTreeNode, type CockpitTreePage,
|
||||
type Density, type PanelTabId, type ViewMode,
|
||||
} from '../documentCockpit'
|
||||
import { CockpitPanel, CockpitReader, VersionBlock } from './CockpitReader'
|
||||
|
||||
interface ProjectView { projectId: string; name: string; canAudit?: boolean }
|
||||
|
||||
function CockpitErrorState({ raw }: { raw: string }) {
|
||||
const c = classifyRdmError(raw)
|
||||
return <EmptyState icon="alert">{c.message}</EmptyState>
|
||||
}
|
||||
|
||||
interface Selection { ref: CockpitNodeRef; parentDocId?: string }
|
||||
|
||||
export function DocumentCockpit() {
|
||||
const identity = useStore(() => store.getIdentityEpoch())
|
||||
const [projects, setProjects] = useState<ProjectView[] | null>(null)
|
||||
const [pid, setPid] = useState('')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [overview, setOverview] = useState<CockpitOverview | null>(null)
|
||||
const [treePages, setTreePages] = useState<Record<string, { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }>>({})
|
||||
const [selected, setSelected] = useState<Selection | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('panorama')
|
||||
const [density, setDensity] = useState<Density>('compact')
|
||||
const [panelTab, setPanelTab] = useState<PanelTabId>('sources')
|
||||
const gen = useRef(0)
|
||||
|
||||
const reload = useCallback((projectId: string, f: string) => {
|
||||
if (!projectId) return
|
||||
const g = ++gen.current
|
||||
const ctx = contextKey(projectId, f)
|
||||
setLoadErr('')
|
||||
api.rdmCommand<CockpitOverview>('rdm.cockpit.overview', { projectId })
|
||||
.then(ov => {
|
||||
if (g !== gen.current || ctx !== contextKey(pid, filter)) return // 旧响应:项目/筛选已切换,丢弃
|
||||
setOverview(ov)
|
||||
setTreePages({}) // 树页随新快照整体重建
|
||||
setSelected(sel => {
|
||||
const kept = selectionSurvives(sel?.ref || null, (ov.documents || []).map(d => d.ref.id), sel?.parentDocId)
|
||||
return kept ? { ref: kept, parentDocId: sel?.parentDocId } : null
|
||||
})
|
||||
})
|
||||
.catch(e => { if (g === gen.current && ctx === contextKey(pid, filter)) setLoadErr(e.message) })
|
||||
}, [pid, filter])
|
||||
|
||||
// 项目清单(rdm.project.list:成员可见项目,canAudit 与否不影响文档读取入口)。
|
||||
useEffect(() => {
|
||||
let c = false
|
||||
setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('')
|
||||
api.rdmCommand<{ projects?: ProjectView[] } | ProjectView[]>('rdm.project.list')
|
||||
.then(list => {
|
||||
if (c) return
|
||||
// rdm.project.list 返回 { projects: [...] };容忍裸数组形态。
|
||||
const rows = Array.isArray(list) ? list : (list?.projects ?? [])
|
||||
setProjects(rows)
|
||||
// 设计 §6.2:进入驾驶舱默认选中第一个可见项目,驱动概览首屏。
|
||||
if (rows.length > 0) setPid(prev => prev || rows[0].projectId)
|
||||
})
|
||||
.catch(e => { if (!c) setError(e.message) })
|
||||
return () => { c = true }
|
||||
}, [identity])
|
||||
|
||||
// 项目/筛选变化 → 重新取概览(统计与根文档来自同一快照)。
|
||||
useEffect(() => {
|
||||
if (!pid) return
|
||||
reload(pid, filter)
|
||||
}, [pid, filter, reload])
|
||||
|
||||
// 个人视图偏好(设计 §7.3):按 登录世代+项目 隔离,仅本地;恢复最近视图。
|
||||
useEffect(() => {
|
||||
if (!pid) return
|
||||
const prefs = loadPrefs(`epoch-${identity}`, pid)
|
||||
setViewMode(prefs.lastView)
|
||||
setDensity(prefs.density)
|
||||
setPanelTab(prefs.rightPanelTab)
|
||||
}, [identity, pid])
|
||||
const applyPrefs = useCallback((patch: { lastView?: ViewMode; density?: Density; rightPanelTab?: PanelTabId }) => {
|
||||
if (!pid) return
|
||||
setViewMode(v => patch.lastView ?? v)
|
||||
setDensity(d => patch.density ?? d)
|
||||
setPanelTab(t => patch.rightPanelTab ?? t)
|
||||
const prefs = loadPrefs(`epoch-${identity}`, pid)
|
||||
savePrefs(`epoch-${identity}`, pid, { ...prefs, ...patch })
|
||||
}, [identity, pid])
|
||||
|
||||
// 展开文档:按需加载章节子页(键集分页;旧项目/旧筛选的响应丢弃;
|
||||
// 加载失败只影响该分支——行内保留重试,不拖垮其它面板)。
|
||||
const loadChildren = useCallback((docId: string, cursor?: string) => {
|
||||
if (!pid) return
|
||||
const g = gen.current
|
||||
const ctx = contextKey(pid, filter)
|
||||
api.rdmCommand<CockpitTreePage>('rdm.cockpit.tree', { projectId: pid, parent: docId, cursor, limit: 50, filter })
|
||||
.then(page => {
|
||||
if (g !== gen.current || ctx !== contextKey(pid, filter)) return
|
||||
setTreePages(prev => {
|
||||
const old = cursor ? prev[docId] : { items: [] as CockpitTreeNode[], hasMore: false }
|
||||
const items = cursor ? [...(old?.items || []), ...page.items] : page.items
|
||||
return { ...prev, [docId]: { items, nextCursor: page.nextCursor, hasMore: page.hasMore, meta: page.meta } }
|
||||
})
|
||||
})
|
||||
.catch(() => { /* 行内重试入口承担 */ })
|
||||
}, [pid, filter])
|
||||
|
||||
if (error) return <CockpitErrorState raw={error} />
|
||||
if (!projects) return <div className="empty-note">加载中…</div>
|
||||
if (projects.length === 0) return <EmptyState icon="folder">暂无可见研发项目——项目成员身份由项目负责人授予</EmptyState>
|
||||
|
||||
const docIds = (overview?.documents || []).map(d => d.ref.id)
|
||||
const splitNotice = !!overview && Object.values(treePages).some(p => !sameSnapshot(overview.meta, p.meta))
|
||||
const selectDoc = (id: string) => setSelected({ ref: { kind: 'document', id } })
|
||||
const selectSection = (docId: string, sec: CockpitTreeNode) => setSelected({ ref: sec.ref, parentDocId: docId })
|
||||
return (
|
||||
<div className="cockpit">
|
||||
<div className="cockpit-header">
|
||||
<select className="ui-input cockpit-project" value={pid} onChange={e => { setPid(e.target.value); setSelected(null); setFilter('') }} aria-label="选择项目">
|
||||
{projects.map(p => <option key={p.projectId} value={p.projectId}>{p.name}</option>)}
|
||||
</select>
|
||||
{filter && <button type="button" className="cockpit-filter-chip" onClick={() => setFilter('')}>
|
||||
筛选:{STAT_FILTERS.find(s => s.filter === filter)?.label || filter} ✕
|
||||
</button>}
|
||||
<span className="cockpit-snap">{overview ? `快照 ${overview.meta.snapshotRevision}` : '加载中…'}</span>
|
||||
<div className="cockpit-viewtoggle" role="group" aria-label="视图切换">
|
||||
<button type="button" className={`cockpit-viewbtn${viewMode === 'panorama' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'panorama' })}>全景</button>
|
||||
<button type="button" className={`cockpit-viewbtn${viewMode === 'reader' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'reader' })}>阅读器</button>
|
||||
<button type="button" className="cockpit-viewbtn" onClick={() => applyPrefs({ density: density === 'compact' ? 'full' : 'compact' })} title="切换密度">{density === 'compact' ? '紧凑' : '宽松'}</button>
|
||||
</div>
|
||||
<button type="button" className="cockpit-refresh" onClick={() => reload(pid, filter)} disabled={!pid}>刷新</button>
|
||||
</div>
|
||||
{splitNotice && (
|
||||
<div className="cockpit-banner" role="status">统计与树快照不一致——请刷新以对齐最新快照</div>
|
||||
)}
|
||||
{loadErr && <div className="cockpit-banner cockpit-banner-err" role="alert">概览读取失败:{loadErr}(保留上一快照,可重试)</div>}
|
||||
{overview && (
|
||||
<div className="cockpit-stats" role="group" aria-label="项目统计">
|
||||
<div className="cockpit-stat">
|
||||
<span className="cockpit-stat-num">{statDisplay(overview.stats, 'completion').value}</span>
|
||||
<span className="cockpit-stat-label">完成度{statDisplay(overview.stats, 'completion').hint ? `(${statDisplay(overview.stats, 'completion').hint})` : ''}</span>
|
||||
</div>
|
||||
<div className="cockpit-stat"><span className="cockpit-stat-num">{statDisplay(overview.stats, 'documents').value}</span><span className="cockpit-stat-label">文档</span></div>
|
||||
{STAT_FILTERS.map(s => {
|
||||
const d = statDisplay(overview.stats, s.key)
|
||||
const active = filter === s.filter
|
||||
return (
|
||||
<button key={s.key} type="button" className={`cockpit-stat${active ? ' cockpit-stat-active' : ''}`}
|
||||
onClick={() => setFilter(f => toggleFilter(f, s.filter))}
|
||||
title={d.hint ? `${s.label}:${d.hint}` : `筛选:${s.label}`}>
|
||||
<span className="cockpit-stat-num">{d.value}</span>
|
||||
<span className="cockpit-stat-label">{s.label}{d.hint ? `(${d.hint})` : ''}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="cockpit-stat cockpit-stat-unavail"><span className="cockpit-stat-num">—</span><span className="cockpit-stat-label">待确认(未纳入投影)</span></div>
|
||||
<div className="cockpit-stat"><span className="cockpit-stat-num">{overview.stats.updatedAt ? new Date(overview.stats.updatedAt).toLocaleString('zh-CN') : '—'}</span><span className="cockpit-stat-label">最近更新</span></div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`cockpit-columns${selected ? ' cockpit-columns-3' : ''}`}>
|
||||
<div className="cockpit-tree" role="tree" aria-label="文档结构树">
|
||||
<SectionLabel>结构树</SectionLabel>
|
||||
{!overview && <div className="empty-note">加载结构…</div>}
|
||||
{overview && docIds.length === 0 && <div className="empty-note">暂无受控文档</div>}
|
||||
{overview && overview.meta.truncated && (
|
||||
<div className="cockpit-truncated">文档清单已截断({overview.meta.truncationReasons?.join(';') || '超出上限'})</div>
|
||||
)}
|
||||
{overview && overview.documents.map(d => (
|
||||
<TreeRow key={d.ref.id} node={d} depth={0}
|
||||
expanded={treePages[d.ref.id] !== undefined}
|
||||
childPage={treePages[d.ref.id]}
|
||||
selectedId={selected?.ref.id}
|
||||
onSelectNode={n => (n.ref.kind === 'document' ? selectDoc(n.ref.id) : selectSection(d.ref.id, n))}
|
||||
onExpand={() => { if (treePages[d.ref.id] === undefined) loadChildren(d.ref.id) }}
|
||||
onLoadMore={() => { const p = treePages[d.ref.id]; if (p?.nextCursor) loadChildren(d.ref.id, p.nextCursor) }}
|
||||
onRetryChild={() => { const p = treePages[d.ref.id]; loadChildren(d.ref.id, p?.nextCursor) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="cockpit-center">
|
||||
{!overview && <div className="empty-note">加载概览…</div>}
|
||||
{overview && !selected && (
|
||||
<PanoramaHome overview={overview} onSelect={selectDoc} />
|
||||
)}
|
||||
{overview && selected && viewMode === 'panorama' && (
|
||||
<PanoramaNode pid={pid} selection={selected} overview={overview}
|
||||
snapRev={overview.meta.snapshotRevision}
|
||||
childPage={selected.ref.kind === 'document' ? treePages[selected.ref.id] : undefined}
|
||||
density={density}
|
||||
onClear={() => setSelected(null)}
|
||||
onOpenReader={() => applyPrefs({ lastView: 'reader' })} />
|
||||
)}
|
||||
{overview && selected && viewMode === 'reader' && (
|
||||
<CockpitReader pid={pid} selection={selected}
|
||||
sections={selected.ref.kind === 'document' ? (treePages[selected.ref.id]?.items || []) : []}
|
||||
density={density} onBack={() => applyPrefs({ lastView: 'panorama' })} />
|
||||
)}
|
||||
</div>
|
||||
{overview && selected && (
|
||||
<div className="cockpit-right">
|
||||
<CockpitPanel pid={pid} selection={selected} tab={panelTab} onTab={t => applyPrefs({ rightPanelTab: t })} density={density} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeRow({ node, depth, expanded, childPage, selectedId, onSelectNode, onExpand, onLoadMore, onRetryChild }: {
|
||||
node: CockpitTreeNode; depth: number
|
||||
expanded: boolean
|
||||
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
|
||||
selectedId?: string
|
||||
onSelectNode: (n: CockpitTreeNode) => void
|
||||
onExpand: () => void
|
||||
onLoadMore: () => void
|
||||
onRetryChild: () => void
|
||||
}) {
|
||||
const badges = treeBadges(node)
|
||||
const selected = selectedId === node.ref.id
|
||||
return (
|
||||
<div className="cockpit-tree-branch">
|
||||
<div className={`cockpit-tree-row${selected ? ' cockpit-tree-row-active' : ''}`} style={{ paddingLeft: depth * 14 }} role="treeitem" aria-selected={selected}>
|
||||
{node.ref.kind === 'document' && (
|
||||
<button type="button" className="cockpit-tree-caret" aria-label={expanded ? '折叠' : '展开'}
|
||||
aria-expanded={expanded} onClick={e => { e.stopPropagation(); if (!expanded) onExpand() }}>
|
||||
{expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="cockpit-tree-title" onClick={() => onSelectNode(node)}>{node.title}</button>
|
||||
{badges.map(b => <span key={b} className="cockpit-badge">{b}</span>)}
|
||||
{node.lastChangedAt && <span className="cockpit-tree-time">{new Date(node.lastChangedAt).toLocaleDateString('zh-CN')}</span>}
|
||||
</div>
|
||||
{expanded && !childPage && (
|
||||
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onRetryChild}>章节加载失败,重试</button>
|
||||
)}
|
||||
{expanded && childPage && (
|
||||
<div>
|
||||
{childPage.items.length === 0 && <div className="cockpit-tree-empty" style={{ paddingLeft: (depth + 1) * 14 }}>无章节</div>}
|
||||
{childPage.items.map(sec => (
|
||||
<TreeRow key={sec.ref.id} node={sec} depth={depth + 1} expanded={false} childPage={undefined}
|
||||
selectedId={selectedId} onSelectNode={onSelectNode}
|
||||
onExpand={onExpand} onLoadMore={onLoadMore} onRetryChild={onRetryChild} />
|
||||
))}
|
||||
{childPage.hasMore && childPage.items.length > 0 && (
|
||||
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onLoadMore}>加载更多章节</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSelect: (id: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>{overview.project.title} · 文档概览</SectionLabel>
|
||||
{overview.documents.length === 0 && <EmptyState icon="folder">暂无受控文档——文档由模板实例化或导入后进入驾驶舱</EmptyState>}
|
||||
<div className="cockpit-doclist">
|
||||
{overview.documents.map(d => (
|
||||
<button key={d.ref.id} type="button" className="cockpit-docrow" onClick={() => onSelect(d.ref.id)}>
|
||||
<span className="cockpit-docrow-title">{d.title}</span>
|
||||
<span className="cockpit-docrow-meta">{nodeSummaryLine(d)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SectionLabel>最近变化</SectionLabel>
|
||||
{overview.recentChanges.length === 0 && <div className="empty-note">暂无可见变化</div>}
|
||||
<div className="cockpit-changes">
|
||||
{overview.recentChanges.map((c, i) => (
|
||||
<div key={`${c.node.id}-${i}`} className="cockpit-changerow">
|
||||
<span className="cockpit-changesum">{c.summary} · {c.title}{c.actor ? ` · ${c.actor}` : ''}</span>
|
||||
<span className="cockpit-changetime">{new Date(c.changedAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanoramaNode({ pid, selection, overview, childPage, density, snapRev, onClear, onOpenReader }: {
|
||||
pid: string; selection: Selection; overview: CockpitOverview
|
||||
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
|
||||
density: Density
|
||||
snapRev: string
|
||||
onClear: () => void
|
||||
onOpenReader: () => void
|
||||
}) {
|
||||
const [detail, setDetail] = useState<any>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const gen = useRef(0)
|
||||
useEffect(() => {
|
||||
const g = ++gen.current
|
||||
setDetail(null); setErr('')
|
||||
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id })
|
||||
.then(d => {
|
||||
if (g !== gen.current) return
|
||||
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
|
||||
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
|
||||
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
|
||||
}
|
||||
setDetail(d)
|
||||
})
|
||||
.catch(e => { if (g === gen.current) setErr(e.message) })
|
||||
return () => { gen.current++ }
|
||||
}, [pid, selection.ref.kind, selection.ref.id, snapRev])
|
||||
const node = (detail?.node || null) as CockpitTreeNode | null
|
||||
const title = node?.title || selection.ref.id
|
||||
return (
|
||||
<div>
|
||||
<div className="cockpit-nodehead">
|
||||
<button type="button" className="cockpit-back" onClick={onClear}>← 返回概览</button>
|
||||
<span className="cockpit-nodetitle">{title}</span>
|
||||
{node && <span className="cockpit-nodesum">{nodeSummaryLine(node)}</span>}
|
||||
<button type="button" className="cockpit-filter-chip" onClick={onOpenReader}>在阅读器中打开</button>
|
||||
</div>
|
||||
{err && <CockpitErrorState raw={err} />}
|
||||
{detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && (
|
||||
<div className="empty-note">内容受限({detail.body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
||||
)}
|
||||
{selection.ref.kind === 'section' && detail?.versions && detail.versions.length > 0 && (
|
||||
<VersionBlock versions={detail.versions} density={density} />
|
||||
)}
|
||||
{detail && detail.meta && !sameSnapshot(detail.meta, overview.meta) && (
|
||||
<div className="cockpit-truncated">节点详情与统计快照不同({detail.meta.snapshotRevision} vs {overview.meta.snapshotRevision})——刷新对齐</div>
|
||||
)}
|
||||
{node && node.ref.kind === 'document' && (
|
||||
<>
|
||||
<SectionLabel>章节状态</SectionLabel>
|
||||
{!childPage && <div className="empty-note">章节按需加载——在左侧树展开</div>}
|
||||
{childPage && childPage.items.length === 0 && <div className="empty-note">无章节</div>}
|
||||
{childPage && childPage.items.length > 0 && (
|
||||
<table className="cockpit-childtable">
|
||||
<thead><tr><th>章节</th><th>内容</th><th>核实</th><th>来源</th><th>冲突</th><th>最近更新</th></tr></thead>
|
||||
<tbody>
|
||||
{childPage.items.map(sec => (
|
||||
<tr key={sec.ref.id}>
|
||||
<td>{sec.title}</td>
|
||||
<td>{{ complete: '完整', partial: '部分', empty: '空', inaccessible: '受限', unavailable: '不可读' }[sec.state.content] || sec.state.content}</td>
|
||||
<td>{sec.state.verification === 'not_applicable' ? '—' : sec.state.verification}</td>
|
||||
<td>{sec.state.sourceCount}</td>
|
||||
<td>{sec.state.conflictCount}</td>
|
||||
<td>{sec.lastChangedAt ? new Date(sec.lastChangedAt).toLocaleDateString('zh-CN') : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{node && node.ref.kind === 'section' && (
|
||||
<div className="empty-note">点「在阅读器中打开」查看连续正文与版本差异;右侧为该节点的依据与影响面板</div>
|
||||
)}
|
||||
{detail?.meta?.truncated && (
|
||||
<div className="cockpit-truncated">详情已截断:{detail.meta.truncationReasons?.join(';')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key);
|
||||
// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api'
|
||||
// LoginPage —— 用户名+密码唯一入口;
|
||||
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
|
||||
// grant_setup 免当前密码(激活/恢复 Key 兑换),one_time_password 免当前密码
|
||||
//(管理员生成的一次性登录密码会话,只需新密码 + 确认),current_required 必须
|
||||
// 校验当前密码(管理员指定/生成的初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type PasswordChangeMode } from '../api'
|
||||
import { Icon, BrandMark } from '@ui'
|
||||
|
||||
function Err({ msg }: { msg: string }) {
|
||||
@@ -31,10 +34,8 @@ function BrandPanel() {
|
||||
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
|
||||
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
|
||||
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
|
||||
const [tab, setTab] = useState<'password' | 'loginkey'>('password')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [key, setKey] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
@@ -54,74 +55,71 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const submitKey = async () => {
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
// 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。
|
||||
const me = await api.redeemLoginKey(key.trim())
|
||||
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
||||
onMustChange(me.owner)
|
||||
return
|
||||
}
|
||||
onAuthed()
|
||||
} catch (e) {
|
||||
setErr(humanRedeemError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lg-wrap">
|
||||
<BrandPanel />
|
||||
<div className="lg-formside">
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); submitPassword() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">登录</h2>
|
||||
<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 === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
|
||||
</div>
|
||||
{tab === 'password' ? (
|
||||
<>
|
||||
<label className="lg-label">用户名</label>
|
||||
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
||||
<label className="lg-label">密码</label>
|
||||
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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">忘记密码时由管理员签发;单次有效、默认 30 分钟内兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
|
||||
</>
|
||||
)}
|
||||
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员生成一次性登录密码,使用后须立即改密</p>
|
||||
<label className="lg-label">用户名</label>
|
||||
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
||||
<label className="lg-label">密码</label>
|
||||
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。
|
||||
* 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框;
|
||||
* 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */
|
||||
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
|
||||
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)与 one_time_password
|
||||
*(一次性登录密码会话)免当前密码。 */
|
||||
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
||||
return mode === 'current_required'
|
||||
}
|
||||
|
||||
/** 设密门只认服务端声明的模式;未知/缺失模式一律拒绝放行。 */
|
||||
export function isPasswordChangeMode(mode: string | null | undefined): mode is PasswordChangeMode {
|
||||
return mode === 'grant_setup' || mode === 'current_required' || mode === 'one_time_password'
|
||||
}
|
||||
|
||||
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
|
||||
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
|
||||
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
||||
const [mode, setMode] = useState<PasswordChangeMode | null>(null)
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.me().then(me => {
|
||||
if (!active) return
|
||||
if (me.owner !== owner || me.sessionScope !== 'set_password_only' || !isPasswordChangeMode(me.passwordChangeMode)) {
|
||||
setErr('当前设密凭据不再有效,请重新登录;忘记密码请联系管理员重新生成一次性登录密码。')
|
||||
return
|
||||
}
|
||||
setMode(me.passwordChangeMode)
|
||||
}).catch(() => { if (active) setErr('无法确认当前设密方式,请重新登录。') })
|
||||
return () => { active = false }
|
||||
}, [owner])
|
||||
|
||||
const submit = async () => {
|
||||
setErr('')
|
||||
if (!mode) { setErr('设密方式尚未由服务端确认'); return }
|
||||
if (requiresCurrentPassword(mode) && !current) { setErr('请填写当前密码'); return }
|
||||
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
|
||||
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.changePassword('', next)
|
||||
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
|
||||
setDone(true)
|
||||
} catch (e) {
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
@@ -143,13 +141,21 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
<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>
|
||||
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
|
||||
{mode === 'current_required' && <>
|
||||
<p className="lg-hint">首次登录必须更换初始密码;「当前密码」填写刚才登录用的初始密码。</p>
|
||||
<label className="lg-label" htmlFor="forced-current">当前密码</label>
|
||||
<input id="forced-current" className="lg-input" type="password" value={current}
|
||||
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
|
||||
</>}
|
||||
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
|
||||
{mode === 'one_time_password' && <p className="lg-hint">你正在使用管理员生成的一次性登录密码登录,请直接设置新密码,无需再次输入一次性密码。</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>
|
||||
<button className="lg-submit" type="submit" disabled={busy || !mode || (requiresCurrentPassword(mode) && !current) || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
|
||||
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
|
||||
</form>
|
||||
)}
|
||||
@@ -166,15 +172,6 @@ export function humanLoginError(raw: string): string {
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 一次性登录 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 契约的一部分,这里按其区分并用测试锁定。 */
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
|
||||
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type AccountProfile, type AccountRecord } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { Collapsible, Icon, SettingsGroup } from '@ui'
|
||||
|
||||
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
||||
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
|
||||
]
|
||||
|
||||
export function MyAccountSettings() {
|
||||
const me = useStore(s => s.me)
|
||||
const [account, setAccount] = useState<AccountRecord | null>(null)
|
||||
const [draft, setDraft] = useState<AccountProfile | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const sequence = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!me?.auth) return
|
||||
const seq = ++sequence.current
|
||||
api.meProfile().then(profile => {
|
||||
if (seq !== sequence.current) return
|
||||
setAccount(profile); setDraft(profile.profile)
|
||||
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
|
||||
return () => { sequence.current++ }
|
||||
}, [me?.owner, me?.auth])
|
||||
|
||||
if (!me?.auth) return null
|
||||
|
||||
const save = async () => {
|
||||
if (!account || !draft) return
|
||||
const patch: Partial<AccountProfile> = {}
|
||||
for (const field of fields) if (draft[field.key] !== account.profile[field.key]) patch[field.key] = draft[field.key]
|
||||
if (Object.keys(patch).length === 0) { setNotice('资料没有变化'); return }
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const updated = await api.patchMeProfile(account.profileVersion, patch)
|
||||
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <SettingsGroup title="我的账号">
|
||||
{account && draft ? <>
|
||||
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空。</p>
|
||||
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
|
||||
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
||||
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
|
||||
<input id={`my-profile-${field.key}`} className="lg-input" value={draft[field.key]}
|
||||
onChange={event => setDraft({ ...draft, [field.key]: event.target.value })} />
|
||||
</div>)}
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
|
||||
</Collapsible>
|
||||
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
|
||||
|
||||
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密);生成后原密码仍可正常登录,用原密码登录会使未使用的一次性登录密码作废。</p>
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||
</SettingsGroup>
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import type { ThemeChoice } from '../theme'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
|
||||
import { BorrowPanels } from './BorrowPanels'
|
||||
import { MyAccountSettings } from './MyAccountSettings'
|
||||
|
||||
type Section = 'settings'
|
||||
type Section = 'settings' | 'account'
|
||||
|
||||
// 分组显示名(项目设置面板共用)。
|
||||
export const groupNames: Record<string, string> = {
|
||||
@@ -19,7 +20,7 @@ export const groupNames: Record<string, string> = {
|
||||
}
|
||||
|
||||
export function SettingsModal() {
|
||||
const [section] = useState<Section>('settings')
|
||||
const [section, setSection] = useState<Section>('settings')
|
||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||
const [, setUser] = useState<Record<string, any>>({})
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
@@ -54,10 +55,11 @@ export function SettingsModal() {
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
title="设置" active={section} onSelect={() => undefined}
|
||||
title="设置" active={section} onSelect={key => setSection(key as Section)}
|
||||
onClose={() => store.openSettings(false)}
|
||||
sections={[
|
||||
{ key: 'settings', label: '常规', icon: 'settings' },
|
||||
{ key: 'account', label: '账号', icon: 'user' },
|
||||
]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
|
||||
actions={section === 'settings' && schema.length > 0 ? (
|
||||
@@ -108,6 +110,7 @@ export function SettingsModal() {
|
||||
)
|
||||
})}
|
||||
{section === 'settings' && <BorrowPanels />}
|
||||
{section === 'account' && <MyAccountSettings />}
|
||||
</SettingsShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
|
||||
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
|
||||
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
|
||||
const theme = useStore(s => s.ui.theme)
|
||||
const pendingBadge = useStore(s =>
|
||||
s.cards.filter(c => c.status === 'action_required').length +
|
||||
@@ -74,7 +75,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
} catch { /* store owns the actionable failure toast */ }
|
||||
}
|
||||
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable, rdmCockpit: rdmCockpitAvailable })
|
||||
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
|
||||
const pinned = visible.filter(t => t.pinned)
|
||||
const rest = visible.filter(t => !t.pinned)
|
||||
|
||||
@@ -8,12 +8,13 @@ import { docBridge } from './Workbench'
|
||||
import { AgentRow } from './Workbench'
|
||||
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
import { DocumentCockpit } from './DocumentCockpit'
|
||||
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
|
||||
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
|
||||
export function workspaceEntries(): WorkspaceEntry[] {
|
||||
const s = store.getState()
|
||||
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable })
|
||||
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable, rdmCockpit: s.rdmCockpitAvailable })
|
||||
}
|
||||
|
||||
export function Workspaces() {
|
||||
@@ -36,6 +37,11 @@ export function Workspaces() {
|
||||
<RdmAuditWorkspace />
|
||||
</OverlayHost>
|
||||
}
|
||||
if (active === 'rdm-cockpit') {
|
||||
return <OverlayHost title="文档驾驶舱" icon="📋" shortcut={entry?.shortcut} sayHint="打开文档驾驶舱" onClose={close}>
|
||||
<DocumentCockpit />
|
||||
</OverlayHost>
|
||||
}
|
||||
if (active.startsWith('plugin:')) {
|
||||
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
|
||||
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
|
||||
|
||||
@@ -9,19 +9,10 @@ import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocB
|
||||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import { randomPassword } from '../passwords'
|
||||
|
||||
// ---- 用户管理(原 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>
|
||||
}
|
||||
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码只有一个处置——
|
||||
// 管理员生成一次性登录密码,用户使用后必须强制改密。 ----
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
|
||||
export function UsersPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
@@ -30,60 +21,34 @@ export function UsersPanel() {
|
||||
const [notice, setNotice] = useState('')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [pw, setPw] = useState('')
|
||||
const [asAdmin, setAsAdmin] = useState(false)
|
||||
const [initialPassword, setInitialPassword] = useState('')
|
||||
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'one-time' } | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 登录 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 }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
|
||||
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
|
||||
setOwner(''); setName(''); setPw('')
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined)
|
||||
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
|
||||
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
|
||||
setOwner(''); setName(''); setInitialPassword('')
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const issue = async () => {
|
||||
if (!keyFor) return
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
const issueOneTimePassword = async (u: AdminUserRow) => {
|
||||
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
|
||||
try {
|
||||
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)
|
||||
const out = await api.adminIssueOneTimePassword(u.owner)
|
||||
setGeneratedCredential({ owner: u.owner, password: out.oneTimePassword, kind: 'one-time' })
|
||||
setNotice(`已为 ${u.owner} 生成一次性登录密码,原密码仍可正常登录。该用户用一次性密码登录后必须立即设置新密码(强制改密);若改用原密码登录,未使用的一次性登录密码随即作废,且不会被要求改密。`)
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
@@ -94,69 +59,40 @@ export function UsersPanel() {
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
|
||||
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
|
||||
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
|
||||
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
|
||||
<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">创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。</p>
|
||||
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{generatedCredential && <div className="ad-reset">
|
||||
<p className="ad-hint">{generatedCredential.kind === 'initial'
|
||||
? `初始密码仅显示一次,请立即安全交付给 ${generatedCredential.owner}。`
|
||||
: `一次性登录密码仅显示一次,请立即安全交付给 ${generatedCredential.owner};使用后必须强制改密。`}</p>
|
||||
<input className="lg-input ad-mono" readOnly value={generatedCredential.password} aria-label="一次性密码" />
|
||||
<button type="button" className="ad-mini" onClick={() => setGeneratedCredential(null)}>关闭</button>
|
||||
</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
{keyFor ? (
|
||||
<div className="ad-reset">
|
||||
<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">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<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={() => openKeyPanel(u.owner)}>生成登录 Key</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点该用户行的「生成一次性登录密码」:生成不会使原密码失效。用户用一次性密码登录后必须设置新密码才能继续;若用户改用原密码登录,未使用的一次性登录密码随即作废,且不会被要求改密。</p>}
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<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" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// 文档驾驶舱 I2 纯逻辑层测试:能力门控、筛选切换、统计呈现(真 0 vs 不可算)、
|
||||
// 快照一致性/更新提示、上下文键、选择保留、树徽标。组件取数编排不在此覆盖。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
contextKey, nodeSummaryLine, sameSnapshot, selectionSurvives, statDisplay,
|
||||
STAT_FILTERS, toggleFilter, treeBadges, updateNotice,
|
||||
type CockpitMeta, type CockpitStats, type CockpitTreeNode,
|
||||
} from './documentCockpit'
|
||||
import { cockpitReady, COCKPIT_COMMANDS } from './workspaces'
|
||||
|
||||
const meta = (rev: string, seq: number): CockpitMeta => ({
|
||||
schemaVersion: 'document-cockpit.v1', projectId: 'p1', snapshotRevision: rev,
|
||||
asOfSeq: seq, generatedAt: '2026-10-16T00:00:00Z', truncated: false,
|
||||
})
|
||||
|
||||
describe('cockpitReady', () => {
|
||||
const all = COCKPIT_COMMANDS.map(id => ({ id, enabled: true }))
|
||||
it('七命令齐备且 enabled 才就绪', () => {
|
||||
expect(cockpitReady(null)).toBe(false)
|
||||
expect(cockpitReady([])).toBe(false)
|
||||
expect(cockpitReady(all.map(c => ({ ...c, enabled: false })))).toBe(false)
|
||||
expect(cockpitReady(all.slice(0, 6))).toBe(false)
|
||||
expect(cockpitReady(all)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('统计呈现:真 0 与不可算不得互相冒充', () => {
|
||||
const stats: CockpitStats = {
|
||||
documentCount: 0,
|
||||
completion: { known: 3, total: 5, state: 'known' },
|
||||
emptyNodeCount: 0,
|
||||
unverifiedSourceCount: 2,
|
||||
conflictCount: null as unknown as number,
|
||||
pendingChangeCount: null,
|
||||
updatedAt: '',
|
||||
statReasons: { conflictCount: 'upstream_error', pendingChangeCount: 'not_projected_v1' },
|
||||
}
|
||||
it('真 0 显示 0', () => {
|
||||
expect(statDisplay(stats, 'empty').value).toBe('0')
|
||||
expect(statDisplay(stats, 'documents').value).toBe('0')
|
||||
})
|
||||
it('null 显示 — 并带原因', () => {
|
||||
const d = statDisplay(stats, 'conflict')
|
||||
expect(d.value).toBe('—')
|
||||
expect(d.hint).toBe('upstream_error')
|
||||
})
|
||||
it('未纳入投影的统计显式标注原因', () => {
|
||||
const d = statDisplay(stats, 'pending')
|
||||
expect(d.value).toBe('—')
|
||||
expect(d.hint).toBe('not_projected_v1')
|
||||
})
|
||||
it('完成度三分态:known/partial/unavailable', () => {
|
||||
expect(statDisplay(stats, 'completion').value).toBe('3/5')
|
||||
expect(statDisplay({ ...stats, completion: { known: 1, total: 5, state: 'partial' } }, 'completion').hint).toBe('部分计算')
|
||||
const u = statDisplay({ ...stats, completion: { known: 0, total: 0, state: 'unavailable' } }, 'completion')
|
||||
expect(u.value).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('筛选切换', () => {
|
||||
it('点击切换、再点清除;统计键与筛选值一一对应', () => {
|
||||
expect(toggleFilter('', 'state:empty')).toBe('state:empty')
|
||||
expect(toggleFilter('state:empty', 'state:empty')).toBe('')
|
||||
expect(toggleFilter('state:empty', 'state:conflict')).toBe('state:conflict')
|
||||
expect(STAT_FILTERS.map(s => s.filter)).toEqual(['state:empty', 'state:unverified', 'state:conflict'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('快照一致性与更新提示', () => {
|
||||
it('同 revision 同 seq 视为同快照', () => {
|
||||
expect(sameSnapshot(meta('wm:5', 5), meta('wm:5', 5))).toBe(true)
|
||||
expect(sameSnapshot(meta('wm:5', 5), meta('wm:6', 6))).toBe(false)
|
||||
expect(sameSnapshot(undefined, meta('wm:5', 5))).toBe(false)
|
||||
})
|
||||
it('展示快照落后时提示刷新,一致时不提示', () => {
|
||||
expect(updateNotice(meta('wm:5', 5), meta('wm:6', 6))).toContain('wm:6')
|
||||
expect(updateNotice(meta('wm:6', 6), meta('wm:6', 6))).toBeNull()
|
||||
expect(updateNotice(null, meta('wm:6', 6))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('旧响应丢弃的上下文键', () => {
|
||||
it('项目与筛选任一变化都改变键', () => {
|
||||
expect(contextKey('p1', '')).not.toBe(contextKey('p2', ''))
|
||||
expect(contextKey('p1', '')).not.toBe(contextKey('p1', 'state:empty'))
|
||||
expect(contextKey('p1', 'state:empty')).toBe(contextKey('p1', 'state:empty'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('刷新不重置选择', () => {
|
||||
it('文档仍在根清单则保留', () => {
|
||||
const kept = selectionSurvives({ kind: 'document', id: 'd1' }, ['d1', 'd2'])
|
||||
expect(kept).toEqual({ kind: 'document', id: 'd1' })
|
||||
})
|
||||
it('文档被筛掉则清空', () => {
|
||||
expect(selectionSurvives({ kind: 'document', id: 'd9' }, ['d1', 'd2'])).toBeNull()
|
||||
})
|
||||
it('章节选择随父文档存活', () => {
|
||||
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d1'], 'd1')).toEqual({ kind: 'section', id: 's1' })
|
||||
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d2'], 'd1')).toBeNull()
|
||||
expect(selectionSurvives(null, ['d1'])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('树徽标与状态行', () => {
|
||||
const node = (over: Partial<CockpitTreeNode['state']> & { visibility?: string }): CockpitTreeNode => ({
|
||||
ref: { kind: 'section', id: 's1' }, title: '章节', path: [], childCount: 0,
|
||||
visibility: over.visibility || 'full',
|
||||
state: {
|
||||
content: 'complete', verification: 'not_applicable', sourceCount: 0,
|
||||
conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true, ...over,
|
||||
},
|
||||
} as CockpitTreeNode)
|
||||
it('诚实徽标:空/受限/未核实/冲突计数', () => {
|
||||
expect(treeBadges(node({}))).toEqual([])
|
||||
expect(treeBadges(node({ content: 'empty' }))).toContain('空')
|
||||
expect(treeBadges(node({ verification: 'unverified' }))).toContain('未核实')
|
||||
expect(treeBadges(node({ conflictCount: 2 }))).toContain('冲突 2')
|
||||
expect(treeBadges(node({ visibility: 'metadata_only' }))).toContain('仅元数据')
|
||||
})
|
||||
it('结构缺口显式标注(冻结事实:doc→section 两级)', () => {
|
||||
const line = nodeSummaryLine(node({ content: 'empty' }))
|
||||
expect(line).toContain('内容为空')
|
||||
expect(line).toContain('层级止于章节')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- I3:阅读器与右栏纯逻辑 ----
|
||||
import {
|
||||
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
|
||||
prefsKey, mergePrefs, loadPrefs,
|
||||
type CockpitHistorySummary, type CockpitVersionSummary,
|
||||
} from './documentCockpit'
|
||||
|
||||
describe('偏好:按用户+项目隔离,枚举外脏值回落默认', () => {
|
||||
it('prefsKey 区分用户与项目', () => {
|
||||
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('a', 'p2'))
|
||||
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('b', 'p1'))
|
||||
})
|
||||
it('mergePrefs 逐项校验', () => {
|
||||
expect(mergePrefs(null)).toEqual({ lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' })
|
||||
expect(mergePrefs({ lastView: 'reader', density: 'full', rightPanelTab: 'conflicts' })).toEqual({
|
||||
lastView: 'reader', density: 'full', rightPanelTab: 'conflicts',
|
||||
})
|
||||
const junk = mergePrefs({ lastView: 'galaxy', density: 42, rightPanelTab: 'nope' })
|
||||
expect(junk.lastView).toBe('panorama')
|
||||
expect(junk.density).toBe('compact')
|
||||
expect(junk.rightPanelTab).toBe('sources')
|
||||
})
|
||||
it('loadPrefs 在无 localStorage 环境安全回落', () => {
|
||||
expect(loadPrefs('a', 'p1').lastView).toBe('panorama')
|
||||
})
|
||||
})
|
||||
|
||||
describe('版本摘要与行级差异', () => {
|
||||
const v = (seq: number, content?: string, redacted?: boolean): CockpitVersionSummary => ({
|
||||
seq, version: `v${seq}`, at: '2026-10-16T00:00:00Z', basisCount: 0, content, redacted,
|
||||
})
|
||||
it('当前+前一版;受限整体降级', () => {
|
||||
const s = versionSummary([v(3, '三'), v(2, '二'), v(1, '一')])
|
||||
expect(s.current?.version).toBe('v3')
|
||||
expect(s.previous?.version).toBe('v2')
|
||||
expect(s.redacted).toBe(false)
|
||||
const r = versionSummary([v(3, undefined, true)])
|
||||
expect(r.redacted).toBe(true)
|
||||
expect(versionSummary(undefined).current).toBeUndefined()
|
||||
})
|
||||
it('diffLines 标记新增/删除/保留', () => {
|
||||
const d = diffLines('共同\n新增行', '共同\n删除行')
|
||||
expect(d).toContainEqual({ flag: '=', text: '共同' })
|
||||
expect(d).toContainEqual({ flag: '+', text: '新增行' })
|
||||
expect(d).toContainEqual({ flag: '-', text: '删除行' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('历史严格合并:乱序与重复丢弃(DV-R05)', () => {
|
||||
const h = (seq: number): CockpitHistorySummary => ({ seq, at: '', op: 'x', summary: `s${seq}` })
|
||||
it('只接受 seq 更大的条目', () => {
|
||||
const r = mergeHistoryStrict([h(10), h(9)], [h(11), h(8)])
|
||||
expect(r.items.map(i => i.seq)).toEqual([10, 9, 11])
|
||||
expect(r.dropped).toBe(1)
|
||||
})
|
||||
it('重复 seq 丢弃且计入 dropped', () => {
|
||||
const r = mergeHistoryStrict([h(10)], [h(10), h(11)])
|
||||
expect(r.items.map(i => i.seq)).toEqual([10, 11])
|
||||
expect(r.dropped).toBe(1)
|
||||
})
|
||||
it('空历史接受全部', () => {
|
||||
expect(mergeHistoryStrict([], [h(1), h(2)]).items.map(i => i.seq)).toEqual([1, 2])
|
||||
})
|
||||
it('historyLine 带操作者与本地时间', () => {
|
||||
expect(historyLine(h(1))).toContain('s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('阅读器装配:受限章节给占位不冒充空内容', () => {
|
||||
const sec = (id: string, content: string): CockpitTreeNode => ({
|
||||
ref: { kind: 'section', id }, title: id, path: [], childCount: 0,
|
||||
state: { content, verification: 'not_applicable', sourceCount: 0, conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true },
|
||||
visibility: 'full',
|
||||
})
|
||||
it('正文/受限/未加载三分态', () => {
|
||||
const out = assembleReader(
|
||||
[sec('a', 'complete'), sec('b', 'complete'), sec('c', 'empty')],
|
||||
{
|
||||
a: { body: [{ kind: 'text', text: '正文A' }] },
|
||||
b: { body: [{ kind: 'restricted', reason: 'section-duty-or-authorship' }] },
|
||||
},
|
||||
)
|
||||
expect(out[0].blocks).toEqual([{ kind: 'text', text: '正文A' }])
|
||||
expect(out[1].blocks[0].kind).toBe('restricted')
|
||||
expect(out[2].blocks[0].reason).toBe('not_loaded')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
// 文档驾驶舱 I2 纯逻辑层(rollout I2;契约:Dev 仓 docs/superpowers/specs
|
||||
// 2026-10-16-document-visualization-read-model-contract.md @ 37b272ba,
|
||||
// 四项取舍冻结见 plans/…-rollout.md §7)。只放可测判定:筛选切换、统计呈现
|
||||
//(真 0 vs 不可算)、快照一致性与更新提示、上下文键与选择保留。组件只做取数编排与渲染。
|
||||
|
||||
// ---- 契约类型(I1 后端 DTO 的前端精简形态) ----
|
||||
|
||||
export interface CockpitNodeRef { kind: string; id: string; version?: string }
|
||||
|
||||
export interface CockpitMeta {
|
||||
schemaVersion: string
|
||||
projectId: string
|
||||
snapshotRevision: string
|
||||
asOfSeq: number
|
||||
generatedAt: string
|
||||
truncated: boolean
|
||||
truncationReasons?: string[]
|
||||
}
|
||||
|
||||
export interface CockpitNodeState {
|
||||
content: string // complete|partial|empty|inaccessible|unavailable
|
||||
verification: string // verified|mixed|unverified|disputed|not_applicable
|
||||
sourceCount: number
|
||||
conflictCount: number
|
||||
impactCount: number
|
||||
missingChildCount: number
|
||||
structuralGap: boolean
|
||||
}
|
||||
|
||||
export interface CockpitTreeNode {
|
||||
ref: CockpitNodeRef
|
||||
title: string
|
||||
path: string[]
|
||||
parent?: CockpitNodeRef
|
||||
childCount: number
|
||||
state: CockpitNodeState
|
||||
lastChangedAt?: string
|
||||
lastChangedBy?: string
|
||||
visibility: string // full|redacted|metadata_only
|
||||
}
|
||||
|
||||
export interface CockpitStats {
|
||||
documentCount: number
|
||||
completion: { known: number; total: number; state: string }
|
||||
emptyNodeCount: number
|
||||
unverifiedSourceCount: number
|
||||
conflictCount: number
|
||||
pendingChangeCount: number | null
|
||||
updatedAt?: string
|
||||
statReasons: Record<string, string>
|
||||
}
|
||||
|
||||
export interface CockpitChangeSummary {
|
||||
node: CockpitNodeRef
|
||||
title: string
|
||||
changedAt: string
|
||||
actor?: string
|
||||
kind: string
|
||||
summary: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
export interface CockpitOverview {
|
||||
meta: CockpitMeta
|
||||
project: { title: string }
|
||||
stats: CockpitStats
|
||||
documents: CockpitTreeNode[]
|
||||
recentChanges: CockpitChangeSummary[]
|
||||
}
|
||||
|
||||
export interface CockpitTreePage {
|
||||
meta: CockpitMeta
|
||||
items: CockpitTreeNode[]
|
||||
nextCursor?: string
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
// ---- 统计带:点击即切换共享筛选(视图规格 §2;未知筛选由后端 400 拒绝) ----
|
||||
|
||||
export const STAT_FILTERS: { key: string; label: string; filter: string }[] = [
|
||||
{ key: 'empty', label: '空缺节点', filter: 'state:empty' },
|
||||
{ key: 'unverified', label: '未核实来源', filter: 'state:unverified' },
|
||||
{ key: 'conflict', label: '冲突', filter: 'state:conflict' },
|
||||
]
|
||||
|
||||
/** 点击统计卡:同筛选再次点击=清除(只定位,不执行写入)。 */
|
||||
export function toggleFilter(current: string, filter: string): string {
|
||||
return current === filter ? '' : filter
|
||||
}
|
||||
|
||||
/** 统计呈现:null(不可算)显示 — 加原因;真 0 显示 0。两侧都不得互相冒充(契约 §2/§6)。 */
|
||||
export function statDisplay(stats: CockpitStats, key: string): { value: string; hint?: string } {
|
||||
const reasons = stats.statReasons || {}
|
||||
if (key === 'documents') return { value: String(stats.documentCount) }
|
||||
if (key === 'completion') {
|
||||
const c = stats.completion
|
||||
if (c.state === 'unavailable') return { value: '—', hint: reasons['completion'] || '无法计算' }
|
||||
if (c.state === 'partial') return { value: `${c.known}/${c.total}`, hint: '部分计算' }
|
||||
return { value: `${c.known}/${c.total}` }
|
||||
}
|
||||
const map: Record<string, number | null | undefined> = {
|
||||
empty: stats.emptyNodeCount,
|
||||
unverified: stats.unverifiedSourceCount,
|
||||
conflict: stats.conflictCount,
|
||||
pending: stats.pendingChangeCount,
|
||||
}
|
||||
const v = map[key]
|
||||
// 原因表按后端 DTO 字段名(statReasons 键=字段名),展示键需映射。
|
||||
const reasonKey: Record<string, string> = {
|
||||
empty: 'emptyNodeCount', unverified: 'unverifiedSourceCount',
|
||||
conflict: 'conflictCount', pending: 'pendingChangeCount',
|
||||
}
|
||||
if (v === null || v === undefined) return { value: '—', hint: reasons[reasonKey[key] || key] || '暂不可计算' }
|
||||
return { value: String(v) }
|
||||
}
|
||||
|
||||
// ---- 快照一致性(DV-R05 前端侧):统计与树徽标同快照才同屏可信 ----
|
||||
|
||||
export function sameSnapshot(a?: CockpitMeta, b?: CockpitMeta): boolean {
|
||||
if (!a || !b) return false
|
||||
return a.snapshotRevision === b.snapshotRevision && a.asOfSeq === b.asOfSeq
|
||||
}
|
||||
|
||||
/** 更新提示:展示中快照落后于最新已见快照时提示刷新(不抢占滚动,视图规格 §5)。 */
|
||||
export function updateNotice(viewed: CockpitMeta | null, latest: CockpitMeta | null): string | null {
|
||||
if (!viewed || !latest) return null
|
||||
if (viewed.snapshotRevision === latest.snapshotRevision && viewed.asOfSeq === latest.asOfSeq) return null
|
||||
return `内容已更新(快照 ${latest.snapshotRevision})——刷新查看最新状态`
|
||||
}
|
||||
|
||||
/** 上下文键:项目+筛选标识一轮取数上下文;不匹配的旧响应直接丢弃(契约 §8)。 */
|
||||
export function contextKey(projectId: string, filter: string): string {
|
||||
return `${projectId}\u0001${filter}`
|
||||
}
|
||||
|
||||
/** 刷新不重置选择:文档仍在根清单(或选中章节的父文档仍在)则保留,否则清空。 */
|
||||
export function selectionSurvives(
|
||||
selected: CockpitNodeRef | null,
|
||||
rootDocIds: string[],
|
||||
parentDocId?: string,
|
||||
): CockpitNodeRef | null {
|
||||
if (!selected) return null
|
||||
if (selected.kind === 'document') return rootDocIds.includes(selected.id) ? selected : null
|
||||
if (parentDocId && rootDocIds.includes(parentDocId)) return selected
|
||||
return null
|
||||
}
|
||||
|
||||
/** 树行徽标(诚实状态:空/部分/受限/未核实/冲突计数;不虚构成功)。 */
|
||||
export function treeBadges(n: CockpitTreeNode): string[] {
|
||||
const out: string[] = []
|
||||
if (n.state.content === 'empty') out.push('空')
|
||||
else if (n.state.content === 'partial') out.push('部分')
|
||||
else if (n.state.content === 'inaccessible') out.push('受限')
|
||||
if (n.visibility === 'metadata_only') out.push('仅元数据')
|
||||
if (n.state.verification === 'unverified') out.push('未核实')
|
||||
if (n.state.conflictCount > 0) out.push(`冲突 ${n.state.conflictCount}`)
|
||||
return out
|
||||
}
|
||||
|
||||
/** 节点状态一句话总结(全景 §4.2)。 */
|
||||
export function nodeSummaryLine(n: CockpitTreeNode): string {
|
||||
const parts: string[] = []
|
||||
const content = { complete: '内容完整', partial: '内容部分', empty: '内容为空', inaccessible: '内容受限', unavailable: '暂不可读' }[n.state.content] || n.state.content
|
||||
parts.push(content)
|
||||
if (n.state.structuralGap) parts.push('层级止于章节')
|
||||
if (n.state.sourceCount > 0) parts.push(`来源 ${n.state.sourceCount}`)
|
||||
if (n.state.conflictCount > 0) parts.push(`冲突 ${n.state.conflictCount}`)
|
||||
if (n.state.impactCount > 0) parts.push(`影响 ${n.state.impactCount}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
// ---- I3:阅读器与右栏纯逻辑 ----
|
||||
|
||||
export interface CockpitVersionSummary {
|
||||
seq: number
|
||||
version: string
|
||||
actor?: string
|
||||
at: string
|
||||
basisCount: number
|
||||
content?: string
|
||||
redacted?: boolean
|
||||
}
|
||||
|
||||
export interface CockpitContentBlock { kind: string; text?: string; reason?: string }
|
||||
|
||||
export interface CockpitSourceSummary {
|
||||
ref: { kind: string; id: string }
|
||||
label: string
|
||||
capturedAt?: string
|
||||
confidence: string
|
||||
verification: string
|
||||
visibility: string
|
||||
redactedReason?: string
|
||||
}
|
||||
|
||||
export interface CockpitHistorySummary {
|
||||
seq: number
|
||||
at: string
|
||||
op: string
|
||||
actor?: string
|
||||
summary: string
|
||||
operationId?: string
|
||||
}
|
||||
|
||||
export interface CockpitImpactSummary {
|
||||
ref: { kind: string; id: string }
|
||||
edgeType: string
|
||||
edgeState: string
|
||||
direction: string
|
||||
layer: string
|
||||
}
|
||||
|
||||
export interface CockpitAssertion { node: CockpitNodeRef; claim: string; visibility: string; redactedReason?: string }
|
||||
|
||||
export interface CockpitConflictSummary {
|
||||
id: string
|
||||
node: CockpitNodeRef
|
||||
state: string
|
||||
left: CockpitAssertion
|
||||
right: CockpitAssertion
|
||||
detectedAt: string
|
||||
resolutionRef?: string
|
||||
}
|
||||
|
||||
export interface CockpitPage<T> { meta?: CockpitMeta; items: T[]; nextCursor?: string; hasMore: boolean; truncated?: boolean }
|
||||
|
||||
export const PANEL_TABS = [
|
||||
{ id: 'sources', label: '内容来源' },
|
||||
{ id: 'history', label: '修改历史' },
|
||||
{ id: 'impacts', label: '影响对象' },
|
||||
{ id: 'conflicts', label: '冲突与风险' },
|
||||
] as const
|
||||
export type PanelTabId = (typeof PANEL_TABS)[number]['id']
|
||||
|
||||
export type ViewMode = 'panorama' | 'reader'
|
||||
export type Density = 'compact' | 'full'
|
||||
|
||||
export interface CockpitPrefs {
|
||||
lastView: ViewMode
|
||||
density: Density
|
||||
rightPanelTab: PanelTabId
|
||||
}
|
||||
|
||||
/** 偏好按 用户+项目 隔离(设计 §7.3);仅本地存储,不构成授权。 */
|
||||
export function prefsKey(actor: string, projectId: string): string {
|
||||
return `cockpit-prefs:${actor}:${projectId}`
|
||||
}
|
||||
|
||||
/** 解析偏好:枚举外的脏值逐项回落默认(panorama+compact+sources)。 */
|
||||
export function mergePrefs(raw: unknown): CockpitPrefs {
|
||||
const base: CockpitPrefs = { lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' }
|
||||
if (!raw || typeof raw !== 'object') return base
|
||||
const r = raw as Record<string, unknown>
|
||||
const out = { ...base }
|
||||
if (r.lastView === 'panorama' || r.lastView === 'reader') out.lastView = r.lastView
|
||||
if (r.density === 'compact' || r.density === 'full') out.density = r.density
|
||||
if (PANEL_TABS.some(t => t.id === r.rightPanelTab)) out.rightPanelTab = r.rightPanelTab as PanelTabId
|
||||
return out
|
||||
}
|
||||
|
||||
export function loadPrefs(actor: string, projectId: string): CockpitPrefs {
|
||||
try {
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(prefsKey(actor, projectId)) : null
|
||||
return mergePrefs(raw ? JSON.parse(raw) : null)
|
||||
} catch {
|
||||
return mergePrefs(null)
|
||||
}
|
||||
}
|
||||
|
||||
export function savePrefs(actor: string, projectId: string, prefs: CockpitPrefs): void {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(prefsKey(actor, projectId), JSON.stringify(prefs))
|
||||
} catch { /* 存储不可用时偏好静默降级为会话内 */ }
|
||||
}
|
||||
|
||||
/** 版本摘要:当前版本 + 前一版本(视图规格 §5)。versions 为最新在前;受限版本无 content。 */
|
||||
export function versionSummary(versions: CockpitVersionSummary[] | undefined): {
|
||||
current?: CockpitVersionSummary
|
||||
previous?: CockpitVersionSummary
|
||||
redacted: boolean
|
||||
} {
|
||||
if (!versions || versions.length === 0) return { redacted: false }
|
||||
const current = versions[0]
|
||||
const previous = versions.length > 1 ? versions[1] : undefined
|
||||
return { current, previous, redacted: !!current.redacted }
|
||||
}
|
||||
|
||||
/** 历史面板合并:只接受严格更新(seq 更大且未见)的条目;过期与重复都计入 dropped(DV-R05/契约 §8)。 */
|
||||
export function mergeHistoryStrict(existing: CockpitHistorySummary[], incoming: CockpitHistorySummary[]): {
|
||||
items: CockpitHistorySummary[]
|
||||
dropped: number
|
||||
} {
|
||||
const seen = new Set(existing.map(e => e.seq))
|
||||
const maxSeq = existing.length > 0 ? Math.max(...seen) : -1
|
||||
const items = [...existing]
|
||||
let dropped = 0
|
||||
for (const e of incoming) {
|
||||
if (e.seq <= maxSeq || seen.has(e.seq)) {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
seen.add(e.seq)
|
||||
items.push(e)
|
||||
}
|
||||
return { items, dropped }
|
||||
}
|
||||
|
||||
/** 阅读器装配:把文档的章节详情按树顺序组装为连续正文;受限章节给占位说明而非空白。 */
|
||||
export function assembleReader(
|
||||
sections: CockpitTreeNode[],
|
||||
details: Record<string, { body?: CockpitContentBlock[]; versions?: CockpitVersionSummary[] } | undefined>,
|
||||
): { ref: CockpitNodeRef; title: string; contentState: string; blocks: CockpitContentBlock[]; versions?: CockpitVersionSummary[] }[] {
|
||||
return sections.map(sec => {
|
||||
const d = details[sec.ref.id]
|
||||
const readable = !!d?.body && d.body.length > 0 && d.body[0].kind === 'text'
|
||||
const restricted = !!d?.body && d.body.length > 0 && d.body[0].kind === 'restricted'
|
||||
const blocks: CockpitContentBlock[] = readable
|
||||
? (d!.body as CockpitContentBlock[])
|
||||
: restricted
|
||||
? [{ kind: 'restricted', reason: d?.body?.[0]?.reason || 'section-duty-or-authorship' }]
|
||||
: [{ kind: 'restricted', reason: 'not_loaded' }]
|
||||
return { ref: sec.ref, title: sec.title, contentState: sec.state.content, blocks, versions: d?.versions }
|
||||
})
|
||||
}
|
||||
|
||||
/** 行级差异(阅读器 Collapsible 的完整对比):按行对齐的朴素 LCS 免实现——
|
||||
* 以行为单位做集合比对标记 +/=/-,足够呈现「完整差异」而不冒充精确补丁。 */
|
||||
export function diffLines(current: string, previous: string): { flag: '+' | '-' | '='; text: string }[] {
|
||||
const cur = current.split('\n')
|
||||
const prev = new Set(previous.split('\n'))
|
||||
const curSet = new Set(cur)
|
||||
const out: { flag: '+' | '-' | '='; text: string }[] = []
|
||||
for (const line of cur) out.push({ flag: prev.has(line) ? '=' : '+', text: line })
|
||||
for (const line of previous.split('\n')) if (!curSet.has(line)) out.push({ flag: '-', text: line })
|
||||
return out
|
||||
}
|
||||
|
||||
/** 历史条目的自然语言时间线文本(右栏 §6)。 */
|
||||
export function historyLine(h: CockpitHistorySummary): string {
|
||||
const who = h.actor ? `${h.actor} ` : ''
|
||||
return `${h.summary}(${who}${new Date(h.at).toLocaleString('zh-CN')})`
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// 浏览器端随机初始密码(管理员未显式给定密码时):
|
||||
// crypto.getRandomValues 拒绝易混淆字符(0/O/1/l/I),保证可抄写。
|
||||
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'
|
||||
|
||||
export function randomPassword(length = 12): string {
|
||||
const buf = new Uint32Array(length)
|
||||
crypto.getRandomValues(buf)
|
||||
let out = ''
|
||||
for (let i = 0; i < length; i++) out += ALPHABET[buf[i] % ALPHABET.length]
|
||||
return out
|
||||
}
|
||||
+6
-3
@@ -11,7 +11,7 @@ import { canFlushOutbox, deriveShellPhase } from './runstate'
|
||||
import { flashElement } from './scrollsync'
|
||||
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
|
||||
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
|
||||
import { rdmAuditReady, rdmExportReady } from './workspaces'
|
||||
import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces'
|
||||
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement'
|
||||
|
||||
type FontSizeChoice = 13 | 14 | 16 | 18
|
||||
@@ -124,6 +124,7 @@ interface AppState {
|
||||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||||
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
|
||||
rdmAuditAvailable: boolean
|
||||
rdmCockpitAvailable: boolean
|
||||
/** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */
|
||||
rdmExportAvailable: boolean
|
||||
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
|
||||
@@ -199,6 +200,7 @@ let state: AppState = {
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
rdmAuditAvailable: false,
|
||||
rdmCockpitAvailable: false,
|
||||
rdmExportAvailable: false,
|
||||
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
|
||||
adminSections: [],
|
||||
@@ -442,7 +444,7 @@ export const store = {
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
|
||||
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
|
||||
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
|
||||
rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
|
||||
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
|
||||
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
|
||||
@@ -1197,6 +1199,7 @@ export const store = {
|
||||
if (epoch !== identityEpoch) return
|
||||
set({
|
||||
rdmAuditAvailable: rdmAuditReady(cmds),
|
||||
rdmCockpitAvailable: cockpitReady(cmds),
|
||||
rdmExportAvailable: rdmExportReady(cmds),
|
||||
rdmRolesAvailable: rdmRolesReady(cmds),
|
||||
rdmMembersAvailable: rdmMembersReady(cmds),
|
||||
@@ -1205,7 +1208,7 @@ export const store = {
|
||||
})
|
||||
} catch {
|
||||
if (epoch !== identityEpoch) return
|
||||
set({ rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false })
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -648,3 +648,76 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.duty-matrix .duty-sticky { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
|
||||
.duty-matrix thead th { position: sticky; top: 0; background: var(--bg-panel); z-index: 2; }
|
||||
.duty-cell select { font-size: var(--fs-ui-sm); color: var(--text); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
||||
|
||||
/* ---- 文档驾驶舱(I2;令牌驱动,词汇沿用 rdm-audit 卡片体系) ---- */
|
||||
.cockpit { display: flex; flex-direction: column; min-height: 320px; }
|
||||
.cockpit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
|
||||
.cockpit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
|
||||
.cockpit-filter-chip, .cockpit-refresh { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); color: var(--text); cursor: pointer; }
|
||||
.cockpit-snap { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
|
||||
.cockpit-banner { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 6px 10px; margin-bottom: 8px; font-size: var(--fs-ui-sm); }
|
||||
.cockpit-banner-err { border-color: var(--border); }
|
||||
.cockpit-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; margin-bottom: 12px; }
|
||||
.cockpit-stat { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; text-align: center; font: inherit; }
|
||||
button.cockpit-stat { cursor: pointer; }
|
||||
.cockpit-stat-active { border-color: var(--text); }
|
||||
.cockpit-stat-unavail { color: var(--text-muted, var(--text)); }
|
||||
.cockpit-stat-num { display: block; font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
|
||||
.cockpit-stat-label { display: block; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
|
||||
.cockpit-columns { display: grid; grid-template-columns: minmax(220px, 300px) 1fr; gap: 14px; }
|
||||
.cockpit-tree { min-height: 0; overflow-y: auto; }
|
||||
.cockpit-tree-row { display: flex; align-items: center; gap: 6px; padding: 3px 4px; border-radius: var(--radius-sm); }
|
||||
.cockpit-tree-row-active { background: var(--bg-panel); }
|
||||
.cockpit-tree-caret { border: none; background: none; cursor: pointer; padding: 0 2px; font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.cockpit-tree-title { border: none; background: none; padding: 0; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; }
|
||||
.cockpit-badge { font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 4px; color: var(--text-muted, var(--text)); }
|
||||
.cockpit-tree-time { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
|
||||
.cockpit-tree-empty, .cockpit-tree-more { border: none; background: none; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 2px 4px; }
|
||||
button.cockpit-tree-more { cursor: pointer; text-decoration: underline; }
|
||||
.cockpit-truncated { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 4px 0; }
|
||||
.cockpit-center { min-height: 0; overflow-y: auto; }
|
||||
.cockpit-doclist { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.cockpit-docrow { display: flex; align-items: baseline; gap: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 8px 10px; cursor: pointer; font: inherit; text-align: left; }
|
||||
.cockpit-docrow-title { font-size: var(--fs-ui-base, var(--fs-ui-sm)); color: var(--text); }
|
||||
.cockpit-docrow-meta { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
|
||||
.cockpit-changes { display: flex; flex-direction: column; gap: 4px; }
|
||||
.cockpit-changerow { display: flex; justify-content: space-between; gap: 10px; font-size: var(--fs-ui-sm); }
|
||||
.cockpit-changesum { color: var(--text); }
|
||||
.cockpit-changetime { color: var(--text-muted, var(--text)); }
|
||||
.cockpit-nodehead { display: flex; align-items: baseline; gap: 10px; padding-bottom: 8px; }
|
||||
.cockpit-back { border: none; background: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); padding: 0; text-decoration: underline; }
|
||||
.cockpit-nodetitle { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
|
||||
.cockpit-nodesum { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
|
||||
.cockpit-childtable { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
|
||||
.cockpit-childtable th, .cockpit-childtable td { border-bottom: 1px solid var(--border); padding: 4px 6px; text-align: left; color: var(--text); }
|
||||
@media (max-width: 760px) { .cockpit-columns { grid-template-columns: 1fr; } .cockpit-tree { max-height: 40vh; } }
|
||||
|
||||
/* ---- 驾驶舱 I3:阅读器与右栏(令牌驱动) ---- */
|
||||
.cockpit-columns-3 { grid-template-columns: minmax(200px, 260px) 1fr minmax(240px, 300px); }
|
||||
.cockpit-right { min-height: 0; overflow-y: auto; border-left: 1px solid var(--border); padding-left: 12px; }
|
||||
.cockpit-viewtoggle { display: flex; gap: 0; }
|
||||
.cockpit-viewbtn { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); background: var(--bg-panel); color: var(--text); cursor: pointer; }
|
||||
.cockpit-viewbtn-active { background: var(--text); color: var(--bg-panel); }
|
||||
.cockpit-reader-para { white-space: pre-wrap; font-size: var(--fs-ui-base, var(--fs-ui-sm)); line-height: 1.6; color: var(--text); margin: 6px 0; }
|
||||
.cockpit-density-full .cockpit-reader-para { line-height: 1.9; margin: 10px 0; }
|
||||
.cockpit-reader-section { border-top: 1px solid var(--border); padding: 8px 0; }
|
||||
.cockpit-reader-sechead { display: flex; align-items: baseline; gap: 8px; }
|
||||
.cockpit-reader-sectitle { font-weight: 600; color: var(--text); font-size: var(--fs-ui-base, var(--fs-ui-sm)); }
|
||||
.cockpit-versions { font-size: var(--fs-ui-sm); }
|
||||
.cockpit-density-full .cockpit-versions { line-height: 1.7; }
|
||||
.cockpit-versions-toggle { font-size: var(--fs-ui-sm); color: var(--text); text-decoration: underline; cursor: pointer; }
|
||||
.cockpit-versions-head { color: var(--text-muted, var(--text)); padding: 4px 0; }
|
||||
.cockpit-diff { white-space: pre-wrap; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--fs-ui-sm); }
|
||||
.cockpit-diff-add { color: var(--text); }
|
||||
.cockpit-diff-del { color: var(--text-muted, var(--text)); text-decoration: line-through; }
|
||||
.cockpit-diff-same { color: var(--text-muted, var(--text)); }
|
||||
.cockpit-panel-tabs { display: flex; gap: 4px; padding-bottom: 8px; }
|
||||
.cockpit-panel-tab { border: 1px solid var(--border); background: none; padding: 3px 8px; font-size: var(--fs-ui-sm); color: var(--text); cursor: pointer; border-radius: var(--radius-sm); }
|
||||
.cockpit-panel-tab-active { background: var(--bg-panel); border-color: var(--text); }
|
||||
.cockpit-panel-body { display: flex; flex-direction: column; gap: 6px; }
|
||||
.cockpit-panel-count { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
|
||||
.cockpit-panel-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border); padding: 4px 0; }
|
||||
.cockpit-panel-kind { color: var(--text-muted, var(--text)); }
|
||||
.cockpit-panel-label { color: var(--text); }
|
||||
.cockpit-panel-conflict { flex-direction: column; }
|
||||
.cockpit-conflict-side { color: var(--text); }
|
||||
|
||||
@@ -54,9 +54,27 @@ export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null
|
||||
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
|
||||
}
|
||||
|
||||
/** 文档驾驶舱消费的全部 rdm.cockpit.* 命令(I1 只读面;rollout I2)。 */
|
||||
export const COCKPIT_COMMANDS = [
|
||||
'rdm.cockpit.overview',
|
||||
'rdm.cockpit.tree',
|
||||
'rdm.cockpit.node',
|
||||
'rdm.cockpit.node.sources',
|
||||
'rdm.cockpit.node.history',
|
||||
'rdm.cockpit.node.impacts',
|
||||
'rdm.cockpit.node.conflicts',
|
||||
] as const
|
||||
|
||||
/** 七命令齐备且 enabled 才展示驾驶舱入口(与 rdmAuditReady 同源语义)。 */
|
||||
export function cockpitReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
|
||||
if (!Array.isArray(cmds)) return false
|
||||
return COCKPIT_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: '🔍' },
|
||||
{ key: 'rdm-cockpit', title: '文档驾驶舱', icon: '📋' },
|
||||
]
|
||||
|
||||
function shortcutNum(raw?: string): number | null {
|
||||
@@ -70,6 +88,8 @@ function shortcutNum(raw?: string): number | null {
|
||||
interface BuildWorkspaceOptions {
|
||||
/** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */
|
||||
rdmAudit?: boolean
|
||||
/** rdm.cockpit.* 命令面探测通过(store.rdmCockpitAvailable)。默认 false:能力未知/缺失不展示驾驶舱入口。 */
|
||||
rdmCockpit?: boolean
|
||||
}
|
||||
|
||||
export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] {
|
||||
@@ -101,6 +121,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWork
|
||||
}
|
||||
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
|
||||
if (opts.rdmAudit === true) ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
|
||||
if (opts.rdmCockpit === true) ranked.push({ rank: takeFree(3), entry: BUILTINS[2] })
|
||||
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
|
||||
return ranked
|
||||
.filter(item => item.rank <= 9)
|
||||
|
||||
+15
-8
@@ -1,7 +1,7 @@
|
||||
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
|
||||
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
|
||||
import { humanChangePasswordError, humanLoginError, isPasswordChangeMode, requiresCurrentPassword } from '../src/components/LoginPage'
|
||||
|
||||
describe('humanLoginError(登录)', () => {
|
||||
it('401 统一为用户名或密码不正确', () => {
|
||||
@@ -31,14 +31,21 @@ describe('humanChangePasswordError(改密门)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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('尝试次数过多,请稍后再试')
|
||||
describe('requiresCurrentPassword(强制改密门模式)', () => {
|
||||
it('current_required(管理员指定/生成的初始密码首登)必须要当前密码,grant_setup 免当前密码', () => {
|
||||
expect(requiresCurrentPassword('current_required')).toBe(true)
|
||||
expect(requiresCurrentPassword('grant_setup')).toBe(false)
|
||||
})
|
||||
it('未知错误剥状态码前缀透传', () => {
|
||||
expect(humanRedeemError('400: bad')).toBe('bad')
|
||||
it('one_time_password(一次性登录密码受限会话)只需新密码与确认,免当前密码', () => {
|
||||
expect(requiresCurrentPassword('one_time_password')).toBe(false)
|
||||
})
|
||||
it('设密门只接受服务端声明的三种模式,其余一律拒绝', () => {
|
||||
expect(isPasswordChangeMode('grant_setup')).toBe(true)
|
||||
expect(isPasswordChangeMode('current_required')).toBe(true)
|
||||
expect(isPasswordChangeMode('one_time_password')).toBe(true)
|
||||
expect(isPasswordChangeMode(undefined)).toBe(false)
|
||||
expect(isPasswordChangeMode('')).toBe(false)
|
||||
expect(isPasswordChangeMode('temporary_password')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// 忘记密码唯一处置守护:管理员生成一次性登录密码 → 用户登录 → 强制改密。
|
||||
// 恢复提案 UI/API 已整体移除;登录页只保留用户名 + 密码一种凭据入口。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join, relative, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const SRC = join(dirname(fileURLToPath(import.meta.url)), '..', 'src')
|
||||
const read = (...parts: string[]) => readFileSync(join(SRC, ...parts), 'utf8')
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
if (statSync(p).isDirectory()) walk(p, out)
|
||||
else if (/\.(ts|tsx)$/.test(p)) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('恢复提案已移除', () => {
|
||||
it('src 不再调用恢复提案或登录 Key 兑换端点', () => {
|
||||
const hits = walk(SRC).filter(p => /recovery-proposals|RecoveryProposal|login-key\/redeem|redeemLoginKey/.test(readFileSync(p, 'utf8')))
|
||||
expect(hits.map(p => relative(SRC, p))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('一次性登录密码 API 契约', () => {
|
||||
const apiSrc = read('api.ts')
|
||||
it('POST /api/admin/users/{owner}/one-time-password 并消费 oneTimePassword 字段', () => {
|
||||
const line = apiSrc.split('\n').find(l => l.includes('adminIssueOneTimePassword:')) ?? ''
|
||||
expect(line).toContain('/api/admin/users/${encodeURIComponent(owner)}/one-time-password`')
|
||||
expect(line).toContain("method: 'POST'")
|
||||
expect(line).toContain('oneTimePassword: string')
|
||||
})
|
||||
it('旧的临时密码重置端点与字段已移除', () => {
|
||||
expect(apiSrc).not.toContain('adminResetUserPassword')
|
||||
expect(apiSrc).not.toContain('temporaryPassword')
|
||||
expect(apiSrc).not.toMatch(/\/api\/admin\/users\/\$\{[^}]+\}\/password`/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('管理员用户管理:唯一忘记密码动作', () => {
|
||||
const panel = read('components', 'adminSections.tsx')
|
||||
it('调用一次性登录密码端点并展示 oneTimePassword', () => {
|
||||
expect(panel.match(/api\.adminIssueOneTimePassword\(/g)).toHaveLength(1)
|
||||
expect(panel).toContain('out.oneTimePassword')
|
||||
expect(panel).not.toContain('temporaryPassword')
|
||||
})
|
||||
it('每行只有「生成一次性登录密码」一个按钮,无恢复提案或重置临时密码入口', () => {
|
||||
expect(panel.match(/>生成一次性登录密码</g)).toHaveLength(1)
|
||||
expect(panel).not.toContain('恢复提案')
|
||||
expect(panel).not.toContain('重置临时密码')
|
||||
})
|
||||
it('一次性密码文案标明使用后强制改密,明文只在一次性展示框出现', () => {
|
||||
expect(panel).toContain('使用后必须强制改密')
|
||||
expect(panel.match(/aria-label="一次性密码"/g)).toHaveLength(1)
|
||||
expect(panel).not.toMatch(/localStorage|sessionStorage|clipboard/)
|
||||
})
|
||||
it('文案标明生成不使原密码失效;原密码登录则作废未用的一次性密码且不强制改密', () => {
|
||||
expect(panel).not.toMatch(/原密码(已|立即|随生成即)失效/)
|
||||
expect(panel).toContain('原密码仍可正常登录')
|
||||
expect(panel).toContain('未使用的一次性登录密码随即作废')
|
||||
expect(panel).toContain('不会被要求改密')
|
||||
})
|
||||
})
|
||||
|
||||
describe('登录页:仅用户名 + 密码', () => {
|
||||
const login = read('components', 'LoginPage.tsx')
|
||||
const loginPage = login.slice(login.indexOf('export function LoginPage'), login.indexOf('export function requiresCurrentPassword'))
|
||||
it('只有一个用户名输入与一个密码输入,无第二凭据入口', () => {
|
||||
expect(loginPage.match(/<input /g)).toHaveLength(2)
|
||||
expect(loginPage.match(/type="password"/g)).toHaveLength(1)
|
||||
expect(loginPage).toContain('api.login(')
|
||||
expect(loginPage).not.toMatch(/role="tab"|login-key|alk_|redeem/i)
|
||||
})
|
||||
it('忘记密码指引指向一次性登录密码且标明须改密', () => {
|
||||
expect(loginPage).toContain('生成一次性登录密码,使用后须立即改密')
|
||||
})
|
||||
})
|
||||
|
||||
describe('强制改密门:one_time_password 模式', () => {
|
||||
const login = read('components', 'LoginPage.tsx')
|
||||
const gate = login.slice(login.indexOf('export function ChangePasswordGate'), login.indexOf('export function humanLoginError'))
|
||||
it('门放行判定经 isPasswordChangeMode,接受 one_time_password', () => {
|
||||
expect(gate).toContain('isPasswordChangeMode(me.passwordChangeMode)')
|
||||
expect(gate).toContain("mode === 'one_time_password'")
|
||||
})
|
||||
it('当前密码输入只在 current_required 下渲染,且提示不再指向一次性登录密码', () => {
|
||||
const currentBlock = gate.slice(gate.indexOf("{mode === 'current_required' && <>"), gate.indexOf('</>}'))
|
||||
expect(currentBlock).toContain('id="forced-current"')
|
||||
expect(currentBlock).not.toContain('一次性登录密码')
|
||||
expect(gate.match(/id="forced-current"/g)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('我的账号:无恢复提案确认', () => {
|
||||
const settings = read('components', 'MyAccountSettings.tsx')
|
||||
it('只给出联系管理员生成一次性登录密码的指引,没有可操作的恢复按钮', () => {
|
||||
expect(settings).toContain('请联系管理员生成一次性登录密码')
|
||||
expect(settings).toContain('强制改密')
|
||||
expect(settings).not.toMatch(/由本人确认|重新生成设密能力|兑换/)
|
||||
})
|
||||
it('不声称生成一次性登录密码会使原密码失效', () => {
|
||||
expect(settings).not.toMatch(/原密码(已|立即|随生成即)失效/)
|
||||
expect(settings).toContain('原密码仍可正常登录')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user