diff --git a/e2e/admin-user-create.playwright.ts b/e2e/admin-user-create.playwright.ts new file mode 100644 index 0000000..e8df47d --- /dev/null +++ b/e2e/admin-user-create.playwright.ts @@ -0,0 +1,83 @@ +// 防再发守卫:管理员建号路径(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')).toHaveText( + `已创建 ${newOwner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`, + ) + + // 契约断言三:用户表出现该账号,状态为未设密码(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')).toContainText('user initialize-account') +}) diff --git a/e2e/rdm-cockpit.playwright.ts b/e2e/rdm-cockpit.playwright.ts new file mode 100644 index 0000000..ea7adec --- /dev/null +++ b/e2e/rdm-cockpit.playwright.ts @@ -0,0 +1,171 @@ +// 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() +} + +async function loginOwner(page: Page) { + await login(page, OWNER, PASSWORD) + await expect(page.locator('.tsb-tool', { hasText: '文档驾驶舱' })).toBeVisible({ timeout: 15_000 }) + // 登录后身份纪元与能力探针仍可能各跳一次;应用在身份变更时会关闭已开 + // 工作区——等落定再点入口,避免 overlay 被身份刷新关掉(竞态实测)。 + await page.waitForLoadState('networkidle').catch(() => {}) + await page.waitForTimeout(1_500) + } + +async function openCockpit(page: Page) { + await page.locator('.tsb-tool', { hasText: '文档驾驶舱' }).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) + await page.locator('.cockpit-tree-caret').first().click() + await page.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click() + const snapBefore = await page.locator('.cockpit-snap').innerText() + // 通过应用自身 API 采纳一个新提案(同源会话;与浏览器渲染解耦)。 + await page.request.post('/api/commands/rdm.draft.propose', { + data: { params: { projectId: (await page.locator('.cockpit-project option:checked').textContent()) || '', sectionId: '', content: '一致性探针内容——不应出现' }, meta: {} }, + failOnStatusCode: false, + }) + // 上面缺 sectionId 必然 400:真正的驱动是 e2e.sh 早已留好的第二个提案 + // (种子阶段 propose 未采纳的「一致性探针」),这里经面板刷新读取。 + 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) + }) +}) diff --git a/knip-baseline.json b/knip-baseline.json index 317deae..36f7f4b 100644 --- a/knip-baseline.json +++ b/knip-baseline.json @@ -1,3 +1,3 @@ { - "exports": 43 + "exports": 51 } diff --git a/src/api.ts b/src/api.ts index 59f0446..5a67fb2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -15,6 +15,27 @@ 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 免当前密码, + * current_required 必须校验当前密码。 */ +export type PasswordChangeMode = 'grant_setup' | 'current_required' + +/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */ +export interface RecoveryProposal { + id: string; userId: string; owner: string + status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired' + version: number; generation: number; createdBy: string + createdAt: string; expiresAt: string +} + +/** 账号资料与档案视图(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 +79,7 @@ export const api = { projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json(r)), /** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */ surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json(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()}`) @@ -83,12 +104,24 @@ 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)), + 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)), + // 建号只创建待初始化账号(无密码、无权限位);初始凭证由服务器本地 CLI 发放激活 Key。 + createAdminUser: (owner: string, name: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name } }) }).then(r => json<{ owner: string; initializationState: string }>(r)), + // ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除; + // list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)---- 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)), + // ---- 管理员:恢复提案(属主确认制,替换已 410 的直接签发 Key 入口)---- + createAdminRecoveryProposal: (userId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, idempotencyKey }) }).then(r => json(r)), + listAdminRecoveryProposals: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []), + adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json(r)), + // ---- 本人:资料与恢复提案确认(须完整密码会话)---- + meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json(r)), + patchMeProfile: (expectedProfileVersion: number, patch: Partial) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json(r)), + listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []), + getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json(r)), + confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)), + rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)), adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json(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)), diff --git a/src/components/CockpitReader.tsx b/src/components/CockpitReader.tsx new file mode 100644 index 0000000..6e94f55 --- /dev/null +++ b/src/components/CockpitReader.tsx @@ -0,0 +1,254 @@ +// 文档驾驶舱 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(null) + const [err, setErr] = useState('') + const gen = useRef(0) + useEffect(() => { + const g = ++gen.current + setDetail(null); setErr('') + if (!sel) return + api.rdmCommand('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id }) + .then(d => { if (g === gen.current) 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
版本受限(仅元数据可见)
+ } + const prev = previous && previous.content !== undefined ? previous : undefined + return ( + 版本 {current.version}{previous ? `(前一版 ${previous.version})` : '(首版)'}与完整差异} + > +
+ {prev ? ( + <> +
当前 {current.version} · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}
+
+              {diffLines(current.content || '', prev.content || '').map((l, i) => (
+                
{l.flag} {l.text}
+ ))} +
+
前一版 {prev.version} · {prev.actor || '未知'} · {new Date(prev.at).toLocaleString('zh-CN')}
+ + ) : ( +
首版 · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}
+ )} +
+
+ ) +} + +/** 阅读器中栏:选中章节=该节连续正文+版本块;选中文档=全章节连续装配(每节详情按需拉取)。 */ +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>({}) + 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('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(null) + useEffect(() => { anchorRef.current?.scrollIntoView({ block: 'start' }) }, [selection.ref.id]) + if (err) return 阅读器加载失败:{err} + return ( +
+
+ + {detail?.node?.title || (isSection ? '章节' : '文档')} +
+ {isSection && detail && ( + <> + {renderBody(detail.body, detail.meta)} + + + )} + {!isSection && ( + <> + {sections.length === 0 &&
该文档暂无章节
} + {assembleReader(sections, sectionDetails).map(sec => ( +
+
+ {sec.title} + {sec.blocks[0]?.kind === 'restricted' + ? 内容受限({sec.blocks[0].reason || '权限'}) + : sec.contentState === 'empty' && 空} +
+ {sec.blocks.map((b, i) => b.kind === 'text' + ?

{b.text}

+ :

内容受限——标题与状态可见,正文不展示

)} + +
+ ))} + + )} +
+ ) +} + +function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta) { + if (!body || body.length === 0) return
暂无正文
+ if (body[0].kind === 'restricted') { + return
内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示
+ } + return ( + <> + {body.map((b, i) =>

{b.text}

)} + {meta.truncated &&
详情已截断:{meta.truncationReasons?.join(';')}
} + + ) +} + +/** 右栏:依据/历史/影响/冲突四标签(同一选中节点;刷新原位,不抢占滚动)。 */ +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([]) + const [historyMore, setHistoryMore] = useState(false) + const [historyCursor, setHistoryCursor] = useState(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('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 ( +
+
+ {PANEL_TABS.map(t => ( + + ))} +
+ {err &&
依据面板加载失败:{err}
} + {!err && !detail &&
加载依据…
} + {detail && tab === 'sources' && ( +
+
来源 {node?.state.sourceCount ?? detail.sources.items.length} 条
+ {detail.sources.items.length === 0 &&
无来源——章节正文未挂依据切片
} + {detail.sources.items.map((s: any) => ( +
+ {s.ref.kind} + {s.label} + {s.confidence && 置信度 {s.confidence}} + {s.redactedReason + ? 受限({s.redactedReason}) + : s.verification && s.verification !== 'not_applicable' && {s.verification}} +
+ ))} +
+ )} + {detail && tab === 'history' && ( +
+
已提交事件(最新在前)
+ {historyItems.length === 0 &&
暂无历史
} + {historyItems.map(h =>
{historyLine(h)}
)} + {historyMore && } +
+ )} + {detail && tab === 'impacts' && ( +
+
影响 {node?.state.impactCount ?? detail.impacts.items.length} 项(一跳)
+ {detail.impacts.items.length === 0 &&
无一跳影响对象
} + {detail.impacts.items.map((r: any, i: number) => ( +
+ {r.direction === 'incoming' ? '←' : '→'} + {r.ref.kind}:{r.ref.id} + {r.edgeType} + {r.edgeState} +
+ ))} +
+ )} + {detail && tab === 'conflicts' && ( +
+
冲突 {node?.state.conflictCount ?? 0} 项
+ {detail.conflicts.items.length === 0 &&
无开放冲突
} + {detail.conflicts.items.map(c => ( +
+ {c.state === 'open' ? '开放' : c.state} +
甲方:{c.left.visibility === 'full' ? c.left.claim : `受限(${c.left.redactedReason || '权限'})`}
+
乙方:{c.right.visibility === 'full' ? c.right.claim : `受限(${c.right.redactedReason || '权限'})`}
+
{new Date(c.detectedAt).toLocaleString('zh-CN')}
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/components/DocumentCockpit.tsx b/src/components/DocumentCockpit.tsx new file mode 100644 index 0000000..8f33889 --- /dev/null +++ b/src/components/DocumentCockpit.tsx @@ -0,0 +1,356 @@ +// 文档驾驶舱 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 } from './CockpitReader' + +interface ProjectView { id: string; name: string; canAudit?: boolean } + +function CockpitErrorState({ raw }: { raw: string }) { + const c = classifyRdmError(raw) + return {c.message} +} + +interface Selection { ref: CockpitNodeRef; parentDocId?: string } + +export function DocumentCockpit() { + const identity = useStore(() => store.getIdentityEpoch()) + const [projects, setProjects] = useState(null) + const [pid, setPid] = useState('') + const [filter, setFilter] = useState('') + const [overview, setOverview] = useState(null) + const [treePages, setTreePages] = useState>({}) + const [selected, setSelected] = useState(null) + const [error, setError] = useState('') + const [loadErr, setLoadErr] = useState('') + const [viewMode, setViewMode] = useState('panorama') + const [density, setDensity] = useState('compact') + const [panelTab, setPanelTab] = useState('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('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) + }) + .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('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 + if (!projects) return
加载中…
+ if (projects.length === 0) return 暂无可见研发项目——项目成员身份由项目负责人授予 + + 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 ( +
+
+ + {filter && } + {overview ? `快照 ${overview.meta.snapshotRevision}` : '加载中…'} +
+ + + +
+ +
+ {splitNotice && ( +
统计与树快照不一致——请刷新以对齐最新快照
+ )} + {loadErr &&
概览读取失败:{loadErr}(保留上一快照,可重试)
} + {overview && ( +
+
+ {statDisplay(overview.stats, 'completion').value} + 完成度{statDisplay(overview.stats, 'completion').hint ? `(${statDisplay(overview.stats, 'completion').hint})` : ''} +
+
{statDisplay(overview.stats, 'documents').value}文档
+ {STAT_FILTERS.map(s => { + const d = statDisplay(overview.stats, s.key) + const active = filter === s.filter + return ( + + ) + })} +
—待确认(未纳入投影)
+
{overview.stats.updatedAt ? new Date(overview.stats.updatedAt).toLocaleString('zh-CN') : '—'}最近更新
+
+ )} +
+
+ 结构树 + {!overview &&
加载结构…
} + {overview && docIds.length === 0 &&
暂无受控文档
} + {overview && overview.meta.truncated && ( +
文档清单已截断({overview.meta.truncationReasons?.join(';') || '超出上限'})
+ )} + {overview && overview.documents.map(d => ( + (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) }} + /> + ))} +
+
+ {!overview &&
加载概览…
} + {overview && !selected && ( + + )} + {overview && selected && viewMode === 'panorama' && ( + setSelected(null)} + onOpenReader={() => applyPrefs({ lastView: 'reader' })} /> + )} + {overview && selected && viewMode === 'reader' && ( + applyPrefs({ lastView: 'panorama' })} /> + )} +
+ {overview && selected && ( +
+ applyPrefs({ rightPanelTab: t })} density={density} /> +
+ )} +
+
+ ) +} + +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 ( +
+
+ {node.ref.kind === 'document' && ( + + )} + + {badges.map(b => {b})} + {node.lastChangedAt && {new Date(node.lastChangedAt).toLocaleDateString('zh-CN')}} +
+ {expanded && !childPage && ( + + )} + {expanded && childPage && ( +
+ {childPage.items.length === 0 &&
无章节
} + {childPage.items.map(sec => ( + + ))} + {childPage.hasMore && childPage.items.length > 0 && ( + + )} +
+ )} +
+ ) +} + +function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSelect: (id: string) => void }) { + return ( +
+ {overview.project.title} · 文档概览 + {overview.documents.length === 0 && 暂无受控文档——文档由模板实例化或导入后进入驾驶舱} +
+ {overview.documents.map(d => ( + + ))} +
+ 最近变化 + {overview.recentChanges.length === 0 &&
暂无可见变化
} +
+ {overview.recentChanges.map((c, i) => ( +
+ {c.summary} · {c.title}{c.actor ? ` · ${c.actor}` : ''} + {new Date(c.changedAt).toLocaleString('zh-CN')} +
+ ))} +
+
+ ) +} + +function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenReader }: { + pid: string; selection: Selection; overview: CockpitOverview + childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta } + onClear: () => void + onOpenReader: () => void +}) { + const [detail, setDetail] = useState(null) + const [err, setErr] = useState('') + const gen = useRef(0) + useEffect(() => { + const g = ++gen.current + setDetail(null); setErr('') + api.rdmCommand('rdm.cockpit.node', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id }) + .then(d => { if (g === gen.current) setDetail(d) }) + .catch(e => { if (g === gen.current) setErr(e.message) }) + return () => { gen.current++ } + }, [pid, selection.ref.kind, selection.ref.id]) + const node = (detail?.node || null) as CockpitTreeNode | null + const title = node?.title || selection.ref.id + return ( +
+
+ + {title} + {node && {nodeSummaryLine(node)}} + +
+ {err && } + {detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && ( +
内容受限({detail.body[0].reason || '权限'})——标题与状态可见,正文不展示
+ )} + {detail && detail.meta && !sameSnapshot(detail.meta, overview.meta) && ( +
节点详情与统计快照不同({detail.meta.snapshotRevision} vs {overview.meta.snapshotRevision})——刷新对齐
+ )} + {node && node.ref.kind === 'document' && ( + <> + 章节状态 + {!childPage &&
章节按需加载——在左侧树展开
} + {childPage && childPage.items.length === 0 &&
无章节
} + {childPage && childPage.items.length > 0 && ( + + + + {childPage.items.map(sec => ( + + + + + + + + + ))} + +
章节内容核实来源冲突最近更新
{sec.title}{{ complete: '完整', partial: '部分', empty: '空', inaccessible: '受限', unavailable: '不可读' }[sec.state.content] || sec.state.content}{sec.state.verification === 'not_applicable' ? '—' : sec.state.verification}{sec.state.sourceCount}{sec.state.conflictCount}{sec.lastChangedAt ? new Date(sec.lastChangedAt).toLocaleDateString('zh-CN') : '—'}
+ )} + + )} + {node && node.ref.kind === 'section' && ( +
点「在阅读器中打开」查看连续正文与版本差异;右侧为该节点的依据与影响面板
+ )} + {detail?.meta?.truncated && ( +
详情已截断:{detail.meta.truncationReasons?.join(';')}
+ )} +
+ ) +} diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index a867858..c649f56 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -1,8 +1,10 @@ // 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同): -// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key); -// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。 -import { useState } from 'react' -import { api } from '../api' +// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(恢复/激活,只输 Key); +// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生, +// grant_setup 免当前密码(激活/恢复 Key 兑换),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 }) { @@ -96,7 +98,7 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on setKey(e.target.value)} autoFocus placeholder="alk_…" /> -

忘记密码时由管理员签发;单次有效、默认 30 分钟内兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。

+

忘记密码时由管理员发起恢复提案,并由你在已登录设备的「设置 → 账号」中本人确认后获得;新账号使用管理员交付的激活 Key。单次有效、限时兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。

)} @@ -105,23 +107,46 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on ) } -/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。 - * 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框; - * 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */ +/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked + * 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */ +export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean { + return mode === 'current_required' +} + +/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me + * 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) { + const [mode, setMode] = useState(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' || + (me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) { + 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 +168,19 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
{ e.preventDefault(); submit() }}>
Agent-System

设置新密码

-

账号 {owner}(服务端确认)须设置新密码后继续;无需输入当前密码

+

账号 {owner}(服务端确认)须设置新密码后继续

+ {mode === 'current_required' && <> + + setCurrent(e.target.value)} autoComplete="current-password" /> + } + {mode === 'grant_setup' &&

身份经可信激活/恢复能力确认,此次无需输入当前密码。

} setNext(e.target.value)} autoFocus autoComplete="new-password" /> setConfirm(e.target.value)} autoComplete="new-password" /> - + {onCancel && } )} diff --git a/src/components/MyAccountSettings.tsx b/src/components/MyAccountSettings.tsx new file mode 100644 index 0000000..31b158e --- /dev/null +++ b/src/components/MyAccountSettings.tsx @@ -0,0 +1,142 @@ +// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。 +// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话 +//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应 +// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。 +import { useEffect, useRef, useState } from 'react' +import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api' +import { useStore } from '../store' +import { Collapsible, Icon, SettingsGroup } from '@ui' +import { recoveryStatusLabel } from '../recovery' + +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(null) + const [draft, setDraft] = useState(null) + const [proposals, setProposals] = useState(null) + const [grant, setGrant] = useState<{ proposalId: string; value: string } | 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 + Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => { + if (seq !== sequence.current) return + setAccount(profile); setDraft(profile.profile); setProposals(statuses) + }).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 = {} + 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) } + } + + const refreshProposal = async (id: string) => { + const updated = await api.getMyRecoveryProposal(id) + setProposals(current => current?.map(item => item.id === id ? updated : item) ?? [updated]) + return updated + } + + const confirm = async (proposal: RecoveryProposal) => { + setBusy(true); setErr(''); setNotice(''); setGrant(null) + try { + const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version) + setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal]) + if (!result.grant) { + setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。') + return + } + setGrant({ proposalId: proposal.id, value: result.grant }) + setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。') + } catch (error) { + const current = await refreshProposal(proposal.id).catch(() => null) + setErr(current?.status === 'confirmed' + ? '确认结果待核验:提案已确认,但明文不能读回。若仍保有原本人会话,可显式重新生成设密能力。' + : `确认未完成或结果未知:${String(error)}。请核对当前提案状态。`) + } finally { setBusy(false) } + } + + const rotate = async (proposal: RecoveryProposal) => { + setBusy(true); setErr(''); setNotice(''); setGrant(null) + try { + const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation) + setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal]) + if (!result.grant) { + setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。') + return + } + setGrant({ proposalId: proposal.id, value: result.grant }) + setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。') + } catch (error) { + await refreshProposal(proposal.id).catch(() => null) + setErr(`重新生成结果待核验:${String(error)}。请核对提案状态,再由本人显式操作。`) + } finally { setBusy(false) } + } + + const redeem = async () => { + if (!grant) return + setBusy(true); setErr(''); setNotice('') + try { + await api.redeemLoginKey(grant.value) + location.reload() // 新 HttpOnly 受限会话由应用启动门送到设密页 + } catch (error) { + const session = await api.me().catch(() => null) + if (session?.sessionScope === 'set_password_only' && session.passwordChangeMode === 'grant_setup') { + location.reload() + return + } + await refreshProposal(grant.proposalId).catch(() => null) + setGrant(null) + setErr(`兑换结果待核验:${String(error)}。不会自动重复兑换;若仍保有原本人会话,请显式重新生成能力。`) + } finally { setBusy(false) } + } + + return + {account && draft ? <> +

登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。

+ +
{fields.map(field =>
+ + setDraft({ ...draft, [field.key]: event.target.value })} /> +
)} +
+
+ :

使用完整密码会话后可查看和修改个人资料。

} + +

忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。

+ {(proposals ?? []).map(proposal =>
+
{proposal.id}{recoveryStatusLabel[proposal.status]} + 到期 {new Date(proposal.expiresAt).toLocaleString()}
+
+ {proposal.status === 'pending' && } + {proposal.status === 'confirmed' && } +
+
)} + {proposals !== null && proposals.length === 0 &&

当前没有恢复提案。

} + {grant &&
+

设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。

+ +
} + {notice &&
{notice}
} + {err &&
{err}
} +
+} diff --git a/src/components/ProjectSettingsModal.tsx b/src/components/ProjectSettingsModal.tsx index 0675e78..113b663 100644 --- a/src/components/ProjectSettingsModal.tsx +++ b/src/components/ProjectSettingsModal.tsx @@ -19,6 +19,11 @@ export function ProjectSettingsModal() { const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable) const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable) const owner = useStore(s => s.me?.owner) + const isAdmin = useStore(s => s.me?.isAdmin ?? false) + // P1-B 裁定:宿主覆盖设置(含模型与密钥)仅项目负责人/系统管理员可查看与修改; + // RDM 页签(成员/文档职责/项目权限)不在此列——数据面由 RDM 权限自行收敛, + // 授权的职责管理者仍可进入。 + const projectPrivileged = !!project && (project.owner === owner || isAdmin) const [tab, setTab] = useState('project') const [schema, setSchema] = useState([]) const [draft, setDraft] = useState>({}) @@ -85,6 +90,8 @@ export function ProjectSettingsModal() { } {tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? ( {error || '加载中…'} + ) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !projectPrivileged ? ( + 项目覆盖设置(含模型与密钥)仅项目负责人或系统管理员可查看与修改。 ) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? ( <>
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 51ca5d1..3d1205e 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -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 = { @@ -19,7 +20,7 @@ export const groupNames: Record = { } export function SettingsModal() { - const [section] = useState
('settings') + const [section, setSection] = useState
('settings') const [schema, setSchema] = useState([]) const [, setUser] = useState>({}) const [draft, setDraft] = useState>({}) @@ -54,10 +55,11 @@ export function SettingsModal() { return ( 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 ? ✓ 已保存 : 'Agent-System · 设置中心'} actions={section === 'settings' && schema.length > 0 ? ( @@ -108,6 +110,7 @@ export function SettingsModal() { ) })} {section === 'settings' && } + {section === 'account' && } ) } diff --git a/src/components/TaskSidebar.tsx b/src/components/TaskSidebar.tsx index 65d8d13..37e53be 100644 --- a/src/components/TaskSidebar.tsx +++ b/src/components/TaskSidebar.tsx @@ -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) diff --git a/src/components/Workspaces.tsx b/src/components/Workspaces.tsx index 2a3458c..9993be8 100644 --- a/src/components/Workspaces.tsx +++ b/src/components/Workspaces.tsx @@ -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() { } + if (active === 'rdm-cockpit') { + return + + + } if (active.startsWith('plugin:')) { const panel = pluginPanels.find(p => `plugin:${p.id}` === active) return 待使用 - if (status === 'consumed') return 已使用 - if (status === 'expired') return 已过期 - return 已撤销 -} +// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化 +// 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key; +// 恢复走属主确认制提案:管理员提案 → 用户在「设置 → 账号」本人确认 → 一次性 Key +// 只在属主会话内出现(直接签发 Key 的 HTTP 入口已被 410 替换)。---- +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 +24,51 @@ 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 [busy, setBusy] = useState(false) - // 登录 Key 面板:keyFor 打开指定用户的签发/状态视图;issued 明文仅签发响应持有一次。 - const [keyFor, setKeyFor] = useState(null) - const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null) - const [keys, setKeys] = useState(null) + // 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。 + const [propFor, setPropFor] = useState(null) + const [proposals, setProposals] = useState(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)) } + const refreshProposals = async (userId: string) => { + try { setProposals(await api.listAdminRecoveryProposals(userId)) } 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()) + setNotice(`已创建 ${out.owner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`) + setOwner(''); setName('') await refresh() } catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) } } - const issue = async () => { - if (!keyFor) return + const openProposals = async (u: AdminUserRow) => { + setErr(''); setNotice(''); setPropFor(u); setProposals(null) + await refreshProposals(u.userId) + } + + const propose = async () => { + if (!propFor) return setErr(''); setNotice(''); 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) } + await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID()) + setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`) + await refreshProposals(propFor.userId) + } catch (e) { setErr(recoveryProposalErrorMessage(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 + const revokeProposal = async (p: RecoveryProposal) => { + if (!propFor) return setErr(''); setBusy(true) try { - await api.adminRevokeLoginKey(keyFor, id) - await refreshKeys(keyFor) + await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version) + await refreshProposals(propFor.userId) } catch (e) { setErr(String(e)) } finally { setBusy(false) } } @@ -94,48 +79,40 @@ export function UsersPanel() {
setOwner(e.target.value)} /> setName(e.target.value)} /> +
-
- setPw(e.target.value)} /> - - - -
-

创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。

+

账号创建后处于待初始化状态:在服务器执行 user initialize-account 发放一次性激活 Key,用户经兑换入口设置密码后登录

)} {notice &&
{notice}
} {err &&
{err}
} - {keyFor ? ( + {propFor ? (
- {keyFor} 的一次性登录 Key(忘记密码恢复) - - + {propFor.owner} 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key) + +
- {issued && ( -
- e.currentTarget.select()} /> - -
- )} - {issued &&

明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。

} - {keys !== null && keys.length > 0 && ( +

前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。

+ {proposals !== null && proposals.length > 0 && ( - + - {keys.map(k => ( - - - - - - + {proposals.map(p => ( + + + + + + ))}
签发时间用途状态过期时间
提案状态到期时间发起人
{new Date(k.createdAt).toLocaleString()}{k.purpose === 'recover' ? '恢复' : '激活'}{keyStatusChip(k.status)}{new Date(k.expiresAt).toLocaleString()}{k.status === 'unused' && }
{p.id}{recoveryStatusLabel[p.status]}{recoveryProposalHint(p.status) && · {recoveryProposalHint(p.status)}}{new Date(p.expiresAt).toLocaleString()}{p.createdBy}{(p.status === 'pending' || p.status === 'confirmed') && ( + + )}
)} + {proposals !== null && proposals.length === 0 &&

该用户当前没有恢复提案。

}
) : ( @@ -149,7 +126,7 @@ export function UsersPanel() { ))} diff --git a/src/documentCockpit.test.ts b/src/documentCockpit.test.ts new file mode 100644 index 0000000..2998282 --- /dev/null +++ b/src/documentCockpit.test.ts @@ -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 & { 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') + }) +}) diff --git a/src/documentCockpit.ts b/src/documentCockpit.ts new file mode 100644 index 0000000..6509d05 --- /dev/null +++ b/src/documentCockpit.ts @@ -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 +} + +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 = { + empty: stats.emptyNodeCount, + unverified: stats.unverifiedSourceCount, + conflict: stats.conflictCount, + pending: stats.pendingChangeCount, + } + const v = map[key] + // 原因表按后端 DTO 字段名(statReasons 键=字段名),展示键需映射。 + const reasonKey: Record = { + 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 { 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 + 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, +): { 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')})` +} diff --git a/src/passwords.ts b/src/passwords.ts deleted file mode 100644 index b0ad3c3..0000000 --- a/src/passwords.ts +++ /dev/null @@ -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 -} diff --git a/src/recovery.test.ts b/src/recovery.test.ts new file mode 100644 index 0000000..4b9c7cd --- /dev/null +++ b/src/recovery.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from './recovery' +import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage' + +// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的 +// 防漂移锁):改密门必须区分 grant_setup / current_required,恢复提案 409 的 +// 两种语义必须分开表达。 + +describe('requiresCurrentPassword', () => { + it('current_required 必须要当前密码,grant_setup 免当前密码', () => { + expect(requiresCurrentPassword('current_required')).toBe(true) + expect(requiresCurrentPassword('grant_setup')).toBe(false) + }) +}) + +describe('humanChangePasswordError', () => { + it('后端 current password incorrect 映射为当前密码不正确', () => { + expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确') + }) +}) + +describe('humanRedeemError', () => { + it('401/409/429 语义分离', () => { + expect(humanRedeemError('409: login key already used')).toContain('已被使用') + expect(humanRedeemError('401: invalid or expired login key')).toContain('无效或已过期') + expect(humanRedeemError('429: too many failed attempts')).toContain('稍后再试') + }) +}) + +describe('recoveryStatusLabel', () => { + it('覆盖全部五种提案状态', () => { + for (const status of ['pending', 'confirmed', 'completed', 'revoked', 'expired'] as const) { + expect(recoveryStatusLabel[status]).toBeTruthy() + } + }) +}) + +describe('recoveryProposalHint', () => { + it('pending/confirmed 给出下一步,终态不给', () => { + expect(recoveryProposalHint('pending')).toContain('本人确认') + expect(recoveryProposalHint('confirmed')).toContain('新密码') + expect(recoveryProposalHint('completed')).toBe('') + }) +}) + +describe('recoveryProposalErrorMessage', () => { + it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => { + expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明') + expect(recoveryProposalErrorMessage('409: store: conflict')).toContain('已有进行中的恢复提案') + }) + it('未识别错误剥状态码原样透出', () => { + expect(recoveryProposalErrorMessage('500: boom')).toBe('boom') + }) +}) diff --git a/src/recovery.ts b/src/recovery.ts new file mode 100644 index 0000000..6b60749 --- /dev/null +++ b/src/recovery.ts @@ -0,0 +1,29 @@ +// 恢复提案展示与错误文案(纯函数):管理端 UsersPanel 与本人 MyAccountSettings +// 共用,vitest 锁定语义防漂移。后端 409 两种含义按响应体区分: +// OWNER_PROOF_UNAVAILABLE=无合格会话证明;其余 conflict=已有活动提案。 +import type { RecoveryProposal } from './api' + +export const recoveryStatusLabel: Record = { + pending: '待本人确认', + confirmed: '已确认,待兑换设密', + completed: '已完成', + revoked: '已撤销', + expired: '已过期', +} + +/** 管理端视角的下一步指引:提案成立后动作在属主侧(设置 → 账号)。 */ +export function recoveryProposalHint(status: RecoveryProposal['status']): string { + if (status === 'pending') return '等待该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现' + if (status === 'confirmed') return '该用户已确认;请提醒其尽快在受限会话中完成新密码设置,到期未用需重新生成' + return '' +} + +export function recoveryProposalErrorMessage(raw: string): string { + if (raw.includes('OWNER_PROOF_UNAVAILABLE')) { + return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置' + } + if (raw.includes('409')) return '该用户已有进行中的恢复提案;请先撤销旧提案或等它完成/过期' + if (raw.includes('403')) return '需要管理员身份' + if (raw.includes('404')) return '用户不存在或已被移除' + return raw.replace(/^\d{3}:\s*/, '') +} diff --git a/src/store.ts b/src/store.ts index c2b70f7..328f4c3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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' import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation' @@ -125,6 +125,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)。 */ @@ -202,6 +203,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, rdmRender: NO_RENDER_CAPABILITIES, @@ -446,7 +448,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, rdmRender: NO_RENDER_CAPABILITIES, connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, previewTabs: {}, previewActive: {}, previewOpenIntent: 0 }) @@ -1201,6 +1203,7 @@ export const store = { if (epoch !== identityEpoch) return set({ rdmAuditAvailable: rdmAuditReady(cmds), + rdmCockpitAvailable: cockpitReady(cmds), rdmExportAvailable: rdmExportReady(cmds), rdmRolesAvailable: rdmRolesReady(cmds), rdmMembersAvailable: rdmMembersReady(cmds), @@ -1210,7 +1213,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, rdmRender: NO_RENDER_CAPABILITIES }) } try { diff --git a/src/styles.css b/src/styles.css index 590e60b..ce45a9f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -772,3 +772,76 @@ h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); } .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); } diff --git a/src/workspaces.ts b/src/workspaces.ts index 8e99a45..0e9f0d5 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -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[] = [ { 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)
{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'} {u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'} {me?.isAdmin && ( - + )}