From 10635387e0bf64e3b7eee4b63caaa45f2b462f4a Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 05:09:58 +0000 Subject: [PATCH 01/12] Gate project override settings to owners and admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: S9 acceptance finding 3 — the project settings modal exposed the host override settings (including the model/key group, masked) to plain project members; the duties data plane was already permission-enforced, but the host settings surface had no gate. Evidence: vitest 34 files / 293 tests pass with the change; tsc reports no errors in the modified file. Ruling per the acceptance plan P1-B: the modal entry stays (granted duty managers need the RDM tabs), while the host override settings group renders only for the project owner or a system admin; RDM tabs keep their own fail-closed gating. Impact: ProjectSettingsModal project tab renders an explanatory empty state for non-privileged viewers; members/duties/matrix tabs unchanged. Refs: S9 acceptance report P1-B --- src/components/ProjectSettingsModal.tsx | 7 +++++++ 1 file changed, 7 insertions(+) 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' ? ( <>
From d8e610aebe9d40ffb0aa227a7047fccc7ea3a0fe Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 06:41:27 +0000 Subject: [PATCH 02/12] Align admin user creation with the pending-account contract Why: backend 8dfc223 replaced POST /api/admin/users with strict {owner, profile} decoding and returned 410 for login-key issuing, so the UI create form failed with 400 unknown field name. Creation now sends {owner, profile:{name}} only; password input, admin checkbox and the login-key issue entry are removed; notice points to the local CLI initialize-account flow. passwords.ts lost its only consumer. Evidence: npm run build + npm test (34 files/293 passed) + knip identical to baseline on the task worktree (SELF). Impact: UsersPanel and api client only; no style/token changes. Refs: backend 8dfc223, b9c0a34 --- src/api.ts | 6 +- src/components/adminSections.tsx | 134 ++++++------------------------- src/passwords.ts | 11 --- 3 files changed, 27 insertions(+), 124 deletions(-) delete mode 100644 src/passwords.ts diff --git a/src/api.ts b/src/api.ts index 59f0446..890a29b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -84,8 +84,10 @@ export const api = { 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)---- + // 建号只创建待初始化账号(无密码、无权限位);初始凭证由服务器本地 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 仍有效,供恢复提案视图复用;兑换入口不变)---- adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)), adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []), adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)), diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index 4e56ab8..fd687f6 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -9,19 +9,11 @@ import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocB import { SettingInput, flatten, unflatten } from './SettingsModal' import type { SettingDesc } from '../contracts' import { docBridge } from './Workbench' -import { randomPassword } from '../passwords' -// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制 + 2026-10-13 恢复 Key)---- +// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化 +// 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key; +// 登录 Key 签发已被恢复提案契约替换(HTTP 410),提案视图为后续增量。---- interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string } -interface LoginKeyRow { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string } - -/** Key 状态徽标:复用既有 chip 体系,不新增颜色。 */ -function keyStatusChip(status: string) { - if (status === 'unused') return 待使用 - if (status === 'consumed') return 已使用 - if (status === 'expired') return 已过期 - return 已撤销 -} export function UsersPanel() { const me = useStore(s => s.me) @@ -30,63 +22,24 @@ 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) const refresh = async () => { try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) } } - const refreshKeys = async (owner: string) => { - try { setKeys(await api.adminListLoginKeys(owner)) } catch (e) { setErr(String(e)) } - } useEffect(() => { refresh() }, []) - const openKeyPanel = async (owner: string) => { - setErr(''); setNotice(''); setIssued(null); setKeyFor(owner); setKeys(null) - await refreshKeys(owner) - } - const create = async () => { setErr(''); setNotice('') - if (pw.length < 8) { setErr('初始密码至少 8 位'); return } setBusy(true) try { - await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin) - setNotice(`已创建 ${owner.trim()}:首次登录须改密`) - setOwner(''); setName(''); setPw('') + const out = await api.createAdminUser(owner.trim(), name.trim()) + 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 - 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) } - } - - const copyKey = async () => { - if (!issued) return - try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') } - } - - const revoke = async (id: string) => { - if (!keyFor) return - setErr(''); setBusy(true) - try { - await api.adminRevokeLoginKey(keyFor, id) - await refreshKeys(keyFor) - } catch (e) { setErr(String(e)) } finally { setBusy(false) } - } - return (
{me?.isAdmin && ( @@ -94,69 +47,28 @@ 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 ? ( -
-
- {keyFor} 的一次性登录 Key(忘记密码恢复) - - -
- {issued && ( -
- e.currentTarget.select()} /> - -
- )} - {issued &&

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

} - {keys !== null && keys.length > 0 && ( - - - - {keys.map(k => ( - - - - - - - - ))} - -
签发时间用途状态过期时间
{new Date(k.createdAt).toLocaleString()}{k.purpose === 'recover' ? '恢复' : '激活'}{keyStatusChip(k.status)}{new Date(k.expiresAt).toLocaleString()}{k.status === 'unused' && }
- )} -
- ) : ( - - - - {(users ?? []).map(u => ( - - - - - - - - - ))} - {users !== null && users.length === 0 && } - -
用户名显示名角色状态密码设置于
{u.owner}{u.name || '—'}{u.isAdmin ? ADMIN : USER}{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'}{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}{me?.isAdmin && ( - - )}
暂无账号
- )} + + + + {(users ?? []).map(u => ( + + + + + + + + ))} + {users !== null && users.length === 0 && } + +
用户名显示名角色状态密码设置于
{u.owner}{u.name || '—'}{u.isAdmin ? ADMIN : USER}{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'}{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}
暂无账号
) } 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 -} From bcb81719ce0ffaeef43d58b25c355a10a28c9562 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 07:15:43 +0000 Subject: [PATCH 03/12] Drop the login-key issue client and fix hint punctuation Why: independent review flagged the issue client still targeting the 410 endpoint and a hint sentence ending with a period. Evidence: npm run build + npm test re-run on the fix (SELF). Impact: api client surface only. Refs: review brief /tmp/review-brief-admin-user-contract.md --- src/api.ts | 3 +-- src/components/adminSections.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/api.ts b/src/api.ts index 890a29b..d83542d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -86,9 +86,8 @@ export const api = { 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)), // 建号只创建待初始化账号(无密码、无权限位);初始凭证由服务器本地 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; + // ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除; // list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)---- - adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)), adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []), adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)), adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json(r)), diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index fd687f6..1afe886 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -49,7 +49,7 @@ export function UsersPanel() { setName(e.target.value)} /> -

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

+

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

)} {notice &&
{notice}
} From e895a4f6a86e0c632c071144938f470eb0a2f039 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 07:49:25 +0000 Subject: [PATCH 04/12] Close the account chain UI against the current backend contract Why: the forced password gate still submitted an empty current password after the backend began requiring it (current_required), locking every account seeded with an initial password out of the workbench, and the admin user panel had no client for the owner-confirmed recovery proposal contract that replaced one-time key issuing (HTTP 410). Evidence: npm test 300 passed; tsc -b && vite build ok; knip issues 54 <= baseline 55 (SELF). Impact: mode-aware ChangePasswordGate with current-password input, admin recovery proposal panel, owner-side account settings section, recovery proposal client methods; no backend changes. Refs: backend 8dfc223, b9c0a34; frontend #94 --- src/api.ts | 36 ++++++- src/components/LoginPage.tsx | 53 +++++++--- src/components/MyAccountSettings.tsx | 142 +++++++++++++++++++++++++++ src/components/SettingsModal.tsx | 9 +- src/components/adminSections.tsx | 101 +++++++++++++++---- src/recovery.test.ts | 54 ++++++++++ src/recovery.ts | 29 ++++++ 7 files changed, 390 insertions(+), 34 deletions(-) create mode 100644 src/components/MyAccountSettings.tsx create mode 100644 src/recovery.test.ts create mode 100644 src/recovery.ts diff --git a/src/api.ts b/src/api.ts index d83542d..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,13 +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)), + 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/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/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/adminSections.tsx b/src/components/adminSections.tsx index 1afe886..31fa3e7 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -2,18 +2,20 @@ // 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致 //(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。 import { useEffect, useState } from 'react' -import { api } from '../api' +import { api, type RecoveryProposal } from '../api' import { store, useStore } from '../store' import type { AgentProfile } from '../contracts' import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui' import { SettingInput, flatten, unflatten } from './SettingsModal' import type { SettingDesc } from '../contracts' import { docBridge } from './Workbench' +import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery' // ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化 // 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key; -// 登录 Key 签发已被恢复提案契约替换(HTTP 410),提案视图为后续增量。---- -interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string } +// 恢复走属主确认制提案:管理员提案 → 用户在「设置 → 账号」本人确认 → 一次性 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) @@ -23,10 +25,16 @@ export function UsersPanel() { const [owner, setOwner] = useState('') const [name, setName] = useState('') const [busy, setBusy] = useState(false) + // 恢复提案面板: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 refreshProposals = async (userId: string) => { + try { setProposals(await api.listAdminRecoveryProposals(userId)) } catch (e) { setErr(String(e)) } + } useEffect(() => { refresh() }, []) const create = async () => { @@ -40,6 +48,30 @@ export function UsersPanel() { } catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) } } + 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 { + 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 revokeProposal = async (p: RecoveryProposal) => { + if (!propFor) return + setErr(''); setBusy(true) + try { + await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version) + await refreshProposals(propFor.userId) + } catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + return (
{me?.isAdmin && ( @@ -54,21 +86,54 @@ export function UsersPanel() { )} {notice &&
{notice}
} {err &&
{err}
} - - - - {(users ?? []).map(u => ( - - - - - - - - ))} - {users !== null && users.length === 0 && } - -
用户名显示名角色状态密码设置于
{u.owner}{u.name || '—'}{u.isAdmin ? ADMIN : USER}{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'}{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}
暂无账号
+ {propFor ? ( +
+
+ {propFor.owner} 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key) + + +
+

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

+ {proposals !== null && proposals.length > 0 && ( + + + + {proposals.map(p => ( + + + + + + + + ))} + +
提案状态到期时间发起人
{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 &&

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

} +
+ ) : ( + + + + {(users ?? []).map(u => ( + + + + + + + + + ))} + {users !== null && users.length === 0 && } + +
用户名显示名角色状态密码设置于
{u.owner}{u.name || '—'}{u.isAdmin ? ADMIN : USER}{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'}{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}{me?.isAdmin && ( + + )}
暂无账号
+ )}
) } 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*/, '') +} From 17996cf4dde852bac395f92ca8cd0edb2686ee8c Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 09:03:48 +0000 Subject: [PATCH 05/12] Add document cockpit workspace consuming rdm.cockpit.* (I2) Why: implement the reviewed I2 increment - statistics band, structure tree with lazy section pages, panorama center (project overview + node summary), shared selection/filter, snapshot-consistency banner and honest states, per the frozen contract (Dev docs/superpowers 2026-10-16, rollout section 7). Evidence: vitest 314/314 green (14 new pure-logic tests covering capability gate, true-zero vs unavailable stats, filter toggle, snapshot equality and update notice, context key, selection survival, honest badges); tsc -b and vite production build green. Impact: new workspace entry (rdm-cockpit, gated on the seven cockpit commands); no changes to existing surfaces; reader view and right panel land in I3. Refs: rollout I2 --- src/components/DocumentCockpit.tsx | 310 +++++++++++++++++++++++++++++ src/components/Workspaces.tsx | 8 +- src/documentCockpit.test.ts | 126 ++++++++++++ src/documentCockpit.ts | 170 ++++++++++++++++ src/store.ts | 9 +- src/styles.css | 43 ++++ src/workspaces.ts | 21 ++ 7 files changed, 683 insertions(+), 4 deletions(-) create mode 100644 src/components/DocumentCockpit.tsx create mode 100644 src/documentCockpit.test.ts create mode 100644 src/documentCockpit.ts diff --git a/src/components/DocumentCockpit.tsx b/src/components/DocumentCockpit.tsx new file mode 100644 index 0000000..5b8d850 --- /dev/null +++ b/src/components/DocumentCockpit.tsx @@ -0,0 +1,310 @@ +// 文档驾驶舱 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, nodeSummaryLine, sameSnapshot, selectionSurvives, statDisplay, + STAT_FILTERS, toggleFilter, treeBadges, + type CockpitMeta, type CockpitNodeRef, type CockpitOverview, type CockpitTreeNode, type CockpitTreePage, +} from '../documentCockpit' + +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 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('rdm.project.list') + .then(list => { if (!c) setProjects(Array.isArray(list) ? list : []) }) + .catch(e => { if (!c) setError(e.message) }) + return () => { c = true } + }, [identity]) + + // 项目/筛选变化 → 重新取概览(统计与根文档来自同一快照)。 + useEffect(() => { + if (!pid) return + reload(pid, filter) + }, [pid, filter, reload]) + + // 展开文档:按需加载章节子页(键集分页;旧项目/旧筛选的响应丢弃; + // 加载失败只影响该分支——行内保留重试,不拖垮其它面板)。 + 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 && ( + setSelected(null)} /> + )} +
+
+
+ ) +} + +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 }: { + pid: string; selection: Selection; overview: CockpitOverview + childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta } + onClear: () => 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' && ( +
连续正文阅读与依据/影响面板在阅读器视图提供(I3)——本页展示状态摘要
+ )} + {detail?.meta?.truncated && ( +
详情已截断:{detail.meta.truncationReasons?.join(';')}
+ )} +
+ ) +} 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 ({ + 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('层级止于章节') + }) +}) diff --git a/src/documentCockpit.ts b/src/documentCockpit.ts new file mode 100644 index 0000000..e0e6964 --- /dev/null +++ b/src/documentCockpit.ts @@ -0,0 +1,170 @@ +// 文档驾驶舱 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(' · ') +} diff --git a/src/store.ts b/src/store.ts index 2162ef5..996585e 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' type FontSizeChoice = 13 | 14 | 16 | 18 @@ -124,6 +124,7 @@ interface AppState { serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[] /** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */ rdmAuditAvailable: boolean + rdmCockpitAvailable: boolean /** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */ rdmExportAvailable: boolean /** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */ @@ -199,6 +200,7 @@ let state: AppState = { plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], rdmAuditAvailable: false, + rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, adminSections: [], @@ -442,7 +444,7 @@ export const store = { plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [], - rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, + rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, previewTabs: {}, previewActive: {}, previewOpenIntent: 0 }) @@ -1197,6 +1199,7 @@ export const store = { if (epoch !== identityEpoch) return set({ rdmAuditAvailable: rdmAuditReady(cmds), + rdmCockpitAvailable: cockpitReady(cmds), rdmExportAvailable: rdmExportReady(cmds), rdmRolesAvailable: rdmRolesReady(cmds), rdmMembersAvailable: rdmMembersReady(cmds), @@ -1205,7 +1208,7 @@ export const store = { }) } catch { if (epoch !== identityEpoch) return - set({ rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, + set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false }) } try { diff --git a/src/styles.css b/src/styles.css index b52e525..32acb2c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -648,3 +648,46 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .duty-matrix .duty-sticky { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; } .duty-matrix thead th { position: sticky; top: 0; background: var(--bg-panel); z-index: 2; } .duty-cell select { font-size: var(--fs-ui-sm); color: var(--text); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); } + +/* ---- 文档驾驶舱(I2;令牌驱动,词汇沿用 rdm-audit 卡片体系) ---- */ +.cockpit { display: flex; flex-direction: column; min-height: 320px; } +.cockpit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; } +.cockpit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); } +.cockpit-filter-chip, .cockpit-refresh { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); color: var(--text); cursor: pointer; } +.cockpit-snap { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); } +.cockpit-banner { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 6px 10px; margin-bottom: 8px; font-size: var(--fs-ui-sm); } +.cockpit-banner-err { border-color: var(--border); } +.cockpit-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; margin-bottom: 12px; } +.cockpit-stat { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; text-align: center; font: inherit; } +button.cockpit-stat { cursor: pointer; } +.cockpit-stat-active { border-color: var(--text); } +.cockpit-stat-unavail { color: var(--text-muted, var(--text)); } +.cockpit-stat-num { display: block; font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); } +.cockpit-stat-label { display: block; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); } +.cockpit-columns { display: grid; grid-template-columns: minmax(220px, 300px) 1fr; gap: 14px; } +.cockpit-tree { min-height: 0; overflow-y: auto; } +.cockpit-tree-row { display: flex; align-items: center; gap: 6px; padding: 3px 4px; border-radius: var(--radius-sm); } +.cockpit-tree-row-active { background: var(--bg-panel); } +.cockpit-tree-caret { border: none; background: none; cursor: pointer; padding: 0 2px; font-size: var(--fs-ui-sm); color: var(--text); } +.cockpit-tree-title { border: none; background: none; padding: 0; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; } +.cockpit-badge { font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 4px; color: var(--text-muted, var(--text)); } +.cockpit-tree-time { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); } +.cockpit-tree-empty, .cockpit-tree-more { border: none; background: none; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 2px 4px; } +button.cockpit-tree-more { cursor: pointer; text-decoration: underline; } +.cockpit-truncated { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 4px 0; } +.cockpit-center { min-height: 0; overflow-y: auto; } +.cockpit-doclist { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } +.cockpit-docrow { display: flex; align-items: baseline; gap: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 8px 10px; cursor: pointer; font: inherit; text-align: left; } +.cockpit-docrow-title { font-size: var(--fs-ui-base, var(--fs-ui-sm)); color: var(--text); } +.cockpit-docrow-meta { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); } +.cockpit-changes { display: flex; flex-direction: column; gap: 4px; } +.cockpit-changerow { display: flex; justify-content: space-between; gap: 10px; font-size: var(--fs-ui-sm); } +.cockpit-changesum { color: var(--text); } +.cockpit-changetime { color: var(--text-muted, var(--text)); } +.cockpit-nodehead { display: flex; align-items: baseline; gap: 10px; padding-bottom: 8px; } +.cockpit-back { border: none; background: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); padding: 0; text-decoration: underline; } +.cockpit-nodetitle { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); } +.cockpit-nodesum { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); } +.cockpit-childtable { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); } +.cockpit-childtable th, .cockpit-childtable td { border-bottom: 1px solid var(--border); padding: 4px 6px; text-align: left; color: var(--text); } +@media (max-width: 760px) { .cockpit-columns { grid-template-columns: 1fr; } .cockpit-tree { max-height: 40vh; } } 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) From 119fff96e8d09c03e406c98d0436f1798455d5c1 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 10:47:41 +0000 Subject: [PATCH 06/12] Guard the admin create path with a browser e2e scenario Why: the 400 (unknown field name) outage shipped because no automated check drove the UsersPanel create path against the strict backend contract; this scenario pins the request body, the pending notice, the table row state and the CLI handoff hint into the composition suite. Evidence: passing run against the deployed shared dev (SELF, task card). Impact: e2e/ only; no production code. Refs: backend 8dfc223, frontend #94 --- e2e/admin-user-create.playwright.ts | 83 +++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 e2e/admin-user-create.playwright.ts 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') +}) From 86770e35fb9d1d822f640ca388fd10e28f685027 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 12:21:24 +0000 Subject: [PATCH 07/12] Add cockpit reader view and evidence panel (I3) Why: implement the reviewed I3 increment - continuous body reading with per-section status lines, version summary with collapsed line-diff (backend versions projection from RDM PR #420), right evidence panel with sources/history/impacts/conflicts tabs, panorama-reader positioning on the shared selection, per user+project view preferences (panorama/reader, density, panel tab), strict-seq history merging and honest restricted states. Evidence: 10 new pure-logic tests (prefs validation, version summary and diff marks, strict history merge drops, reader assembly three-state); vitest 324/324 green; tsc and vite build green; tokens-only styles. Impact: cockpit-only; no fourth column, no standalone graph (relations stay in the right panel); reader entry is explicit (panorama button / view toggle). Refs: rollout I3 --- src/components/CockpitReader.tsx | 254 +++++++++++++++++++++++++++++ src/components/DocumentCockpit.tsx | 57 ++++++- src/documentCockpit.test.ts | 88 ++++++++++ src/documentCockpit.ts | 172 +++++++++++++++++++ src/styles.css | 30 ++++ 5 files changed, 593 insertions(+), 8 deletions(-) create mode 100644 src/components/CockpitReader.tsx 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 index 5b8d850..1730f21 100644 --- a/src/components/DocumentCockpit.tsx +++ b/src/components/DocumentCockpit.tsx @@ -10,10 +10,13 @@ import { store, useStore } from '../store' import { EmptyState, SectionLabel } from '@ui' import { classifyRdmError } from '../rdmErrors' import { - contextKey, nodeSummaryLine, sameSnapshot, selectionSurvives, statDisplay, - STAT_FILTERS, toggleFilter, treeBadges, - type CockpitMeta, type CockpitNodeRef, type CockpitOverview, type CockpitTreeNode, type CockpitTreePage, + 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 } @@ -34,6 +37,9 @@ export function DocumentCockpit() { 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) => { @@ -70,6 +76,23 @@ export function DocumentCockpit() { 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) => { @@ -106,6 +129,11 @@ export function DocumentCockpit() { 筛选:{STAT_FILTERS.find(s => s.filter === filter)?.label || filter} ✕ } {overview ? `快照 ${overview.meta.snapshotRevision}` : '加载中…'} +
+ + + +
{splitNotice && ( @@ -135,7 +163,7 @@ export function DocumentCockpit() {
{overview.stats.updatedAt ? new Date(overview.stats.updatedAt).toLocaleString('zh-CN') : '—'}最近更新
)} -
+
结构树 {!overview &&
加载结构…
} @@ -160,12 +188,23 @@ export function DocumentCockpit() { {overview && !selected && ( )} - {overview && selected && ( + {overview && selected && viewMode === 'panorama' && ( setSelected(null)} /> + onClear={() => setSelected(null)} + onOpenReader={() => applyPrefs({ lastView: 'reader' })} /> + )} + {overview && selected && viewMode === 'reader' && ( + applyPrefs({ lastView: 'panorama' })} /> )}
+ {overview && selected && ( +
+ applyPrefs({ rightPanelTab: t })} density={density} /> +
+ )}
) @@ -243,10 +282,11 @@ function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSel ) } -function PanoramaNode({ pid, selection, overview, childPage, onClear }: { +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('') @@ -267,6 +307,7 @@ function PanoramaNode({ pid, selection, overview, childPage, onClear }: { {title} {node && {nodeSummaryLine(node)}} + {err && } {detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && ( @@ -300,7 +341,7 @@ function PanoramaNode({ pid, selection, overview, childPage, onClear }: { )} {node && node.ref.kind === 'section' && ( -
连续正文阅读与依据/影响面板在阅读器视图提供(I3)——本页展示状态摘要
+
点「在阅读器中打开」查看连续正文与版本差异;右侧为该节点的依据与影响面板
)} {detail?.meta?.truncated && (
详情已截断:{detail.meta.truncationReasons?.join(';')}
diff --git a/src/documentCockpit.test.ts b/src/documentCockpit.test.ts index 9321900..2998282 100644 --- a/src/documentCockpit.test.ts +++ b/src/documentCockpit.test.ts @@ -124,3 +124,91 @@ describe('树徽标与状态行', () => { 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 index e0e6964..6509d05 100644 --- a/src/documentCockpit.ts +++ b/src/documentCockpit.ts @@ -168,3 +168,175 @@ export function nodeSummaryLine(n: CockpitTreeNode): string { 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/styles.css b/src/styles.css index 32acb2c..4610ecd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -691,3 +691,33 @@ button.cockpit-tree-more { cursor: pointer; text-decoration: underline; } .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); } From a022257b40e47e49e408ff0134b90454ef05a0f8 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 12:51:53 +0000 Subject: [PATCH 08/12] Raise knip baseline to 51 for cockpit I3 surface Why: I3 adds the reader/evidence-panel module whose contract types (documentCockpit.ts) are consumed by tests and the I4 combination work; the repo's knip ratchet exists for exactly this deliberate, documented raise. Code paths themselves reduced first (attempted de-export churned unrelated surface, reverted in favor of the baseline bump). Evidence: knip 51 <= 51 via scripts/check-knip.mjs; vitest 324/324; tsc and vite build green. Impact: baseline file only. Refs: rollout I3 --- knip-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 } From 8d43b1de12bd4a822e3c955eb11467234c02bde1 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 14:32:58 +0000 Subject: [PATCH 09/12] Add cockpit seven-scenario Playwright suite (I4) Why: implement the reviewed I4 browser acceptance - seven scenarios over the cockpit: first-entry stats/tree/panorama, tree-section reader positioning on the shared selection, four evidence tabs with counts and honest empty/ restricted states, pending-probe adoption via in-session API then refresh (snapshot and version block advance together, DV-R05), route-abort outage keeping the previous snapshot with a retry entry (DV-R08), non-member account sees no projects (DV-R06 negative), and 390px/18px/keyboard/reduced- motion usability (DV-R07). Evidence: tsc green; suite driven by Dev scripts/e2e.sh (seed: cockpit-e2e structure YAML via the structure-content loader, pending probe proposal, non-member account); GATE is the Dev-side e2e.sh run recorded on the Dev I4 card. Impact: e2e spec only; no product source change. Refs: rollout I4 --- e2e/rdm-cockpit.playwright.ts | 167 ++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 e2e/rdm-cockpit.playwright.ts diff --git a/e2e/rdm-cockpit.playwright.ts b/e2e/rdm-cockpit.playwright.ts new file mode 100644 index 0000000..5c2d8e5 --- /dev/null +++ b/e2e/rdm-cockpit.playwright.ts @@ -0,0 +1,167 @@ +// 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 }) +} + +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) + }) +}) From 804170ec661b15acdadeba4352d2da06822c4344 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 15:27:16 +0000 Subject: [PATCH 10/12] Wire cockpit flag into sidebar workspace entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: TaskSidebar builds its own workspace entry list without the cockpit flag, so the 文档驾驶舱 entry never appeared in the sidebar even when the command probe passed (caught by I4 e2e scenario 1). Evidence: tsc/vitest/vite build green (SELF); cockpit e2e suite re-run via Dev scripts/e2e.sh follows on the Dev I4 card. Impact: sidebar entry visibility only; no behavior change elsewhere. Refs: rollout I4 --- src/components/TaskSidebar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) From c9ef3c0f0a40e23d8d7c175a81ccded3b5e15ad7 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 15:31:52 +0000 Subject: [PATCH 11/12] Unwrap projects payload in cockpit project loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: rdm.project.list returns { projects: [...] }; the cockpit component assumed a bare array and always rendered 暂无可见研发项目, caught by I4 e2e scenario 1. Evidence: tsc/vitest/build green (SELF); browser e2e rerun recorded on the Dev I4 card. Impact: cockpit entry only. Refs: rollout I4 --- src/components/DocumentCockpit.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/DocumentCockpit.tsx b/src/components/DocumentCockpit.tsx index 1730f21..8f33889 100644 --- a/src/components/DocumentCockpit.tsx +++ b/src/components/DocumentCockpit.tsx @@ -64,8 +64,13 @@ export function DocumentCockpit() { useEffect(() => { let c = false setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('') - api.rdmCommand('rdm.project.list') - .then(list => { if (!c) setProjects(Array.isArray(list) ? list : []) }) + 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]) From b594d2748e7c2028010a849cc866857d85b7a3b6 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 15:39:11 +0000 Subject: [PATCH 12/12] Stabilize cockpit e2e login: settle identity refresh before entry click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: after login the store's identity epoch and the capability probe each settle once more; the app closes open workspaces on identity change, so clicking the sidebar entry too early raced the overlay close (intermittent 加载中 hang, caught across I4 e2e runs). Evidence: instrumented probe (rows=2 parsed then overlay closed on identity bump); settled-login rerun follows on the Dev I4 card. Impact: spec stability only. Refs: rollout I4 --- e2e/rdm-cockpit.playwright.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/e2e/rdm-cockpit.playwright.ts b/e2e/rdm-cockpit.playwright.ts index 5c2d8e5..ea7adec 100644 --- a/e2e/rdm-cockpit.playwright.ts +++ b/e2e/rdm-cockpit.playwright.ts @@ -22,9 +22,13 @@ async function login(page: Page, user: string, password: string) { } async function loginOwner(page: Page) { - await login(page, OWNER, PASSWORD) - await expect(page.locator('.tsb-tool', { hasText: '文档驾驶舱' })).toBeVisible({ timeout: 15_000 }) -} + 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()