Compare commits

...
Author SHA1 Message Date
weichen 1f366ecc8d Preserve legacy personal settings in scoped editors
Why: Plugin descriptors without an explicit scope remain personal-compatible in the backend, but the editor hid them.

Evidence: Frontend tests (290), production build, and check-knip pass; focused regression was red before the fix and green after.

Impact: Legacy plugin settings remain visible in user and project-personal editors while shared project and system editors stay scoped.

Refs: Dev feature/rdm-settings-completion P1-P2
2026-10-14 04:58:27 +00:00
weichen a5becea652 Show the selected project name in settings
Why: The project settings title should identify the current project while keeping its full context available to assistive technology.

Evidence: SELF /tmp/project-settings-frontend-title-context-green-20261014-973d52c1.json records 289 Frontend tests, typecheck, the unchanged knip baseline and build; /tmp/project-settings-visual-project-name-20261014-973d52c1.json records the updated browser view.

Impact: Keep the sidebar title concise and preserve the full project settings dialog label.

Refs: P7, Dev DESIGN.md section 12.4
2026-10-14 04:24:18 +00:00
weichen 0a052f445b Merge current frontend main into project settings work
Why: RDM now pins Frontend 5313cfa with scoped audit export controls that the settings branch must retain.

Evidence: Fetched origin/main at 5313cfad4e633d0909ed40dcdfa28a11bc1902c0; post-merge tests are recorded separately in the Dev task.

Impact: Preserve audit export and project settings changes in one reviewable Frontend branch.

Refs: P7, RDM feature/rdm-settings-completion
2026-10-14 03:40:38 +00:00
weichen 04a25f5fad Keep project permission details readable and show scoped errors
Why: A rejected command appeared as a raw receipt, and expanded permission details exposed internal action/source codes.

Evidence: SELF /tmp/project-settings-readable-rejection-green-20261014-973d52c1.json, /tmp/project-settings-frontend-final-check-20261014-973d52c1.json and /tmp/project-settings-frontend-browser-final-20261014-973d52c1.json record the regression, 285 unit tests, typecheck, unchanged knip baseline, build and 15 browser fixture flows.

Impact: Present the service's readable rejection reason while keeping permission details in user-facing terms. No authority is inferred in the client.

Refs: P6/P7, Dev docs/records/2026-10-14-rdm-settings-completion-contract.md
2026-10-14 03:33:08 +00:00
weichen 09c6ac41e0 Simplify the settled project settings implementation
Why: Keep the completed settings flows readable without adding unused API exports or duplicate helpers.

Evidence: SELF /tmp/project-settings-simplify-green-20261014-973d52c1.json records settings tests/vet, 284 Frontend tests, typecheck, the unchanged knip baseline and diff checks. Reuse and quality passes were independent read-only agents; efficiency was reviewed inline after the host rejected another review thread.

Impact: Preserve scope rules, write guards, DTOs, errors and ordering. Reuse Core ownership constants and path lookup; remove unused exports and keep permission selection in a typed local state.

Refs: P1/P7, docs/records/2026-10-14-rdm-settings-completion-contract.md
2026-10-14 02:51:51 +00:00
weichen 5313cfad4e Merge pull request 'Add export controls with scoped asynchronous state' (#73) from feature/rdm-audit-export-entry into main 2026-10-14 02:49:17 +00:00
weichen 4e1eb39035 Integrate export controls with current identity boundaries
Why: Complete the authorized export entry while preserving current main project and session behavior.

Evidence: SELF full frontend tests and build, 263 unit tests; deferred browser races reproduced then all 6 passed. Raw records stay outside source until Dev task closure.

Impact: Export callbacks are invalidated on project, identity and unmount changes. Command readiness and current main security paths remain intact.

Refs: Dev PR #488; Frontend PR #73; user takeover authorization on 2026-10-14
2026-10-14 02:38:58 +00:00
weichen 53ceadf51e Mirror the authoritative settings ownership types (P1)
Why: Frontend and UI must consume the same Core scope vocabulary.

Evidence: Generated by make contracts; SELF /tmp/project-settings-contract-types-20261014-973d52c1.json records both TypeScript checks.

Impact: Generated contract update only; the Core object is fixed by the Dev composition.

Refs: P1, docs/records/2026-10-14-rdm-settings-completion-contract.md
2026-10-14 02:33:03 +00:00
weichen 6106db250c Complete project member duty and permission settings flows
Why: Raw identifiers and mixed settings scopes made project administration difficult and hid authorization boundaries.

Evidence: SELF receipts /tmp/project-settings-frontend-full-green-20261014-973d52c1.json and /tmp/project-settings-browser-fixture-final-20261014-973d52c1.json record 284 tests, typecheck, build and 15 real React browser fixture flows. Live composition and formal acceptance remain NOT_RUN.

Impact: Add searchable member and document selectors, role filters, versioned duty changes, removal impact review and shared permission details/matrix. Consume server capabilities and settings scopes, preserve personal preferences and default restoration, and discard late responses.

Refs: P3/P4/P5/P6/P7, docs/records/2026-10-14-rdm-settings-completion-contract.md
2026-10-14 02:29:06 +00:00
weichen 2bc1e51456 Capture the missing settings surface contracts
Why: the approved role filters, document lookup, capabilities and readable labels are still absent.

Evidence: SELF red receipt /tmp/project-settings-frontend-red-20261014-973d52c1.json reproduced five missing behaviors before implementation.

Impact: regression tests only; this test-first checkpoint intentionally precedes implementation and is not a passing candidate.

Refs: approved settings increment P3-P7; Dev feature/rdm-settings-completion
2026-10-14 01:19:48 +00:00
weichen eb2d6d32f9 Merge pull request 'Use verified project identities for member workflows' (#82) from feature/project-member-access into main 2026-10-13 11:52:27 +00:00
weichen 40c2afe054 Merge the upstream member command rename
Why: Current main carries the same corrected lowercase command ID as this member candidate.

Evidence: Preserve the canonical wrapper and set-role ID; compare the final tree against the independently tested 2ba275b object.

Impact: Update ancestry only; no intended product behavior change.

Refs: Frontend PR83; project-member-access
2026-10-13 11:35:36 +00:00
weichen 42cda9c056 Merge pull request 'Rename member setRole command call to set-role' (#83) from fix/set-role-rename into main 2026-10-13 11:03:41 +00:00
weichen 15b962694b Merge origin/main 2026-10-13 11:03:27 +00:00
weichen 67585c1701 Rename member setRole command call to set-role
Why: RDM 命令 ID 契约改名(camelCase 违基座校验,RDM 仓同批修复);前端调用点同步。
Evidence: tsc 通过。
Impact: 单处命令 ID。
2026-10-13 10:50:36 +00:00
weichen 2ba275b991 Keep document duties tied to the current selection
Why:
Fresh documents had no first-submit action, reading active duties opened
confirmation dialogs, and delayed detail or status queries could overwrite
another selection and send its document ID with the wrong duty draft.

Evidence:
SELF real-browser regressions observed the missing action, render-time
dialogs, detail/status races, and a follow-up query after panel unmount.
Five focused Playwright scenarios passed after the fix, together with the
full npm test, npm run build, and git diff --check commands. Receipts:
/tmp/project-member-duties-browser-green-20261013.json and
/tmp/project-member-duties-full-build-green-20261013.json.

Impact:
Offer first submission independently of an existing review round. Ask for
invalidation only on a dirty save click. Guard queries and continuations
with project lifetime, selection, and request sequences; keep the existing
Picker, canonical project routing, controls, and UI tokens.

Refs:
Dev project-member-access Task 4; independent synchronized Frontend P2 review
2026-10-13 10:21:37 +00:00
weichen e7f9081a93 Merge current project picker and permission panels
Why: Current main supplies member pickers and duty catalogs that must keep verified project identity.

Evidence: Frontend 259 tests and build pass after resolving panel conflicts; final canonical set-role dispatch and fixed combo checks follow.

Impact: Preserve upstream panels with canonical project commands and read-versus-work state guards.

Refs: feature/project-member-access; origin/main c294835
2026-10-13 10:02:54 +00:00
weichen c294835f8d Merge pull request 'Rebuild duties and permission pages on catalog, matrix and tri-state' (#81) from feature/rdm-increment-duties-page into main 2026-10-13 09:56:49 +00:00
weichen 880eff74f8 Rebuild duties and permission pages on catalog, matrix and tri-state
Why: P7-B/C——文档职责页升级为「文档清单+选中职责详情」(duty.catalog/get:名称/类型/阶段/在途轮次,当前职责经三个项目成员 Picker 回填并与冻结名单分列;在途轮次失效确认;批量勾选+统一三权走 assign.doc.batch,整批拒绝列原因);权限页升级为「单成员详情+多人矩阵」双视图(中文标签、系统范围动作折叠为技术详情、矩阵单元格悬停解释、行展开三态覆盖——沿用默认/项目内允许/项目内禁止,恢复默认走 perm.unset 实际撤销)。
Evidence: tsc、design-guard 6/6、npm run build 通过;服务端语义由 RDM #346(标签/矩阵)与 #349(unset)测试覆盖;折叠使用 @ui Collapsible 原语。
Impact: RdmDutiesSection/RdmPermMatrixSection 重构;无跨页变更。
Refs: 提案 r1 §4/§5/P7;RDM #346/#349
2026-10-13 09:56:32 +00:00
weichen b7473fe585 Merge pull request 'Wire members section to Picker with explicit role changes' (#80) from feature/rdm-increment-members-picker into main 2026-10-13 09:35:29 +00:00
weichen dfcea5c988 Wire members section to Picker with explicit role changes
Why: P7-A——成员添加从手填账号升级为可搜索人员选择器(@ui Picker:搜索/浏览/键盘/分页),仅提交稳定账号;候选「已加入」标记杜绝隐式身份变更;成员表拆分「专业角色」与「项目身份」(中文标签)两列;身份调整走 rdm.project.member.setRole 显式命令(升/降按钮)。
Evidence: tsc 通过;design-guard 6/6;npm run build 成功;Picker 行为由 UI #53 的 7 用例覆盖。
Impact: RdmMembersSection 重构(member.add/setRole/remove 三命令消费);rdmManagement 增 setRole payload;account.list 适配层 query 透传+旧基座首页本地过滤降级。
Refs: 提案 r1 §3/P7;UI #53;RDM #345
2026-10-13 09:34:52 +00:00
weichen 07a03ac4f6 Preserve project history reads after archive or closure
Why:
Project readiness is a write gate. Applying it to member, permission, and
document status queries hides history that the backend intentionally keeps
readable after archival or closure.

Evidence:
SELF archived and closed read regressions failed before the fix. Full npm
test, npm run build, and git diff --check passed with the amended code.
Receipts: /tmp/project-member-frontend-archived-read-red-20261013.json and
/tmp/project-member-frontend-archived-read-green-20261013.json.

Impact:
Resolve canonical project IDs separately from command readiness. Preserve
known read commands and existing settings tabs for historical projects;
keep mutations and new work blocked with existing disabled controls.
Server authorization remains authoritative.

Refs:
Dev project-member-access Task 4; independent Frontend review P2
2026-10-13 08:41:12 +00:00
weichen d69009e081 Invalidate account continuations across awaited project work
Why:
An identity can change after an operation has passed its first response guard.
Later project loads, cleanup calls, and attachment refreshes must not reopen
private context or return the previous account's result to the new session.

Evidence:
SELF regressions failed before the guards: delayed project opening, task
creation success, completed-card cleanup, and upload artifact-ID return.
SELF npm test, npm run build, and git diff --check passed; full command
receipt: /tmp/project-member-frontend-final-checks-20261013.json.

Impact:
Guard post-await continuations with the same identity epoch. Preserve the
original server-side result and ignore stale client projections; do not undo
the previous account's already completed work.

Refs:
Dev feature-project-member-access implementation plan Task 4
2026-10-13 08:24:06 +00:00
weichen 55a64f40e8 Respect canonical project bindings and isolate account state
Why:
Project members need RDM commands to address the registered domain project.
Failed project states and responses from a previous account must not create
work, report a rejected mutation as successful, or restore private content.

Evidence:
SELF npm test: 32 files and 251 tests passed, including project command,
mutation rejection, archived history, and delayed account response coverage.
SELF npm run build and git diff --check passed. Original regressions were
observed before implementation; root owns composition and independent replay.

Impact:
Keep host IDs for conversation and base settings APIs; send the decorated
domain ID to RDM project commands. Reuse existing sidebar, settings and toast
states. Reset identity projections and invalidate old asynchronous responses.
No deployment, database change, push, merge, or workspace cleanup.

Refs:
Dev feature-project-member-access task card and implementation plan Task 4
2026-10-13 08:20:44 +00:00
weichen b3fa39744d Merge pull request 'Add admin system settings panel for managed model and service config' (#78) from feature/rdm-increment-system-settings into main 2026-10-13 06:40:58 +00:00
weichen 23f96fd921 Merge origin/main into feature/rdm-increment-system-settings 2026-10-13 06:40:37 +00:00
weichen f96a591123 Add admin system settings panel for managed model and service config
Why: P2 前端入口——系统受管项(模型/密钥/Tavily/遥测/运行基础设施)需要唯一的管理员写入面;系统管理新增「模型与服务配置」分区消费 GET/PUT /api/settings/system。
Evidence: vitest design-guard 6/6 通过;npm run build 成功;面板仅渲染 scope=system 项,保存后重 GET 回填,403 如实提示。
Impact: contracts 加 SettingDesc.scope;api 加 get/putSystemSettings;AdminZone 注册新分区;无既有面板变更。
Refs: modules/documents/docs/proposals/2026-10-13-rdm-settings-members-duties-perms-increment.md P2;Backend PR 188
2026-10-13 05:59:57 +00:00
weichen 698fffd929 Merge pull request 'Add login-key browser e2e and harden changepw gate sync' (#77) from feature/login-key-e2e into main 2026-10-13 04:57:57 +00:00
weichen 44b964682c Add login-key browser e2e and harden changepw gate sync
Why: The login recovery contract requires a real-browser acceptance
spec covering admin issue -> key-only redeem -> forced set-password ->
relogin, and the changepw spec needed the same deterministic gate
synchronization.

Evidence: Both specs pass against an isolated auth-on instance
(serve --auth on, binary backend=467718a+frontend=9fc7da2); receipts
be7ae9a2/b3a9e691 bound to Dev HEAD 268ef29 (SELF). changepw-form also
passes (5.9s) alongside login-key (12.2s) on a reused instance,
proving idempotency.

Impact: New e2e/login-key.playwright.ts (skip-guarded like existing
suites); changepw login() waits for the gate heading and success page
before returning to login, fixing a stale-fill race that submitted
empty gate fields.

Refs: Dev docs/svc-deploy/2026-10-13-login-recovery-contract.md; Dev PR #504.
2026-10-13 04:33:35 +00:00
weichen 0655bc37e1 Merge pull request 'Wire login recovery flow: redeem key entry and restricted set-password gate' (#76) from feature/svc-login-recovery-flow into main 2026-10-13 04:04:00 +00:00
weichen 9fc7da2ace Wire login recovery flow: redeem key entry and restricted set-password gate
Why: The 2026-10-13 login contract makes username/password the default
login, replaces the browser ask_ tab with a one-time recovery key
entry, requires the forced set-password gate to drop the current
password field, and routes admin password recovery through admin-issued
login keys instead of direct password resets.

Evidence: vitest 218 passed, npm run build OK, check-knip 43<=43,
design-guard green (SELF, local run in task worktree).

Impact: LoginPage gains a redeem-only key tab (no key persistence);
ChangePasswordGate shows server-confirmed owner plus new/confirm only
and returns to the login page after success (reloginRequired); App
gates on sessionScope=set_password_only; UsersPanel replaces reset
password with issue/revoke login key UI showing plaintext once.

Refs: Dev task feature/svc-login-recovery-flow; backend companion
commit 467718a.
2026-10-13 03:42:23 +00:00
weichenandCursor d9bb780384 Add an audit overview export that stays outside the readiness gate
Why: auditors need to generate and download a watermarked report from the overview, and that must not appear when the export commands are off or change whether the workspace itself opens.
Evidence: SELF npm test, 138 passed. Playwright download case is in e2e/rdm-audit.playwright.ts and runs under scripts/e2e.sh.
Impact: the overview shows type, generate, draft confirmation, watermark, sha256, expiry, and download only when the three export commands are enabled.
Refs: RDM pull request 301
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 15:38:00 +00:00
39 changed files with 3240 additions and 726 deletions
+11 -4
View File
@@ -17,14 +17,21 @@ async function login(page: Page) {
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
const gate = page.getByText('修改密码')
// 受限设密门(2026-10-13 合同):heading 唯一定位(标题与按钮同名),
// 门挂载后登录卡已卸载,fill 不再与登录卡竞态。
const gate = page.getByRole('heading', { name: '设置新密码' })
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(0).fill(PASSWORD2)
await pwInputs.nth(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
// 设密成功后服务端销毁会话:等成功页再回登录(在途点取消会被会话复验拒绝)。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
} else {
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
+112
View File
@@ -0,0 +1,112 @@
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
// 新密码登录进工作台;重复兑换同一 Key 被拒。
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
import { test, expect, type Page } from '@playwright/test'
const ADMIN = process.env.E2E_ADMIN || 'e2e-admin'
const ADMIN_BOOT = process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin#2026'
const ADMIN_PW = process.env.E2E_ADMIN_PASSWORD2 || 'E2eAdmin#2026b'
const USER = process.env.E2E_LOGIN_KEY_USER || 'e2e-user'
const USER_PW = process.env.E2E_LOGIN_KEY_PASSWORD2 || 'E2eUser#2026b'
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (auth-on instance required)')
async function passwordLogin(page: Page, owner: string, password: string) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(owner)
await page.getByPlaceholder('初始密码或常用密码').fill(password)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
/** 管理员登录;初始密码(must-change)则先完成受限设密再以新密码重登。
* 初始密码单次有效:已跑过本用例的环境(boot 已被改掉)自动改用新密码。 */
async function adminLogin(page: Page) {
await passwordLogin(page, ADMIN, ADMIN_BOOT)
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位,
// getByText 双命中会触发 strict-mode 抛错被 catch 吞掉、误判 gated=false。
let gate = page.getByRole('heading', { name: '设置新密码' })
let gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (!gated) {
// boot 密码失败(此前运行已完成改密):换正式密码重登。
const loginErr = await page.locator('.lg-err').waitFor({ state: 'visible', timeout: 3_000 }).then(() => true).catch(() => false)
if (!loginErr) throw new Error('admin login neither gated nor errored')
await passwordLogin(page, ADMIN, ADMIN_PW)
gate = page.getByRole('heading', { name: '设置新密码' })
gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
}
if (gated) {
const pw = page.locator('.lg-card input[type="password"]')
await pw.nth(0).fill(ADMIN_PW)
await pw.nth(1).fill(ADMIN_PW)
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
// 等设密事务完成再回登录:提交在途时点取消会登出受限会话、
// 服务端按会话复验拒绝该次设密(产品正确行为,测试必须同步成功页)。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await passwordLogin(page, ADMIN, ADMIN_PW)
}
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
}
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
test.setTimeout(120_000)
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
await adminLogin(page)
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
const zone = page.getByRole('dialog', { name: '系统管理' })
await expect(zone).toBeVisible()
// 每个用户行都有同款按钮:限定目标用户所在行。
await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click()
await zone.getByRole('button', { name: '生成 Key', exact: true }).click()
const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]')
await expect(keyInput).toBeVisible({ timeout: 10_000 })
const key = await keyInput.inputValue()
expect(key).toMatch(/^alk_/)
await expect(zone.getByText(/单次有效/)).toBeVisible()
// 2) 管理员退出系统管理与登录态,回到登录页。
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
await zone.getByRole('button', { name: '返回工作台' }).click()
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
// 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
await page.getByPlaceholder('alk_…').fill(key)
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
// 4) 受限设密门:服务端确认用户名;无当前密码输入框。
const gate = page.locator('.lg-card')
await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 })
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
const pw = gate.locator('input[type="password"]')
await pw.nth(0).fill(USER_PW)
await pw.nth(1).fill(USER_PW)
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
// 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。
await passwordLogin(page, USER, USER_PW)
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
await page.getByPlaceholder('alk_…').fill(key)
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 })
})
+20
View File
@@ -123,4 +123,24 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
)
})
test('概览导出:生成后显示水位与校验和,并可下载', async ({ page }) => {
await login(page)
await openAudit(page)
const exportBox = page.locator('.rdm-audit-export')
await expect(exportBox).toBeVisible()
await exportBox.getByLabel('导出类型').selectOption('cgraph-docx')
await exportBox.getByRole('button', { name: '生成', exact: true }).click()
const draft = exportBox.getByRole('button', { name: '生成草稿' })
if (await draft.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true).catch(() => false)) {
await draft.click()
}
await expect(exportBox.getByText(/水位 \d+/)).toBeVisible({ timeout: 15_000 })
await expect(exportBox.getByText(/sha256 [0-9a-f]{64}/)).toBeVisible()
await expect(exportBox.getByText(/有效期/)).toBeVisible()
const download = page.waitForEvent('download')
await exportBox.getByRole('button', { name: '下载' }).click()
const file = await download
expect(file.suggestedFilename()).toMatch(/\.docx$/)
})
})
+272
View File
@@ -0,0 +1,272 @@
// Real React/Picker interaction with deterministic HTTP responses; no live account or database.
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(ok => { resolve = ok })
return { promise, resolve }
}
async function dutiesPage(page: Page, options: { canAssign?: boolean; missingVersion?: boolean } = {}) {
const writes: { command: string; params: Record<string, unknown> }[] = []
const calls: { command: string; params: Record<string, unknown> }[] = []
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
const active = new Set<string>()
const submissions: Record<string, unknown> = { 'doc-A': null, 'doc-B': null }
const canAssign = options.canAssign !== false
const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove',
'rdm.project.member.set-role', 'rdm.project.member.impact', 'rdm.account.list', 'rdm.role.options',
'rdm.duty.catalog', 'rdm.duty.tree', 'rdm.duty.get', 'rdm.review.assign', 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch',
'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final', 'rdm.review.doc.status']
const docs = [
{ docId: 'doc-A', title: '文档A', typeKey: 'design', typeLabel: '设计方案', sections: 1, dutyVersion: 3, assigned: true, author: 'alice', reviewers: ['carol'], approver: 'dave' },
{ docId: 'doc-B', title: '文档B', typeKey: 'design', typeLabel: '设计方案', sections: 1, dutyVersion: 7, assigned: true, author: 'bob', reviewers: ['carol'], approver: 'dave' },
]
const members = ['alice', 'bob', 'carol', 'dave'].map(owner => ({ owner, role: owner === 'alice' ? 'owner' : 'member', addedBy: '', addedAt: '', valid: true,
profRole: { roleId: owner === 'bob' ? 'systems' : 'software', name: owner === 'bob' ? '系统工程师' : '软件工程师' },
dutyEligibility: { author: { allowed: true }, reviewer: { allowed: true }, approver: { allowed: true } } }))
const filteredDocs = (params: Record<string, unknown>) => docs.filter(document => !params.query || (params.match === 'exact'
? document.docId === params.query || document.title === params.query : `${document.docId} ${document.title}`.includes(String(params.query))))
await page.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
let body: unknown = []
if (path === '/api/health') body = { ok: true }
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: false }
else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} }
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
else if (path.startsWith('/api/commands/')) {
const command = decodeURIComponent(path.substring('/api/commands/'.length))
const params = request.postDataJSON().params as Record<string, unknown>
expect(params.projectId).toBe('domain-7')
calls.push({ command, params })
const hold = holds.get(`${command}:${params.docId ?? ''}`)
if (hold) { hold.started.resolve(); await hold.release.promise }
let payload: unknown = {}
if (command === 'rdm.duty.catalog') payload = { docs: filteredDocs(params), next: '', count: filteredDocs(params).length, total: docs.length, canAssignDuties: canAssign,
filters: { stages: [], typeKeys: [{ value: 'design', label: '设计方案' }], assignments: [{ value: 'assigned', label: '已分配' }, { value: 'unassigned', label: '未分配' }], statuses: [] } }
else if (command === 'rdm.duty.tree') payload = { nodes: filteredDocs(params).map(document => ({ id: document.docId, kind: 'doc', label: document.title, selectable: true, docId: document.docId,
children: [{ id: document.docId + '-section', kind: 'section', label: '方案章节', selectable: false, children: [] }] })), next: '', canAssignDuties: canAssign }
else if (command === 'rdm.role.options') payload = { roles: [{ roleId: 'systems', name: '系统工程师' }, { roleId: 'software', name: '软件工程师' }] }
else if (command === 'rdm.project.member.list') payload = { members: members.filter(person => (!params.query || person.owner.includes(String(params.query))) && (!params.roleId || person.profRole.roleId === params.roleId)),
next: '', accountDirectoryAvailable: true, canManageMembers: true, canAssignDuties: canAssign, canConfigurePermissions: false }
else if (command === 'rdm.duty.get') {
const doc = docs.find(d => d.docId === params.docId)!
payload = { docId: doc.docId, title: doc.title, assigned: doc.assigned,
roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [], canAssignDuties: canAssign,
...(options.missingVersion ? {} : { dutyVersion: doc.dutyVersion }),
active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice', duties: { Author: doc.author, Reviewers: doc.reviewers, Approver: doc.approver } } : null }
} else if (command === 'rdm.review.doc.status') payload = { submission: submissions[String(params.docId)] }
else {
writes.push({ command, params })
if (command === 'rdm.review.doc.submit') {
const document = docs.find(document => document.docId === params.docId)!
active.add(document.docId)
submissions[document.docId] = { round: 1, state: 'submitted', createdBy: 'alice',
duties: { Author: document.author, Reviewers: document.reviewers, Approver: document.approver }, reviews: {} }
}
if (command === 'rdm.review.assign.doc' || command === 'rdm.review.assign.doc.batch') {
const entries = command === 'rdm.review.assign.doc' ? [params] : params.docs as Record<string, unknown>[]
for (const entry of entries) {
const document = docs.find(document => document.docId === entry.docId)!
expect(entry.expectedDutyVersion).toBe(document.dutyVersion)
const changed = entry.author !== document.author || entry.approver !== document.approver
|| JSON.stringify(entry.reviewers) !== JSON.stringify(document.reviewers)
if (changed && active.has(document.docId)) expect(entry.confirmInvalidation || params.confirmInvalidation).toBe(true)
if (changed) {
document.author = String(entry.author); document.reviewers = entry.reviewers as string[]; document.approver = String(entry.approver); document.dutyVersion++
active.delete(document.docId)
}
}
}
}
body = { payload }
} else if (path.endsWith('/cards')) body = { cards: [] }
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/')
await page.getByRole('button', { name: '项目设置', exact: true }).click()
await page.getByRole('tab', { name: /文档职责/ }).click()
await expect(page.getByRole('button', { name: '文档A', exact: true })).toBeVisible()
return { writes, calls, active, submissions,
hold(command: string, docId: string) {
const pending = { started: deferred(), release: deferred() }
holds.set(`${command}:${docId}`, pending)
return pending
} }
}
async function chooseMember(page: Page, label: string, owner: string) {
await page.locator(`.picker[aria-label="${label}"]`).getByRole('combobox').fill(owner)
await page.getByRole('option', { name: new RegExp(`^${owner}`) }).click()
}
async function settleResponse(page: Page, command: string, release: () => void) {
const response = page.waitForResponse(r => r.url().endsWith(`/api/commands/${command}`))
release()
await response
await page.evaluate(() => new Promise<void>(done => requestAnimationFrame(() => requestAnimationFrame(() => done()))))
}
test('a newly assigned document can submit its first whole-document review', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByRole('button', { name: '文档A', exact: true }).click()
await expect(page.getByText('尚无整文提交记录', { exact: true })).toBeVisible()
const submit = page.getByRole('button', { name: '整文提交(编制人)', exact: true })
await expect(submit).toBeEnabled()
await submit.click()
await expect(page.getByText('整文已提交审核', { exact: true })).toBeVisible()
await expect(submit).toBeDisabled()
await expect(page.getByRole('heading', { name: '整文审签 · 第 1 轮 · 审核中', exact: true })).toBeVisible()
expect(fixture.writes).toEqual([{ command: 'rdm.review.doc.submit', params: { projectId: 'domain-7', docId: 'doc-A' } }])
})
test('viewing active duties is passive; only a dirty save asks for confirmation', async ({ page }) => {
const fixture = await dutiesPage(page)
fixture.active.add('doc-A')
const dialogs: string[] = []
let accept = false
page.on('dialog', async dialog => { dialogs.push(dialog.message()); accept ? await dialog.accept() : await dialog.dismiss() })
await page.getByRole('button', { name: '文档A', exact: true }).click()
await expect(page.getByRole('heading', { name: /文档A · 当前职责/ })).toBeVisible()
const save = page.getByRole('button', { name: '保存当前职责', exact: true })
await expect(save).toBeDisabled()
expect(dialogs).toEqual([])
await chooseMember(page, '编制人', 'bob')
await expect(save).toBeEnabled()
expect(dialogs).toEqual([])
await save.click()
expect(dialogs).toHaveLength(1)
expect(fixture.writes).toEqual([])
accept = true
await save.click()
await expect(page.getByText('职责已保存,已重新读取当前状态', { exact: true })).toBeVisible()
expect(dialogs).toHaveLength(2)
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: {
projectId: 'domain-7', docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave', expectedDutyVersion: 3, confirmInvalidation: true,
} }])
})
test('a late document detail cannot overwrite another selection or its saved roles', async ({ page }) => {
const fixture = await dutiesPage(page)
const old = fixture.hold('rdm.duty.get', 'doc-A')
await page.getByRole('button', { name: '文档A', exact: true }).click()
await old.started.promise
await page.getByRole('button', { name: '文档B', exact: true }).click()
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
await settleResponse(page, 'rdm.duty.get', old.release.resolve)
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([])
await chooseMember(page, '批准人', 'alice')
await page.getByRole('button', { name: '保存当前职责', exact: true }).click()
await expect(page.getByText('职责已保存,已重新读取当前状态', { exact: true })).toBeVisible()
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: {
projectId: 'domain-7', docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'alice', expectedDutyVersion: 7, confirmInvalidation: false,
} }])
})
test('document ID and title searches share list and tree selection without making chapters selectable', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByLabel('文档匹配方式', { exact: true }).selectOption('exact')
await page.getByRole('textbox', { name: '搜索文档', exact: true }).fill('doc-B')
await expect(page.getByRole('button', { name: '文档A', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '文档B', exact: true })).toBeVisible()
await page.getByLabel('文档浏览方式', { exact: true }).selectOption('tree')
await page.getByRole('button', { name: '文档B', exact: true }).click()
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
await page.getByRole('button', { name: '展开 文档B', exact: true }).click()
await expect(page.getByText('方案章节 · 章节', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '方案章节', exact: true })).toHaveCount(0)
expect(fixture.writes).toEqual([])
expect(fixture.calls.some(call => call.command === 'rdm.duty.tree' && call.params.query === 'doc-B' && call.params.match === 'exact')).toBe(true)
})
test('professional role filtering keeps duty candidates within project membership and selected labels stay visible', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByRole('button', { name: '文档A', exact: true }).click()
await page.getByLabel('专业角色筛选', { exact: true }).selectOption('systems')
await page.locator('.picker[aria-label="编制人"]').getByRole('combobox').focus()
await expect(page.getByRole('option', { name: /^bob/ })).toBeVisible()
await expect(page.getByRole('option', { name: /^carol/ })).toHaveCount(0)
await page.getByRole('option', { name: /^bob/ }).click()
await expect(page.locator('.picker[aria-label="编制人"] .picker-selected')).toContainText('bob')
expect(fixture.calls.some(call => call.command === 'rdm.project.member.list' && call.params.roleId === 'systems')).toBe(true)
expect(fixture.calls.some(call => call.command === 'rdm.account.list')).toBe(false)
})
test('missing version or caller assignment capability keeps duty editors read only', async ({ page }) => {
const fixture = await dutiesPage(page, { canAssign: false, missingVersion: true })
await page.getByRole('button', { name: '文档A', exact: true }).click()
await expect(page.getByRole('button', { name: '保存当前职责', exact: true })).toBeDisabled()
await expect(page.locator('.picker[aria-label="编制人"]').getByRole('combobox')).toBeDisabled()
await expect(page.getByText('职责版本尚未就绪,只能查看;请升级项目服务后重新读取', { exact: true })).toBeVisible()
expect(fixture.writes).toEqual([])
})
test('batch preview carries each document version and explicitly confirms active review invalidation', async ({ page }) => {
const fixture = await dutiesPage(page)
fixture.active.add('doc-A')
const prompts: string[] = []
page.on('dialog', async dialog => { prompts.push(dialog.message()); await dialog.accept() })
await page.getByRole('checkbox', { name: '选择 文档A', exact: true }).check()
await page.getByRole('checkbox', { name: '选择 文档B', exact: true }).check()
await chooseMember(page, '批量编制人', 'bob')
await chooseMember(page, '批量审核人', 'carol')
await chooseMember(page, '批量批准人', 'dave')
await page.getByRole('button', { name: '预览并分配 2 份文档', exact: true }).click()
await expect(page.getByText('批量职责已保存,已重新读取目录', { exact: true })).toBeVisible()
expect(prompts).toHaveLength(1)
expect(prompts[0]).toContain('第 1 轮将失效')
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc.batch', params: { projectId: 'domain-7', confirmInvalidation: true,
docs: [
{ docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave', expectedDutyVersion: 3, confirmInvalidation: true },
{ docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'dave', expectedDutyVersion: 7, confirmInvalidation: true },
] } }])
})
test('a batch keeping existing duties does not claim its active review will be invalidated', async ({ page }) => {
const fixture = await dutiesPage(page)
fixture.active.add('doc-A')
const prompts: string[] = []
page.on('dialog', async dialog => { prompts.push(dialog.message()); await dialog.accept() })
await page.getByRole('checkbox', { name: '选择 文档A', exact: true }).check()
await chooseMember(page, '批量编制人', 'alice')
await chooseMember(page, '批量审核人', 'carol')
await chooseMember(page, '批量批准人', 'dave')
await page.getByRole('button', { name: '预览并分配 1 份文档', exact: true }).click()
await expect(page.getByText('批量职责已保存,已重新读取目录', { exact: true })).toBeVisible()
expect(prompts[0]).not.toContain('失效')
expect(fixture.writes[0].params.confirmInvalidation).toBe(false)
expect(fixture.active.has('doc-A')).toBe(true)
})
test('a late review status cannot replace the selected document frozen review', async ({ page }) => {
const fixture = await dutiesPage(page)
const submission = (round: number, author: string) => ({ round, state: 'submitted', digest: '', createdBy: author,
createdAt: '', duties: { author, reviewers: ['carol'], approver: 'dave' }, reviews: {}, sections: {} })
fixture.submissions['doc-A'] = submission(1, 'alice')
fixture.submissions['doc-B'] = submission(2, 'bob')
const old = fixture.hold('rdm.review.doc.status', 'doc-A')
await page.getByRole('button', { name: '文档A', exact: true }).click()
await old.started.promise
await page.getByRole('button', { name: '文档B', exact: true }).click()
await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible()
await settleResponse(page, 'rdm.review.doc.status', old.release.resolve)
await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible()
await expect(page.getByRole('heading', { name: /整文审签 · 第 1 轮/ })).toHaveCount(0)
})
test('closing the project panel invalidates a pending detail before its follow-up status read', async ({ page }) => {
const fixture = await dutiesPage(page)
const old = fixture.hold('rdm.duty.get', 'doc-A')
await page.getByRole('button', { name: '文档A', exact: true }).click()
await old.started.promise
await page.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click()
await expect(page.getByRole('tab', { name: /文档职责/ })).toHaveCount(0)
await settleResponse(page, 'rdm.duty.get', old.release.resolve)
expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([])
})
+107
View File
@@ -0,0 +1,107 @@
// Isolated browser component regression: actual React workspace, deliberately deferred API responses.
// This checks client request ownership, not server authorization or business acceptance.
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_COMPONENT_BASE_URL, 'requires the isolated Vite component test server')
test.use({ baseURL: process.env.PLAYWRIGHT_COMPONENT_BASE_URL })
async function mount(page: Page, delayed: string) {
const index = await page.request.get('/')
const html = (await index.text()).replace(/<script[^>]+src="\/src\/main\.tsx[^" ]*"[^>]*><\/script>/, '')
// Let Vite prepare ReactDOM before requesting its optimized module directly.
await page.request.get('/src/main.tsx')
await page.route('**/__export_test', route => route.fulfill({ contentType: 'text/html',
body: html }))
await page.goto('/__export_test')
await page.waitForFunction(() => typeof (window as any).$RefreshReg$ === 'function')
await page.evaluate(async delayed => {
const load = (path: string) => import(/* @vite-ignore */ path)
const { default: React } = await load('/node_modules/.vite/deps/react.js')
const { default: ReactDOM } = await load('/node_modules/.vite/deps/react-dom_client.js')
const { RdmAuditWorkspace } = await load('/src/components/RdmAuditWorkspace.tsx')
const { api } = await load('/src/api.ts')
const { auth } = await load('/src/auth.ts')
const { store } = await load('/src/store.ts')
const { RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } = await load('/src/workspaces.ts')
const calls: { command: string; params: any }[] = []
let actor = 'alice'
const pending: (() => void)[] = []
let downloads = 0
URL.createObjectURL = () => { downloads++; return 'blob:export-test' }
URL.revokeObjectURL = () => {}
HTMLAnchorElement.prototype.click = () => {}
api.listPlugins = async () => []
api.listWorkspaces = async () => []
api.listCommands = async () => [...RDM_AUDIT_COMMANDS, ...RDM_EXPORT_COMMANDS].map(id => ({ id, enabled: true }))
api.adminZoneSections = async () => []
api.getSettings = async () => ({ user: {} })
api.rdmCommand = async (command: string, params: any = {}) => {
calls.push({ command, params })
const result = command === 'rdm.inspect.capabilities'
? { actor, crossProject: false, projects: ['A', 'B'].map(projectId => ({ projectId, name: actor + projectId, canAudit: true })) }
: command === 'rdm.inspect.summary'
? { projectId: params.projectId, counts: {}, watermark: 7 }
: command === 'rdm.inspect.export.create'
? { exportId: params.projectId + '-report', watermark: 7, sha256: 'abc', fileName: 'report.docx' }
: command === 'rdm.inspect.export.status'
? { expiresAt: '2099-01-01', watermark: 7, sha256: 'abc' }
: { fileName: 'report.docx', mime: 'application/octet-stream', dataB64: 'QQ==', sha256: 'abc' }
if (command === delayed && params.projectId === 'A') {
await new Promise<void>(resolve => pending.push(resolve))
}
return result
}
await store.refreshPlugins()
const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(React.createElement(RdmAuditWorkspace))
;(window as any).exportTest = { calls, release: () => pending.splice(0).forEach(resolve => resolve()),
downloads: () => downloads, identity: async () => { actor = 'bob'; auth.beginSession(); await store.refreshPlugins() },
unmount: () => root.unmount() }
}, delayed)
await expect(page.locator('.rdm-audit-export')).toBeVisible()
}
async function release(page: Page) {
await page.evaluate(async () => { (window as any).exportTest.release(); await new Promise(resolve => setTimeout(resolve, 0)) })
}
const generate = (page: Page) => page.getByRole('button', { name: '生成', exact: true }).click()
for (const stage of ['create', 'status']) {
test(`ignores old ${stage} response after project A -> B -> A`, async ({ page }) => {
await mount(page, 'rdm.inspect.export.' + stage)
await generate(page)
await expect.poll(() => page.evaluate(stage => (window as any).exportTest.calls
.some((c: any) => c.command === 'rdm.inspect.export.' + stage), stage)).toBe(true)
await page.getByLabel('选择项目').selectOption('B')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
await page.getByLabel('选择项目').selectOption('A')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
await release(page)
await expect(page.getByRole('button', { name: '下载', exact: true })).toHaveCount(0)
if (stage === 'create') expect(await page.evaluate(() => (window as any).exportTest.calls
.filter((c: any) => c.command === 'rdm.inspect.export.status').length)).toBe(0)
await expect(page.getByRole('button', { name: '生成', exact: true })).toBeEnabled()
})
}
for (const change of ['project', 'identity', 'unmount']) {
test(`suppresses delayed download after ${change} change`, async ({ page }) => {
await mount(page, 'rdm.inspect.export.download')
await generate(page)
await page.getByRole('button', { name: '下载', exact: true }).click()
if (change === 'project') {
await page.getByLabel('选择项目').selectOption('B')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
} else if (change === 'identity') {
await page.evaluate(() => (window as any).exportTest.identity())
await expect(page.getByLabel('选择项目').locator('option').first()).toHaveText('bobA')
} else await page.evaluate(() => (window as any).exportTest.unmount())
await release(page)
expect(await page.evaluate(() => (window as any).exportTest.downloads())).toBe(0)
})
}
test('current identity can generate and download normally', async ({ page }) => {
await mount(page, '')
await generate(page)
await page.getByRole('button', { name: '下载', exact: true }).click()
await expect.poll(() => page.evaluate(() => (window as any).exportTest.downloads())).toBe(1)
await expect(page.locator('.rdm-audit-export-result')).toContainText('水位 7')
})
+155
View File
@@ -0,0 +1,155 @@
// Deterministic HTTP contract QA of the real React settings surfaces. Live domain QA is separate.
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
async function settingsPage(page: Page, canManage = true) {
const calls: { command: string; params: Record<string, unknown> }[] = []
const writes: { command: string; params: Record<string, unknown> }[] = []
const settingWrites: { path: string; body: unknown }[] = []
const people = [
{ owner: 'alice', name: '项目负责人', valid: true, joined: true, role: 'owner', projectRole: 'owner', profRole: { roleId: 'systems', name: '系统工程师' } },
{ owner: 'bob', name: '李工', valid: true, joined: false, role: 'member', projectRole: 'member', profRole: { roleId: 'systems', name: '系统工程师' } },
{ owner: 'carol', name: '陈工', valid: true, joined: true, role: 'member', projectRole: 'member', profRole: { roleId: 'software', name: '软件工程师' } },
{ owner: 'unknown', name: '待核验', joined: false, profRole: null },
]
const schema = [
{ key: 'deliverables.style', group: 'deliverables', label: '项目交付风格', type: 'enum', options: ['modern', 'formal', 'minimal'], default: 'modern', effect: 'new-run', scope: 'project' },
{ key: 'models.chat.model', group: 'models', label: '主模型', type: 'string', effect: 'new-run', scope: 'system' },
]
let projectRevision = 4
let projectValues: Record<string, unknown> = { deliverables: { style: 'formal' } }
const overrides = new Map<string, boolean | null>([['alice:doc.read', false], ['alice:doc.write', true]])
const permissions = (member: string) => [
{ action: 'project.create', label: '创建项目', scope: 'project', allowed: true, defaultAllowed: true, capAllowed: true, override: null, source: 'default' },
{ action: 'doc.read', label: '读取文档', scope: 'project', allowed: overrides.get(member + ':doc.read') ?? true,
defaultAllowed: true, capAllowed: true, override: overrides.get(member + ':doc.read') ?? null, source: overrides.has(member + ':doc.read') ? 'override' : 'prof-role-default' },
{ action: 'doc.write', label: '编制与编辑文档', scope: 'project', allowed: false, defaultAllowed: true, capAllowed: false, override: overrides.get(member + ':doc.write') ?? null, source: 'cap', reason: '管理员上限禁止' },
]
const matches = (person: typeof people[number], params: Record<string, unknown>) => {
const query = String(params.query || '')
return (!params.roleId || person.profRole?.roleId === params.roleId) && (!params.projectRole || person.projectRole === params.projectRole)
&& (!query || (params.match === 'exact' ? person.owner === query || person.name === query : `${person.owner} ${person.name}`.includes(query)))
}
const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.set-role', 'rdm.project.member.impact',
'rdm.account.list', 'rdm.role.options', 'rdm.perm.check', 'rdm.perm.matrix', 'rdm.perm.set', 'rdm.perm.unset']
await page.route('**/api/**', async route => {
const request = route.request()
const url = new URL(request.url())
const path = url.pathname
let body: unknown = []
if (path === '/api/me') body = { owner: 'alice', name: '项目负责人', auth: true, isAdmin: false }
else if (path === '/api/health') body = { ok: true }
else if (path === '/api/projects') body = [{ id: 'host-1', name: '项目配置验收', createdAt: '', updatedAt: '', settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
else if (path === '/api/settings') body = url.searchParams.has('project') ? { schema, defaults: {}, user: {}, project: projectValues, projectRevision, projectCanConfigure: canManage,
personalSchema: [{ key: 'crossSession.read', group: 'crossSession', label: '跨会话读取', type: 'boolean', default: false, effect: 'new-run', scope: 'project-personal' }], personalDefaults: {}, personalUser: {}, personalProject: {} }
: { schema: [{ key: 'ui.instructions', group: 'ui', label: '个人偏好', type: 'string', effect: 'new-run', scope: 'user' }], defaults: {}, user: {}, project: {} }
else if (path.startsWith('/api/settings/project/') && request.method() === 'PUT') {
const patch = request.postDataJSON()
settingWrites.push({ path, body: patch })
expect(patch.expectedRevision).toBe(projectRevision)
if (patch.values.deliverables) projectValues = patch.values
if (patch.unset?.includes('deliverables.style')) projectValues = {}
projectRevision++
body = { ok: true, projectRevision }
} else if (path.startsWith('/api/commands/')) {
const command = path.substring('/api/commands/'.length)
const params = request.postDataJSON().params as Record<string, unknown>
expect(params.projectId).toBe('domain-7')
expect(params).not.toHaveProperty('actor')
calls.push({ command, params })
let payload: unknown = {}
if (command === 'rdm.role.options') payload = { roles: [{ roleId: 'systems', name: '系统工程师' }, { roleId: 'software', name: '软件工程师' }] }
else if (command === 'rdm.account.list') payload = { accounts: people.filter(person => matches(person, params)), next: '' }
else if (command === 'rdm.project.member.list') payload = { members: people.filter(person => person.joined && matches(person, params)), next: '',
accountDirectoryAvailable: true, canManageMembers: canManage, canAssignDuties: false, canConfigurePermissions: canManage }
else if (command === 'rdm.project.member.impact') payload = { member: params.member, canRemove: params.member !== 'carol', lastOwner: false,
documents: params.member === 'carol' ? [{ docId: 'private-doc', label: '受控文档', sectionId: 'chapter-1', activeRound: 2 }] : [], reasons: params.member === 'carol' ? ['请先交接文档职责'] : [] }
else if (command === 'rdm.perm.check') {
const person = people.find(person => person.owner === params.member)!
payload = { projectId: 'domain-7', member: person.owner, name: person.name, role: person.role, profRole: person.profRole, actions: permissions(person.owner), canConfigurePermissions: canManage }
} else if (command === 'rdm.perm.matrix') payload = { projectId: 'domain-7', canConfigurePermissions: canManage,
rows: people.filter(person => person.joined && matches(person, params)).map(person => ({ member: person.owner, name: person.name, role: person.role, profRole: person.profRole, actions: permissions(person.owner) })), next: '' }
else {
writes.push({ command, params })
if (command === 'rdm.project.member.add') { const person = people.find(person => person.owner === params.member)!; person.joined = true; person.role = String(params.role); person.projectRole = person.role }
if (command === 'rdm.project.member.set-role') { const person = people.find(person => person.owner === params.member)!; person.role = String(params.role); person.projectRole = person.role }
if (command === 'rdm.perm.unset') overrides.delete(`${params.member}:${params.action}`)
if (command === 'rdm.perm.set') overrides.set(`${params.member}:${params.action}`, params.allow === true)
}
body = { payload }
} else if (path.endsWith('/cards')) body = { cards: [] }
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/')
await page.getByRole('button', { name: '项目设置', exact: true }).click()
return { calls, writes, settingWrites }
}
test('shared settings exclude system controls and restore inheritance with an explicit versioned removal', async ({ page }) => {
const fixture = await settingsPage(page)
await expect(page.getByLabel('项目交付风格', { exact: true })).toHaveValue('formal')
await expect(page.getByLabel('主模型', { exact: true })).toHaveCount(0)
await page.getByLabel('项目交付风格', { exact: true }).selectOption('')
await page.getByRole('button', { name: '保存项目设置', exact: true }).click()
await expect(page.getByText('已保存共享项目配置;按各项标注的生效时机应用', { exact: true })).toBeVisible()
expect(fixture.settingWrites).toEqual([{ path: '/api/settings/project/host-1', body: { values: {}, expectedRevision: 4, unset: ['deliverables.style'] } }])
})
test('members can be found by name and professional role; joined accounts cannot silently change identity', async ({ page }) => {
const fixture = await settingsPage(page)
await page.getByRole('tab', { name: '项目成员', exact: true }).click()
await page.getByLabel('专业角色筛选', { exact: true }).selectOption('systems')
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').fill('李工')
await page.getByRole('option', { name: /^李工/ }).click()
await page.getByRole('button', { name: '添加成员', exact: true }).click()
await expect(page.getByRole('row').filter({ hasText: '李工' })).toContainText('项目成员')
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').fill('李工')
await expect(page.getByRole('option', { name: /^李工/ })).toHaveAttribute('aria-disabled', 'true')
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').press('ArrowDown')
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').press('Enter')
await expect(page.getByRole('button', { name: '添加成员', exact: true })).toBeDisabled()
expect(fixture.writes).toEqual([{ command: 'rdm.project.member.add', params: { projectId: 'domain-7', member: 'bob', role: 'member' } }])
expect(fixture.calls.some(call => call.command === 'rdm.account.list' && call.params.query === '李工' && call.params.roleId === 'systems')).toBe(true)
})
test('member removal shows scoped duty impacts and blocks affected members before any write', async ({ page }) => {
const fixture = await settingsPage(page)
await page.getByRole('tab', { name: '项目成员', exact: true }).click()
await page.getByRole('row').filter({ hasText: '陈工' }).getByRole('button', { name: '移除', exact: true }).click()
await expect(page.getByText('受控文档', { exact: false })).toBeVisible()
await expect(page.getByRole('button', { name: '确认移除', exact: true })).toBeDisabled()
expect(fixture.calls.some(call => call.command === 'rdm.project.member.impact' && call.params.member === 'carol')).toBe(true)
expect(fixture.writes).toEqual([])
})
test('ordinary members see their own permissions immediately and cannot open management views', async ({ page }) => {
const fixture = await settingsPage(page, false)
await page.getByRole('tab', { name: '项目权限', exact: true }).click()
await expect(page.getByRole('cell', { name: '读取文档', exact: true })).toBeVisible()
await expect(page.locator('option[value="matrix"]')).toBeDisabled()
await expect(page.locator('.picker[aria-label="选择权限成员"]').getByRole('combobox')).toBeDisabled()
await expect(page.getByLabel('读取文档项目覆盖', { exact: true })).toHaveCount(0)
await expect(page.getByText('创建项目', { exact: true })).toHaveCount(0)
expect(fixture.calls.some(call => call.command === 'rdm.perm.check' && call.params.member === 'alice')).toBe(true)
expect(fixture.writes).toEqual([])
})
test('permission matrix filters members and cap-bound overrides use actual stored values and removal', async ({ page }) => {
const fixture = await settingsPage(page)
await page.getByRole('tab', { name: '项目权限', exact: true }).click()
await expect(page.getByLabel('编制与编辑文档项目覆盖', { exact: true })).toHaveValue('allow')
await expect(page.getByLabel('编制与编辑文档项目覆盖', { exact: true }).locator('option[value="allow"]')).toBeDisabled()
await page.getByLabel('读取文档项目覆盖', { exact: true }).selectOption('default')
await expect(page.getByLabel('读取文档项目覆盖', { exact: true })).toHaveValue('default')
expect(fixture.writes).toEqual([{ command: 'rdm.perm.unset', params: { projectId: 'domain-7', member: 'alice', action: 'doc.read' } }])
await page.getByLabel('权限查看方式', { exact: true }).selectOption('matrix')
await page.getByLabel('专业角色筛选', { exact: true }).selectOption('software')
await expect(page.getByRole('row').filter({ hasText: '陈工' })).toBeVisible()
await expect(page.getByRole('row').filter({ hasText: '项目负责人' })).toHaveCount(0)
await page.getByRole('button', { name: '陈工 · 读取文档 · 允许', exact: true }).click()
await expect(page.getByRole('heading', { name: '陈工 · 权限依据', exact: true })).toBeVisible()
await expect(page.getByText('创建项目', { exact: true })).toHaveCount(0)
expect(fixture.calls.some(call => call.command === 'rdm.perm.matrix' && call.params.roleId === 'software')).toBe(true)
})
+19 -10
View File
@@ -77,7 +77,7 @@ export function App() {
const h = (e: KeyboardEvent) => {
if (authState === 'ok' && (e.metaKey || e.ctrlKey)) {
const key = e.key.toLowerCase()
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened); return }
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened).catch(() => undefined); return }
if (key === 'b') { e.preventDefault(); openContext(); return }
}
workspaceShortcuts(e)
@@ -91,11 +91,14 @@ export function App() {
try {
const r = await auth.fetch('/api/health')
if (r.status === 401) { setAuthState('login'); return }
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
// key 持有者清 key 回登录页,改走密码登录完成改密。
// 已持有有效会话 cookie(或密钥)时直接放行。设密门依据 sessionScope 判定
//(2026-10-13 合同):恢复 Key 兑换与 must-change 首登都建立
// set_password_only 受限会话,仅看 mustChangePassword 会漏掉已有密码的
// 恢复会话。改密接口只认会话登录:API key 凭证进改密门是死路(提交必
// 401),key 持有者清 key 回登录页,改走密码登录完成改密。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) {
const gated = me?.sessionScope === 'set_password_only' || me?.mustChangePassword
if (gated) {
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
}
@@ -111,10 +114,14 @@ export function App() {
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
store.refreshMe()
store.refreshProjects()
store.refreshProfiles()
store.refreshPlugins()
let active = true
store.refreshMe().then(() => {
if (!active) return
void store.refreshProjects()
void store.refreshProfiles()
void store.refreshPlugins()
})
return () => { active = false }
}, [authState])
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
@@ -173,9 +180,11 @@ export function App() {
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
}
if (authState === 'mustchange') {
// 受限会话设密成功后服务端销毁全部会话并返回 reloginRequired=true:
// 唯一后继动作是回密码登录页,不得因历史 onDone 误放行进工作台。
return <ChangePasswordGate
owner={mustChangeOwner}
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
onDone={() => { api.logout().catch(() => undefined); setAuthState('login') }}
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
}
if (authState !== 'ok') {
+50 -11
View File
@@ -1,6 +1,7 @@
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
import { auth } from './auth'
import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
@@ -14,11 +15,23 @@ const BASE = ''
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
async function json<T>(res: Response): Promise<T> {
export interface SettingsView {
schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown>
projectRevision?: number; projectCanConfigure?: boolean
personalSchema?: SettingDesc[]; personalDefaults?: Record<string, unknown>
personalUser?: Record<string, unknown>; personalProject?: Record<string, unknown>
}
async function checked(res: Response): Promise<Response> {
if (!res.ok) {
const text = await res.text()
throw new Error(`${res.status}: ${text}`)
}
return res
}
async function json<T>(res: Response): Promise<T> {
await checked(res)
return res.json() as Promise<T>
}
@@ -26,7 +39,7 @@ export const api = {
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
@@ -42,20 +55,37 @@ export const api = {
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
auth.beginSession()
return me
}),
logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }),
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
auth.beginSession()
return me
}),
logout: () => {
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
auth.clear()
return request
},
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
}),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
// ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)----
adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)),
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
@@ -113,10 +143,14 @@ export const api = {
saveProfile: (p: AgentProfile) => auth.fetch(`${BASE}/api/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }),
deleteProfile: (id: string) => auth.fetch(`${BASE}/api/profiles/${id}`, { method: 'DELETE' }),
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r =>
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + encodeURIComponent(projectId) : ''}`).then(r => json<SettingsView>(r)),
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
putProjectSettings: (projectId: string, values: Record<string, unknown>, expectedRevision: number, unset?: string[]) => auth.fetch(`${BASE}/api/settings/project/${encodeURIComponent(projectId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values, expectedRevision, ...(unset?.length ? { unset } : {}) }) }).then(r => json<{ ok: boolean; projectRevision: number }>(r)),
putPersonalProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${encodeURIComponent(projectId)}/personal`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
// P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。
getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r =>
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown>; systemRevision?: number }>(r)),
putSystemSettings: (values: Record<string, unknown>, expectedRevision: number, unset?: string[]) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values, expectedRevision, ...(unset?.length ? { unset } : {}) }) }).then(checked),
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
@@ -141,6 +175,11 @@ export const api = {
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}): Promise<T> => {
const error = rdmProjectCommandError(project, id)
if (error) throw new Error(error)
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
},
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
+14 -3
View File
@@ -17,11 +17,22 @@ function pickStorage(): KV {
}
const storage = pickStorage()
const identityListeners = new Set<() => void>()
function identityChanged() { identityListeners.forEach(listener => listener()) }
export const auth = {
apiKey(): string { return storage.getItem(KEY) ?? '' },
setKey(key: string) { storage.setItem(KEY, key.trim()) },
clear() { storage.removeItem(KEY) },
setKey(key: string) {
const next = key.trim()
if (next === this.apiKey()) return
storage.setItem(KEY, next)
identityChanged()
},
clear() { storage.removeItem(KEY); identityChanged() },
onIdentityChange(listener: () => void) {
identityListeners.add(listener)
return () => { identityListeners.delete(listener) }
},
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
beginSession() { this.clear() },
@@ -33,7 +44,7 @@ export const auth = {
headers.set('Authorization', `Bearer ${key}`)
return fetch(input, { ...init, headers })
},
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
// 退出登录:立即清客户端投影并回登录门;调用者先经 api.logout 注销服务端会话。
logout() {
this.clear()
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
+12 -7
View File
@@ -4,10 +4,10 @@
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { store, useStore } from '../store'
import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { RdmRolesPanel } from './adminRdmSections'
import type { AdminZoneSection } from '../api'
@@ -18,7 +18,7 @@ interface AdminSectionDef {
icon: IconName
desc: string
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
render: () => React.ReactNode
render: (onDirtyChange?: (dirty: boolean) => void) => React.ReactNode
}
function ProfilesPanelWrapper() {
@@ -31,6 +31,8 @@ const SECTIONS: AdminSectionDef[] = [
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: onDirtyChange => <SystemSettingsPanel onDirtyChange={onDirtyChange} /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
]
@@ -62,6 +64,9 @@ export function AdminZone() {
const declared = useStore(s => s.adminSections)
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
const [sectionKey, setSectionKey] = useState('users')
const [dirty, setDirty] = useState(false)
const dirtyChange = useCallback((value: boolean) => setDirty(value), [])
const depart = () => !dirty || window.confirm('系统配置尚未保存,离开后将丢弃这些改动。继续?')
const isAdmin = !!me?.isAdmin
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
@@ -80,18 +85,18 @@ export function AdminZone() {
return (
<div className="az" role="dialog" aria-label="系统管理">
<div className="az-head">
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
<button className="btn ghost" onClick={() => { if (depart()) store.setUI({ adminZoneOpen: false }) }}>
<Icon name="chevron-down" size={14} /> 返回工作台
</button>
<span className="az-title">系统管理</span>
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 按配置注明的时机生效</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me?.owner ?? ''}</span>
</div>
<div className="az-body">
<nav className="az-side">
{sections.map(s => (
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => { if (depart()) { setDirty(false); setSectionKey(s.key) } }}>
<Icon name={s.icon} size={15} />
<span>{s.label}</span>
</button>
@@ -102,7 +107,7 @@ export function AdminZone() {
<h3>{section.label}</h3>
<p>{section.desc}</p>
</div>
{section.render()}
{section.render(dirtyChange)}
</main>
</div>
</div>
-36
View File
@@ -2,25 +2,12 @@ import { useEffect, useState } from 'react'
import { Btn, SettingRow, SettingsGroup } from '@ui'
import { api } from '../api'
const services = [
['models.chat', '主模型'],
['models.lightweight', '轻量模型'],
['models.embedding', 'Embedding'],
['models.rerank', 'Reranker'],
['tools.tavily', 'Tavily'],
] as const
type Secret = { id: string; service: string; label: string; active: boolean; mask: string }
type Entry = { id: string; keys: string[]; content: string; constant: boolean; logic: string }
type Cmd = { id: string; name: string; title: string; template: string }
export function BorrowPanels() {
const [secrets, setSecrets] = useState<Secret[]>([])
const [entries, setEntries] = useState<Entry[]>([])
const [cmds, setCmds] = useState<Cmd[]>([])
const [service, setService] = useState<string>(services[0][0])
const [label, setLabel] = useState('')
const [value, setValue] = useState('')
const [keys, setKeys] = useState('')
const [content, setContent] = useState('')
const [constant, setConstant] = useState(false)
@@ -29,7 +16,6 @@ export function BorrowPanels() {
const [cmdBody, setCmdBody] = useState('请总结以上内容。\n{{input}}')
const reload = () => {
api.listSecrets().then(setSecrets).catch(() => undefined)
api.listContextEntries().then(setEntries).catch(() => undefined)
api.listPromptCommands().then(setCmds).catch(() => undefined)
}
@@ -37,28 +23,6 @@ export function BorrowPanels() {
return (
<>
<SettingsGroup title="密钥">
<SettingRow label="新增密钥" desc="每个服务同时只有一把生效。列表只显示掩码。">
<select value={service} onChange={e => setService(e.target.value)}>
{services.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
</select>
</SettingRow>
<SettingRow label="标签与密钥" desc="保存后立即生效,并停用同服务的其他密钥。">
<input className="search-input" value={label} placeholder="标签" onChange={e => setLabel(e.target.value)} />
<input className="search-input" type="password" autoComplete="new-password" value={value} placeholder="密钥" onChange={e => setValue(e.target.value)} />
<Btn variant="primary" onClick={async () => {
if (!value.trim()) return
await api.createSecret(service, label || '未命名', value.trim())
setValue(''); setLabel(''); reload()
}}>添加</Btn>
</SettingRow>
{secrets.map(s => (
<SettingRow key={s.id} label={s.label} desc={`${s.service} · ${s.mask}${s.active ? ' · 生效' : ''}`}>
{!s.active && <Btn onClick={async () => { await api.activateSecret(s.id); reload() }}>启用</Btn>}
<Btn onClick={async () => { await api.deleteSecret(s.id); reload() }}>删除</Btn>
</SettingRow>
))}
</SettingsGroup>
<SettingsGroup title="条件注入">
<SettingRow label="关键词" desc="逗号分隔。用 /正则/ 表示正则。勾选后变为常驻。">
<input className="search-input" value={keys} placeholder="关键词" onChange={e => setKeys(e.target.value)} />
+63 -52
View File
@@ -1,8 +1,7 @@
// 登录与改密(管理员发放制,R2):
// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口;
// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key);
// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。
import { useState } from 'react'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark } from '@ui'
@@ -28,9 +27,11 @@ function BrandPanel() {
)
}
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
const [tab, setTab] = useState<'password' | 'apikey'>('password')
const [tab, setTab] = useState<'password' | 'loginkey'>('password')
const [owner, setOwner] = useState('')
const [password, setPassword] = useState('')
const [key, setKey] = useState('')
@@ -41,10 +42,9 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
setErr(''); setBusy(true)
try {
const me = await api.login(owner.trim(), password)
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
auth.beginSession()
if (me.mustChangePassword) {
// 初始/重置密码:先完成强制改密再放行。
// 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明;
// 受限会话先进设密门,完成前不放行。
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
onMustChange(me.owner)
return
}
@@ -56,24 +56,16 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
const submitKey = async () => {
setErr(''); setBusy(true)
auth.setKey(key)
try {
const r = await auth.fetch('/api/health')
if (r.ok) {
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
// 不放行进死路改密门,引导改走账号密码登录。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) {
auth.clear()
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
return
}
onAuthed(); return
// 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。
const me = await api.redeemLoginKey(key.trim())
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
onMustChange(me.owner)
return
}
throw new Error(String(r.status))
} catch {
auth.clear()
setErr('密钥无效或已撤销,请重试')
onAuthed()
} catch (e) {
setErr(humanRedeemError(String(e)))
} finally { setBusy(false) }
}
@@ -84,10 +76,10 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">登录</h2>
<p className="lg-sub">账号由管理员发放;首次登录需修改初始密码</p>
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key</p>
<div className="lg-tabs" role="tablist">
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
<button type="button" role="tab" aria-selected={tab === 'apikey'} className={`lg-tab ${tab === 'apikey' ? 'on' : ''}`} onClick={() => setTab('apikey')}>API 密钥</button>
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
</div>
{tab === 'password' ? (
<>
@@ -100,11 +92,11 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
</>
) : (
<>
<label className="lg-label">API 访问密钥</label>
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="ask_…" />
<label className="lg-label">一次性登录 Key</label>
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '验证中…' : '进入'}</button>
<p className="lg-hint">密钥由服务端创建:<code>agent-system apikey create --owner &lt;name&gt;</code>。程序化客户端也可直接带 Authorization 头。</p>
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
<p className="lg-hint">忘记密码时由管理员签发;单次有效、默认 30 分钟内兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
</>
)}
</form>
@@ -113,13 +105,15 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
)
}
/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */
/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。
* 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框;
* 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
const submit = async () => {
setErr('')
@@ -127,8 +121,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.changePassword(current, next)
onDone()
await api.changePassword('', next)
setDone(true)
} catch (e) {
setErr(humanChangePasswordError(String(e)))
} finally { setBusy(false) }
@@ -138,20 +132,27 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
<div className="lg-wrap">
<BrandPanel />
<div className="lg-formside">
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">修改密码</h2>
<p className="lg-sub">账号 <b>{owner}</b> 使用初始/重置密码首次登录,须设置新密码后继续</p>
<label className="lg-label">当前密码(初始/重置密码)</label>
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus autoComplete="current-password" />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '设置新密码并进入'}</button>
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>返回登录</button>}
</form>
{done ? (
<form className="lg-card" onSubmit={e => { e.preventDefault(); onDone() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">密码已更新</h2>
<p className="lg-sub">请使用新密码重新登录账号 <b>{owner}</b></p>
<button className="lg-submit" type="submit">返回登录</button>
</form>
) : (
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">设置新密码</h2>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续;无需输入当前密码</p>
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
</form>
)}
</div>
</div>
)
@@ -165,13 +166,23 @@ export function humanLoginError(raw: string): string {
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
export function humanRedeemError(raw: string): string {
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
* 不能沿用登录的「用户名或密码不正确」一刀切。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
+87 -89
View File
@@ -1,100 +1,98 @@
// 项目设置面板(提案 2026-09-30-cross-session-reading):首个项目级设置 UI。
// schema 驱动渲染 project scope(4.9 覆盖链:system → user → project),
// 写 putProjectSettings;后续项目级设置项经 settings schema 自动出现,零开发。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { useCallback, useEffect, useState } from 'react'
import { api, type SettingsView } from '../api'
import { store, useStore } from '../store'
import type { SettingDesc } from '../contracts'
import { SettingsShell, Btn, EmptyState } from '@ui'
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
import { flatten, scopedDraft, scopedSchema, SettingsFields, settingsMutation } from './SettingsFields'
import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections'
import { rdmProjectReadError } from '../rdmManagement'
import { projectActionError, projectWorkError } from '../projectmeta'
import { Feedback, useRequestFence, validVersion } from './rdmSettingsShared'
export function ProjectSettingsModal() {
const projectId = useStore(s => s.ui.projectSettingsId)
const projects = useStore(s => s.projects)
const project = projects.find(p => p.id === projectId)
const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable)
const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable)
const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable)
const project = useStore(s => s.projects.find(project => project.id === projectId))
const owner = useStore(s => s.me?.owner)
const membersAvailable = useStore(s => s.rdmMembersAvailable)
const dutiesAvailable = useStore(s => s.rdmDocReviewAvailable)
const permissionsAvailable = useStore(s => s.rdmPermMatrixAvailable)
const context = `${owner || ''}:${projectId || ''}`
const fence = useRequestFence(context)
const [tab, setTab] = useState('project')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [settings, setSettings] = useState<SettingsView | null>(null)
const [draft, setDraft] = useState<Record<string, any>>({})
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (!projectId) return
setLoaded(false)
api.getSettings(projectId).then(s => {
setSchema(s.schema)
setDraft(flatten(s.project))
setLoaded(true)
}).catch(() => undefined)
}, [projectId])
const groups = useMemo(() => {
const g: Record<string, SettingDesc[]> = {}
for (const d of schema) (g[d.group] ??= []).push(d)
return g
}, [schema])
if (!projectId) return null
const save = async () => {
if (!projectId) return
const [childDirty, setChildDirty] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const schema = settings ? scopedSchema(settings.schema, 'project') : []
const initial = settings ? scopedDraft(settings.project, schema) : {}
const dirty = JSON.stringify(draft) !== JSON.stringify(initial)
const writeError = projectWorkError(project)
const canSave = settings?.projectCanConfigure === true && validVersion(settings.projectRevision) && !writeError
const load = async () => {
if (!projectId) return false
const current = fence.begin('load')
try {
await api.putProjectSettings(projectId, unflatten(draft))
setSaved(true)
setTimeout(() => setSaved(false), 1500)
} catch (e) {
store.toast(`项目设置保存失败:${e}`)
}
const next = await api.getSettings(projectId)
if (!current()) return false
setSettings(next); setDraft(scopedDraft(next.project, scopedSchema(next.schema, 'project')))
return true
} catch (e) { if (current()) setError(`项目设置未能加载:${projectActionError(e)}`); return false }
}
return (
<SettingsShell
title={`项目设置 · ${project?.name ?? projectId}`}
active={tab}
onSelect={setTab}
onClose={() => store.openProjectSettings(null)}
sections={[
{ key: 'project', label: '本项目', icon: 'folder' },
// U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。
...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []),
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责 · 整文审签', icon: 'shield' as const }] : []),
...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
>
<div>
{tab === 'members' && <RdmMembersSection projectId={projectId} />}
{tab === 'duties' && <RdmDutiesSection projectId={projectId} />}
{tab === 'matrix' && <RdmPermMatrixSection projectId={projectId} />}
{tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? (
<EmptyState icon="settings">加载中…</EmptyState>
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? (
<>
<div className="empty-note" style={{ marginBottom: 10 }}>
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
</div>
{Object.entries(groups).map(([g, descs]) => (
<div className="settings-group" key={g}>
<h4>{groupNames[g] ?? g}</h4>
{descs.map(d => (
<div className="setting-row" key={d.key}>
<label title={d.key}>{d.label}</label>
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
</span>
</div>
))}
</div>
))}
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
</>
) : null}
</div>
</SettingsShell>
)
useEffect(() => {
fence.invalidate(); setSettings(null); setDraft({}); setTab('project'); setChildDirty(false)
setError(''); setNotice(''); setBusy(false)
void load()
}, [context])
const dirtyChange = useCallback((value: boolean) => setChildDirty(value), [])
const depart = () => !(dirty || childDirty) || window.confirm('存在未保存的设置,离开后将丢弃这些改动。继续?')
useEffect(() => {
if (!dirty && !childDirty) return
const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault() }
window.addEventListener('beforeunload', beforeUnload)
return () => window.removeEventListener('beforeunload', beforeUnload)
}, [dirty, childDirty])
const save = async () => {
if (!projectId || !canSave || !settings || !validVersion(settings.projectRevision) || busy) return
const current = fence.begin('save')
setBusy(true); setError(''); setNotice('')
try {
const mutation = settingsMutation(initial, draft, schema)
await api.putProjectSettings(projectId, mutation.values, settings.projectRevision, mutation.unset)
if (current() && await load()) setNotice('已保存共享项目配置;按各项标注的生效时机应用')
} catch (e) {
if (!current()) return
const message = String(e).includes('409') ? '项目设置已被其他成员更新;请核对最新设置后重新调整' : `保存结果未能确认:${projectActionError(e)};请核对最新设置后再保存`
await load()
if (current()) setError(message)
} finally { if (current()) setBusy(false) }
}
if (!projectId) return null
const rdmTab = ['members', 'duties', 'matrix'].includes(tab)
const readError = rdmProjectReadError(project)
return <SettingsShell title={project?.name || '当前项目'} ariaLabel={`项目设置 · ${project?.name || '当前项目'}`} active={tab}
onSelect={next => { if (!busy && depart()) { if (dirty) setDraft(initial); setChildDirty(false); setNotice(''); setTab(next) } }}
onClose={() => { if (depart()) { fence.invalidate(); store.openProjectSettings(null) } }}
sections={[
{ key: 'project', label: '共享项目配置', icon: 'folder' },
...(membersAvailable ? [{ key: 'members', label: '项目成员', icon: 'users' as const }] : []),
...(dutiesAvailable ? [{ key: 'duties', label: '文档职责', icon: 'shield' as const }] : []),
...(permissionsAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
]}
footer="项目配置由成员共享;个人项目偏好在个人设置中调整"
actions={!rdmTab && settings && schema.length > 0 ? <Btn variant="primary" icon="check" disabled={!canSave || busy || !dirty} onClick={save}>保存项目设置</Btn> : undefined}>
{rdmTab && readError ? <EmptyState icon="folder">{readError}</EmptyState> : project && <>
{tab === 'members' && <RdmMembersSection key={context} project={project} />}
{tab === 'duties' && <RdmDutiesSection key={context} project={project} onDirtyChange={dirtyChange} />}
{tab === 'matrix' && <RdmPermMatrixSection key={context} project={project} />}
</>}
{!rdmTab && <>
<p className="ad-hint">此处配置项目共享的业务规则。模型、工具与系统插件由系统管理员配置;个人上下文、检索与会话偏好在个人设置中调整</p>
{!settings ? <><EmptyState icon="settings">{error || '加载项目设置…'}</EmptyState>{error && <Btn onClick={() => { setError(''); void load() }}>重试</Btn>}</>
: schema.length === 0 ? <EmptyState icon="settings">当前没有可配置的共享项目设置</EmptyState>
: <SettingsFields schema={schema} draft={draft} defaults={flatten(settings.defaults)} disabled={!canSave || busy} source="本项目共享配置" onChange={(key, value) => { setNotice(''); setDraft(previous => ({ ...previous, [key]: value })) }} />}
{settings && !canSave && <p className="ad-hint">{writeError || (validVersion(settings.projectRevision) ? '你可以查看共享配置;保存需要项目设置管理权限' : '共享配置版本尚未就绪,暂不能保存')}</p>}
<Feedback error={error} notice={notice} />
</>}
</SettingsShell>
}
+141 -6
View File
@@ -5,6 +5,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
import { store, useStore } from '../store'
import { exportNeedsDraft } from '../workspaces'
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
@@ -45,22 +47,30 @@ function RdmErrorState({ raw }: { raw: string }) {
}
export function RdmAuditWorkspace() {
const identity = useStore(() => store.getIdentityEpoch())
const [caps, setCaps] = useState<Capabilities | null>(null)
const [capsIdentity, setCapsIdentity] = useState(-1)
const [pid, setPid] = useState('')
const [tab, setTab] = useState<TabId>('summary')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
setCaps(null); setPid(''); setError('')
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
.catch(e => { if (!cancelled) setError(e.message) })
.then(c => {
if (cancelled || identity !== store.getIdentityEpoch()) return
setCaps(c); setCapsIdentity(identity)
if (c.projects.length > 0) setPid(c.projects[0].projectId)
})
.catch(e => { if (!cancelled && identity === store.getIdentityEpoch()) setError(e.message) })
return () => { cancelled = true }
}, [])
}, [identity])
const project = caps?.projects.find(p => p.projectId === pid)
const exportOn = useStore(s => s.rdmExportAvailable)
if (error) return <RdmErrorState raw={error} />
if (!caps) return <div className="empty-note">加载中…</div>
if (!caps || capsIdentity !== identity) return <div className="empty-note">加载中…</div>
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
return (
@@ -82,7 +92,7 @@ export function RdmAuditWorkspace() {
))}
</div>
<div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} />}
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
{tab === 'materials' && <MaterialsTab pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />}
@@ -93,7 +103,7 @@ export function RdmAuditWorkspace() {
// ---- 概览 ----
function SummaryTab({ pid }: { pid: string }) {
function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) {
const [data, setData] = useState<SummaryData | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
@@ -133,6 +143,131 @@ function SummaryTab({ pid }: { pid: string }) {
{data.coverage && data.coverage.length > 0 && (
<CoverageList cov={data.coverage} />
)}
{exportOn && <ExportSection pid={pid} />}
</div>
)
}
// ---- 概览导出(三条命令都启用才挂上;不参与七命令就绪门) ----
interface ExportCreated {
exportId: string
fileName: string
watermark: number
sha256: string
draft?: boolean
}
interface ExportStatus {
expiresAt: string
sha256: string
watermark: number
}
interface ExportDownloaded {
fileName: string
mime: string
dataB64: string
sha256: string
}
function saveExportFile(fileName: string, mime: string, dataB64: string) {
const bin = atob(dataB64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
const blob = new Blob([bytes], { type: mime || 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}
function ExportSection({ pid }: { pid: string }) {
const identity = useStore(() => store.getIdentityEpoch())
const generation = useRef(0)
const [kind, setKind] = useState<'cgraph-docx' | 'cgraph-matrix'>('cgraph-docx')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [needDraft, setNeedDraft] = useState(false)
const [artifact, setArtifact] = useState<(ExportCreated & { expiresAt: string }) | null>(null)
useEffect(() => {
generation.current++
setArtifact(null)
setErr('')
setNeedDraft(false)
setBusy(false)
return () => { generation.current++ }
}, [pid, identity])
const generate = async (draft: boolean) => {
const request = ++generation.current
const current = () => request === generation.current && identity === store.getIdentityEpoch()
setBusy(true)
setErr('')
setNeedDraft(false)
try {
const created = await api.rdmCommand<ExportCreated>('rdm.inspect.export.create', { projectId: pid, kind, draft })
if (!current()) return
const st = await api.rdmCommand<ExportStatus>('rdm.inspect.export.status', { projectId: pid, exportId: created.exportId })
if (!current()) return
setArtifact({ ...created, expiresAt: st.expiresAt })
} catch (e) {
if (!current()) return
const msg = e instanceof Error ? e.message : String(e)
if (!draft && exportNeedsDraft(msg)) setNeedDraft(true)
else setErr(msg)
} finally {
if (current()) setBusy(false)
}
}
const download = async () => {
if (!artifact) return
const request = ++generation.current
const current = () => request === generation.current && identity === store.getIdentityEpoch()
setBusy(true)
setErr('')
try {
const dl = await api.rdmCommand<ExportDownloaded>('rdm.inspect.export.download', { projectId: pid, exportId: artifact.exportId })
if (!current()) return
saveExportFile(dl.fileName, dl.mime, dl.dataB64)
} catch (e) {
if (current()) setErr(e instanceof Error ? e.message : String(e))
} finally {
if (current()) setBusy(false)
}
}
return (
<div className="rdm-audit-export">
<SectionLabel>导出</SectionLabel>
<div className="rdm-audit-export-row">
<label className="rdm-audit-export-kind">
类型
<select className="ui-input" aria-label="导出类型" value={kind} onChange={e => setKind(e.target.value as 'cgraph-docx' | 'cgraph-matrix')}>
<option value="cgraph-docx">合规追溯报告</option>
<option value="cgraph-matrix">追溯矩阵</option>
</select>
</label>
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(false) }}>生成</button>
</div>
{needDraft && (
<div className="rdm-audit-export-draft">
<p>正式报告不完整。可以生成带缺口标注的草稿。</p>
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(true) }}>生成草稿</button>
</div>
)}
{err && <div className="rdm-audit-export-err">{err}</div>}
{artifact && (
<div className="rdm-audit-export-result">
<div>水位 {artifact.watermark}</div>
<div>sha256 {artifact.sha256}</div>
<div>有效期 {artifact.expiresAt ? new Date(artifact.expiresAt).toLocaleString('zh-CN') : '—'}</div>
{artifact.draft && <div>草稿</div>}
<button type="button" className="btn v-ghost" disabled={busy} onClick={() => { void download() }}>下载</button>
</div>
)}
</div>
)
}
+353
View File
@@ -0,0 +1,353 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Btn, Collapsible, Picker, SettingRow, SettingsGroup, type PickPage } from '@ui'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { assignDocDutiesPayload, docApprovePayload, docFinalPayload, docRejectPayload, docSubmitPayload } from '../rdmManagement'
import { Feedback, personItem, projectContext, readableError, RoleFilter, selectedPeople,
useRequestFence, useRoleOptions, validVersion, type MemberList, type Person } from './rdmSettingsShared'
interface Duties { author: string; reviewers: string[]; approver: string }
type WireDuties = Partial<Duties> & { Author?: string; Reviewers?: string[]; Approver?: string }
export function normalizeDuties(value?: WireDuties | null): Duties {
return { author: value?.author ?? value?.Author ?? '', reviewers: value?.reviewers ?? value?.Reviewers ?? [], approver: value?.approver ?? value?.Approver ?? '' }
}
const blank = (): Duties => ({ author: '', reviewers: [], approver: '' })
const sameDuties = (left: Duties, right: Duties) => left.author === right.author && left.approver === right.approver
&& [...left.reviewers].sort().join('\u0000') === [...right.reviewers].sort().join('\u0000')
export function dutyValidation(duties: Duties): string {
if (!duties.author || !duties.approver || duties.reviewers.length === 0) return '请选择编制人、至少一位审核人和批准人'
if (duties.author === duties.approver || duties.reviewers.includes(duties.author) || duties.reviewers.includes(duties.approver)) return '编制、审核、批准须由不同成员承担'
return ''
}
interface CatalogDoc {
docId: string; title: string; typeKey: string; typeLabel?: string; stage?: string; stageLabel?: string
sections: number; assigned: boolean; author?: string; reviewers?: string[]; approver?: string
activeRound?: number; activeSections?: number; status?: string; statusLabel?: string; dutyVersion?: number
}
interface DutyTreeNode {
id: string; kind: 'stage' | 'type' | 'doc' | 'section'; label: string; selectable: boolean
docId?: string; sectionId?: string; children?: DutyTreeNode[]
}
interface Catalog {
docs: CatalogDoc[]; next?: string; count?: number; total?: number; canAssignDuties?: boolean
filters?: { stages?: { value: string; label: string }[]; typeKeys?: { value: string; label: string }[]; statuses?: { value: string; label: string }[]; assignments?: { value: string; label: string }[] }
}
interface ActiveDuty { round?: number; state?: string; createdBy?: string; duties?: WireDuties; cardId?: string }
interface Chapter {
sectionId: string; title: string; roles: WireDuties; dutyVersion?: number; overridden: boolean; active?: ActiveDuty | null
}
interface DutyDetail {
docId: string; title: string; assigned: boolean; roles: WireDuties; dutyVersion?: number
sections: Chapter[]; active?: ActiveDuty | null; canAssignDuties?: boolean
}
/** A default change affects whole-document review and only chapters inheriting that default. */
export function documentAssignmentImpact(document: Pick<DutyDetail, 'roles' | 'active' | 'sections'>, duties: Duties): boolean {
if (sameDuties(normalizeDuties(document.roles), duties)) return false
return !!document.active || document.sections.some(section => !section.overridden && !!section.active
&& !sameDuties(normalizeDuties(section.roles), duties))
}
interface Submission {
round: number; state: string; duties: WireDuties; reviews: Record<string, boolean>; createdBy: string
}
const statusLabel = (state?: string) => ({ submitted: '审核中', final: '已批准', rejected: '已驳回', invalidated: '职责变更,待重新提交', 'in-review': '审核中', approved: '已批准', draft: '编写中', unassigned: '未分配' }[state || ''] || '暂无审签')
/** Only document nodes may become a document selection. Chapters remain visible context. */
export function DutyTree({ nodes, selected, onSelect }: { nodes: DutyTreeNode[]; selected: string; onSelect: (docId: string) => void }) {
return <ul className="rdm-duty-tree">{nodes.map(node => <li key={node.id}>
{node.kind === 'doc' && node.selectable === true && node.docId
? <button className={`ad-mini${selected === node.docId ? ' ad-row-on' : ''}`} onClick={() => onSelect(node.docId!)}>{node.label}</button>
: <span className="ad-dim">{node.label}{node.kind === 'section' ? ' · 章节' : ''}</span>}
{!!node.children?.length && <Collapsible trigger={`展开 ${node.label}`} defaultOpen={node.kind === 'stage' || node.kind === 'type'}>
<DutyTree nodes={node.children} selected={selected} onSelect={onSelect} />
</Collapsible>}
</li>)}</ul>
}
function mergeTree(previous: DutyTreeNode[], incoming: DutyTreeNode[]): DutyTreeNode[] {
const merged = new Map(previous.map(node => [node.id, node]))
for (const node of incoming) {
const old = merged.get(node.id)
merged.set(node.id, old ? { ...node, children: mergeTree(old.children || [], node.children || []) } : node)
}
return [...merged.values()]
}
function DutyPeople({ value, onChange, fetchPage, people, disabled, labelPrefix = '' }: {
value: Duties; onChange: (value: Duties) => void; fetchPage: (query: string, cursor: string, limit: number, purpose: 'author' | 'reviewer' | 'approver') => Promise<PickPage>
people: Person[]; disabled: boolean; labelPrefix?: string
}) {
const authorPage = useCallback((query: string, cursor: string, limit: number) => fetchPage(query, cursor, limit, 'author'), [fetchPage])
const reviewerPage = useCallback((query: string, cursor: string, limit: number) => fetchPage(query, cursor, limit, 'reviewer'), [fetchPage])
const approverPage = useCallback((query: string, cursor: string, limit: number) => fetchPage(query, cursor, limit, 'approver'), [fetchPage])
return <>
<SettingRow label={`${labelPrefix}编制人`} desc="单选项目成员">
<Picker aria-label={`${labelPrefix}编制人`} selected={value.author ? [value.author] : []} selectedItems={selectedPeople([value.author].filter(Boolean), people)}
onSelect={ids => onChange({ ...value, author: ids[0] || '' })} fetchPage={authorPage} disabled={disabled} />
</SettingRow>
<SettingRow label={`${labelPrefix}审核人`} desc="至少选择一位,与编制人和批准人互异">
<Picker aria-label={`${labelPrefix}审核人`} multiple selected={value.reviewers} selectedItems={selectedPeople(value.reviewers, people)}
onSelect={reviewers => onChange({ ...value, reviewers })} fetchPage={reviewerPage} disabled={disabled} />
</SettingRow>
<SettingRow label={`${labelPrefix}批准人`} desc="单选项目成员">
<Picker aria-label={`${labelPrefix}批准人`} selected={value.approver ? [value.approver] : []} selectedItems={selectedPeople([value.approver].filter(Boolean), people)}
onSelect={ids => onChange({ ...value, approver: ids[0] || '' })} fetchPage={approverPage} disabled={disabled} />
</SettingRow>
</>
}
export function RdmDutiesSection({ project, onDirtyChange }: { project: Project; onDirtyChange?: (dirty: boolean) => void }) {
const me = useStore(s => s.me)
const context = projectContext(project, me?.owner)
const fence = useRequestFence(context)
const writeError = projectWorkError(project)
const [catalog, setCatalog] = useState<Catalog | null>(null)
const [tree, setTree] = useState<DutyTreeNode[] | null>(null)
const [view, setView] = useState<'list' | 'tree'>('list')
const [query, setQuery] = useState('')
const [match, setMatch] = useState<'exact' | 'fuzzy'>('fuzzy')
const [stage, setStage] = useState('')
const [typeKey, setTypeKey] = useState('')
const [assignment, setAssignment] = useState('')
const [status, setStatus] = useState('')
const [roleId, setRoleId] = useState('')
const [members, setMembers] = useState<MemberList | null>(null)
const [docId, setDocId] = useState('')
const [sectionId, setSectionId] = useState('')
const [detail, setDetail] = useState<DutyDetail | null>(null)
const [draft, setDraft] = useState<Duties>(blank)
const [sub, setSub] = useState<Submission | null | undefined>(undefined)
const [selection, setSelection] = useState<Set<string>>(new Set())
const [batchDraft, setBatchDraft] = useState<Duties>(blank)
const [busy, setBusy] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const { roles, error: roleError } = useRoleOptions(project, 'duties', members?.canAssignDuties === true)
const selectedRef = useRef('')
const memberNames = useRef(new Map<string, Person>())
const canAssign = catalog?.canAssignDuties === true && members?.canAssignDuties === true && !writeError
const canEditCurrent = canAssign && detail?.canAssignDuties === true
const currentChapter = detail?.sections.find(section => section.sectionId === sectionId)
const original = normalizeDuties(sectionId ? currentChapter?.roles : detail?.roles)
const currentVersion = sectionId ? currentChapter?.dutyVersion : detail?.dutyVersion
const active = sectionId ? currentChapter?.active || detail?.active : detail?.active
const dirty = !!detail && !sameDuties(draft, original)
const batchDirty = selection.size > 0 && (batchDraft.author !== '' || batchDraft.approver !== '' || batchDraft.reviewers.length > 0)
useEffect(() => { onDirtyChange?.(dirty || batchDirty); return () => onDirtyChange?.(false) }, [dirty, batchDirty, onDirtyChange])
const filters = { query, match, stage, typeKey, assignment, status }
const clearDetail = () => {
fence.cancel('detail'); fence.cancel('status'); fence.cancel('mutation'); fence.cancel('batch-preview')
selectedRef.current = ''; setDocId(''); setSectionId(''); setDetail(null); setDraft(blank()); setSub(undefined)
setSelection(new Set()); setBatchDraft(blank()); setNotice(''); setBusy(false)
}
const depart = () => !(dirty || batchDirty) || window.confirm('有未保存的职责调整,离开后将丢弃这些改动。继续?')
const changeFilter = (change: () => void) => { if (depart()) { fence.cancel('catalog'); clearDetail(); setCatalog(null); setTree(null); setError(''); change() } }
const loadCatalog = async (cursor = '') => {
const current = fence.begin('catalog')
setLoading(true)
try {
const out = await api.rdmProjectCommand<Catalog>(project, 'rdm.duty.catalog', { ...filters, cursor, limit: 30 })
if (!current()) return false
if (!Array.isArray(out.docs) || typeof out.canAssignDuties !== 'boolean') throw new Error('文档目录契约尚未就绪,请升级项目服务')
setCatalog(prev => cursor && prev ? { ...out, docs: [...prev.docs, ...out.docs] } : out)
if (view === 'tree') {
const treeOut = await api.rdmProjectCommand<{ nodes: DutyTreeNode[]; next?: string }>(project, 'rdm.duty.tree', { ...filters, cursor, limit: 30 })
if (!current()) return false
if (!Array.isArray(treeOut.nodes)) throw new Error('文档树接口尚未就绪')
setTree(previous => cursor ? mergeTree(previous || [], treeOut.nodes) : treeOut.nodes)
}
return true
} catch (e) { if (current()) { setError(readableError(e)); if (!cursor) { setCatalog(null); setTree(null) } } return false }
finally { if (current()) setLoading(false) }
}
const loadDetail = async (id: string) => {
const current = fence.begin('detail')
const statusCurrent = fence.begin('status')
selectedRef.current = id; setDocId(id); setSectionId(''); setDetail(null); setDraft(blank()); setSub(undefined); setError('')
try {
const duty = await api.rdmProjectCommand<DutyDetail>(project, 'rdm.duty.get', { docId: id })
if (!current() || selectedRef.current !== id) return false
if (duty.docId !== id || !Array.isArray(duty.sections)) throw new Error('职责结果与所选文档不一致')
setDetail(duty); setDraft(normalizeDuties(duty.roles))
} catch (e) {
if (current()) setError(readableError(e))
return false
}
if (!current() || !statusCurrent()) return false
try {
const output = await api.rdmProjectCommand<{ submission: Submission | null }>(project, 'rdm.review.doc.status', { docId: id })
if (!current() || !statusCurrent() || selectedRef.current !== id) return false
setSub(output.submission)
return true
} catch (e) {
if (current() && statusCurrent()) setError(`整文审签状态读取失败:${readableError(e)}`)
return false
}
}
const selectDoc = (id: string) => {
if (!depart()) return
fence.cancel('mutation'); setBusy(false); setSelection(new Set()); setBatchDraft(blank()); setNotice('')
void loadDetail(id)
}
useEffect(() => {
fence.invalidate(); clearDetail(); setCatalog(null); setTree(null); setMembers(null); memberNames.current.clear()
setQuery(''); setStage(''); setTypeKey(''); setAssignment(''); setStatus(''); setRoleId(''); setError('')
const current = fence.begin('members')
api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { limit: 50 })
.then(out => { if (current()) { setMembers(out); for (const person of out.members ?? []) memberNames.current.set(person.owner, person) } })
.catch(e => { if (current()) setError(`成员候选无法读取:${readableError(e)}`) })
}, [context])
useEffect(() => {
fence.cancel('catalog')
const timer = setTimeout(() => { void loadCatalog() }, query ? 250 : 0)
return () => { clearTimeout(timer); fence.cancel('catalog') }
}, [context, query, match, stage, typeKey, assignment, status, view])
const memberPage = useCallback(async (query: string, cursor: string, limit: number, purpose: 'author' | 'reviewer' | 'approver'): Promise<PickPage> => {
const current = fence.capture()
const out = await api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { query, cursor, limit, roleId })
if (!current()) return { items: [] }
if (!Array.isArray(out.members) || out.accountDirectoryAvailable !== true) throw new Error('项目成员账号状态尚未就绪')
return { items: out.members.map(person => {
memberNames.current.set(person.owner, person)
const item = personItem(person)
const eligibility = person.dutyEligibility?.[purpose]
return eligibility?.allowed === false ? { ...item, disabled: true, disabledReason: eligibility.reason || '当前成员暂不具备该职责资格' } : item
}), nextCursor: out.next || undefined }
}, [project, roleId, context])
const run = async (message: string, mutation: () => Promise<unknown>, id = selectedRef.current) => {
const current = fence.begin('mutation')
setBusy(true); setError(''); setNotice('')
try {
await mutation()
if (!current()) return
const catalogVerified = await loadCatalog()
if (!current()) return false
const detailVerified = !id || await loadDetail(id)
if (current() && catalogVerified && detailVerified) { setNotice(message); return true }
return false
} catch (e) {
if (!current()) return
setError(`${readableError(e)};请核对最新状态后再操作`)
// Outcome may be unknown after disconnection. Reconcile, never retry a write automatically.
if (id) await loadDetail(id)
if (current()) setError(`${readableError(e)};已尝试读取最新状态,未自动重试写入`)
return false
} finally { if (current()) setBusy(false) }
}
const save = () => {
if (!canEditCurrent || !detail || !validVersion(currentVersion) || !dirty || dutyValidation(draft) || busy) return
const affected = sectionId ? !!active : documentAssignmentImpact(detail, draft)
const confirmed = affected ? window.confirm(`「${detail.title}」${active?.round ? `第 ${active.round} 轮` : '相关章节'}审签将失效,需重新提交。确认职责变更?`) : false
if (affected && !confirmed) return
const payload = assignDocDutiesPayload(project.id, detail.docId, draft.author, draft.reviewers, draft.approver, currentVersion, confirmed)
void run('职责已保存,已重新读取当前状态', () => api.rdmProjectCommand(project,
sectionId ? 'rdm.review.assign' : 'rdm.review.assign.doc', sectionId ? { ...payload, sectionId } : payload))
}
const batchApply = async () => {
if (!canAssign || busy || selection.size === 0 || dutyValidation(batchDraft)) return
const current = fence.begin('batch-preview'); setBusy(true); setError('')
try {
const ids = [...selection]
const preview = await Promise.all(ids.map(id => api.rdmProjectCommand<DutyDetail>(project, 'rdm.duty.get', { docId: id })))
if (!current()) return
if (preview.some((document, index) => document.docId !== ids[index] || !validVersion(document.dutyVersion))) throw new Error('批次中有文档版本未核验,请重新选择')
const affected = preview.filter(document => documentAssignmentImpact(document, batchDraft))
const summary = preview.map(document => `${document.title}${!documentAssignmentImpact(document, batchDraft) ? '' : document.active?.round ? `(第 ${document.active.round} 轮将失效)` : '(章节审签将失效)'}`).join('\n')
if (!window.confirm(`将对以下 ${preview.length} 份文档应用相同职责,任一失败整批不写入:\n${summary}\n编制:${batchDraft.author}\n审核:${batchDraft.reviewers.join('、')}\n批准:${batchDraft.approver}\n${affected.length ? '受影响的审签需重新提交。' : ''}确认分配?`)) return
if (!current()) return
const saved = await run('批量职责已保存,已重新读取目录', () => api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', {
docs: preview.map(document => ({ docId: document.docId, author: batchDraft.author, reviewers: batchDraft.reviewers, approver: batchDraft.approver,
expectedDutyVersion: document.dutyVersion!, confirmInvalidation: affected.length > 0 })),
confirmInvalidation: affected.length > 0,
}))
if (current() && saved) { setSelection(new Set()); setBatchDraft(blank()) }
} catch (e) { if (current()) setError(readableError(e)) }
finally { if (current()) setBusy(false) }
}
const submissionDuties = normalizeDuties(sub?.duties)
const canSubmit = !!detail && detail.assigned && normalizeDuties(detail.roles).author === me?.owner && sub !== undefined && sub?.state !== 'submitted'
const canReview = sub?.state === 'submitted' && !!me?.owner && submissionDuties.reviewers.includes(me.owner)
const canFinal = sub?.state === 'submitted' && submissionDuties.approver === me?.owner && submissionDuties.reviewers.every(owner => sub.reviews?.[owner] === true)
const people = [...memberNames.current.values()]
return <div className="rdm-settings">
<h4 className="settings-group-title">文档职责</h4>
<div className="ad-row">
<input className="lg-input" aria-label="搜索文档" placeholder="输入文档名称或 ID" value={query} onChange={e => changeFilter(() => setQuery(e.target.value))} />
<select className="lg-input" aria-label="文档匹配方式" value={match} onChange={e => changeFilter(() => setMatch(e.target.value as 'exact' | 'fuzzy'))}>
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
</select>
<select className="lg-input" aria-label="文档浏览方式" value={view} onChange={e => { fence.cancel('catalog'); setTree(null); setView(e.target.value as 'list' | 'tree') }}>
<option value="list">列表</option><option value="tree">树形</option>
</select>
</div>
<div className="ad-row rdm-filter-row">
<select className="lg-input" aria-label="阶段筛选" value={stage} onChange={e => changeFilter(() => setStage(e.target.value))}><option value="">全部阶段</option>{(catalog?.filters?.stages ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select className="lg-input" aria-label="文档类型筛选" value={typeKey} onChange={e => changeFilter(() => setTypeKey(e.target.value))}><option value="">全部文档类型</option>{(catalog?.filters?.typeKeys ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select className="lg-input" aria-label="职责分配筛选" value={assignment} onChange={e => changeFilter(() => setAssignment(e.target.value))}><option value="">全部职责</option>{(catalog?.filters?.assignments ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select className="lg-input" aria-label="审签状态筛选" value={status} onChange={e => changeFilter(() => setStatus(e.target.value))}><option value="">全部审签状态</option>{(catalog?.filters?.statuses ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
</div>
{view === 'tree' && tree ? <DutyTree nodes={tree} selected={docId} onSelect={selectDoc} /> : view === 'list' && catalog && <div className="rdm-table-scroll"><table className="ad-table">
<thead><tr><th>选择</th><th>文档</th><th>类型/阶段</th><th>当前职责</th><th>审签状态</th></tr></thead>
<tbody>{catalog.docs.map(document => <tr key={document.docId} className={document.docId === docId ? 'ad-row-on' : ''}>
<td><input type="checkbox" aria-label={`选择 ${document.title}`} checked={selection.has(document.docId)} disabled={!canAssign || busy || (selection.size >= 20 && !selection.has(document.docId))} onChange={e => {
if (dirty && !window.confirm('丢弃当前文档未保存的职责调整,切换到批量分配?')) return
if (docId) { fence.cancel('detail'); setDocId(''); selectedRef.current = ''; setDetail(null); setDraft(blank()); setSub(undefined) }
const next = new Set(selection); e.target.checked ? next.add(document.docId) : next.delete(document.docId); setSelection(next)
}} /></td>
<td><button className="ad-mini" onClick={() => selectDoc(document.docId)}>{document.title}</button><div className="ad-dim">{document.docId}</div></td>
<td>{document.typeLabel || catalog.filters?.typeKeys?.find(option => option.value === document.typeKey)?.label || '文档'}{document.stage && <div className="ad-dim">{document.stageLabel || catalog.filters?.stages?.find(option => option.value === document.stage)?.label || '阶段未提供'}</div>}</td>
<td>{document.assigned ? `编制 ${document.author || '—'} · 审核 ${(document.reviewers ?? []).join('、') || '—'} · 批准 ${document.approver || '—'}` : '未分配'}</td>
<td>{document.activeRound ? `第 ${document.activeRound} 轮审核中` : document.statusLabel || statusLabel(document.status)}{document.activeSections ? <div className="ad-dim">{document.activeSections} 个章节审签中</div> : null}</td>
</tr>)}</tbody>
</table></div>}
{loading && <p className="ad-hint" role="status">加载文档…</p>}
{!loading && catalog?.docs.length === 0 && <p className="ad-hint">没有匹配且可访问的文档</p>}
{!loading && !catalog && <Btn onClick={() => { setError(''); void loadCatalog() }}>重新加载文档</Btn>}
{catalog?.next && <Btn disabled={loading} onClick={() => loadCatalog(catalog.next)}>加载更多文档</Btn>}
{writeError && <p className="ad-hint">{writeError}</p>}
{!canAssign && catalog && <p className="ad-hint">你可以查看有权访问的文档;分配职责需要文档职责管理权限</p>}
{docId && !detail && <p className="ad-hint">{error ? '文档职责未能加载' : '读取当前职责…'}</p>}
{detail && <SettingsGroup title={`${detail.title} · 当前职责`}>
<div className="ad-row"><select className="lg-input" aria-label="职责对象" value={sectionId} onChange={e => {
if (dirty && !window.confirm('丢弃未保存的职责调整?')) return
const id = e.target.value; setSectionId(id); setDraft(normalizeDuties(id ? detail.sections.find(section => section.sectionId === id)?.roles : detail.roles))
}}><option value="">整份文档默认职责</option>{detail.sections.map(section => <option key={section.sectionId} value={section.sectionId}>{section.title}{section.overridden ? ' · 单独指派' : ' · 沿用文档默认'}</option>)}</select>
<RoleFilter roles={roles} value={roleId} onChange={value => { fence.cancel('member-candidates'); setRoleId(value) }} disabled={!canEditCurrent || !!roleError} />
</div>
<DutyPeople value={draft} onChange={setDraft} fetchPage={memberPage} people={people} disabled={!canEditCurrent || busy || !validVersion(currentVersion) || members?.accountDirectoryAvailable !== true} />
{dirty && dutyValidation(draft) && <p className="ad-hint">{dutyValidation(draft)}</p>}
{!validVersion(currentVersion) && <p className="ad-hint">职责版本尚未就绪,只能查看;请升级项目服务后重新读取</p>}
{active && <p className="ad-hint">{active.round ? `第 ${active.round} 轮` : '章节'}审签正在进行,职责变更需要确认后重新提交</p>}
<Btn variant="primary" disabled={!canEditCurrent || busy || !dirty || !validVersion(currentVersion) || !!dutyValidation(draft)} onClick={save}>保存当前职责</Btn>
<p className="ad-hint">当前职责与提交时冻结名单独立。项目管理权限不代表编制、审核或批准资格</p>
</SettingsGroup>}
{detail && <SettingsGroup title={sub ? `整文审签 · 第 ${sub.round} 轮 · ${statusLabel(sub.state)}` : '整文审签'}>
<Btn disabled={busy || !!writeError || !canSubmit} onClick={() => run('整文已提交审核', () => api.rdmProjectCommand(project, 'rdm.review.doc.submit', docSubmitPayload(project.id, docId)))}>整文提交(编制人)</Btn>
{sub && <>
<p>第 {sub.round} 轮 · {statusLabel(sub.state)}</p>
<p className="ad-hint">冻结名单:编制 {submissionDuties.author} · 审核 {submissionDuties.reviewers.join('、')} · 批准 {submissionDuties.approver}</p>
<p className="ad-hint">审核进度:{submissionDuties.reviewers.map(owner => `${owner} ${sub.reviews?.[owner] ? '已通过' : '待审核'}`).join(';')}</p>
<div className="ad-row"><Btn disabled={busy || !!writeError || !canReview} onClick={() => run('已完成审核', () => api.rdmProjectCommand(project, 'rdm.review.doc.approve', docApprovePayload(project.id, docId, '通过')))}>审核通过</Btn>
<Btn disabled={busy || !!writeError || !canReview} onClick={() => { const reason = window.prompt('请输入驳回原因'); if (reason?.trim()) void run('已驳回整文', () => api.rdmProjectCommand(project, 'rdm.review.doc.reject', docRejectPayload(project.id, docId, reason.trim()))) }}>驳回</Btn>
<Btn variant="primary" disabled={busy || !!writeError || !canFinal} onClick={() => run('整文已批准', () => api.rdmProjectCommand(project, 'rdm.review.doc.final', docFinalPayload(project.id, docId, sub.round)))}>终批(批准人)</Btn></div>
</>}
{sub === null && <p className="ad-hint">尚无整文提交记录</p>}
{sub === undefined && <p className="ad-hint">审签状态尚未核验,暂不能执行审签操作</p>}
</SettingsGroup>}
{selection.size > 0 && <SettingsGroup title={`批量分配 · 已选 ${selection.size} 份文档`}>
<p className="ad-hint">最多选择 20 份;提交前读取各文档版本、预览影响,任一失败整批不写入</p>
<RoleFilter roles={roles} value={roleId} onChange={setRoleId} disabled={!canAssign || !!roleError} />
<DutyPeople value={batchDraft} onChange={setBatchDraft} fetchPage={memberPage} people={people} disabled={!canAssign || busy || members?.accountDirectoryAvailable !== true} labelPrefix="批量" />
<div className="ad-row"><Btn variant="primary" disabled={!canAssign || busy || !!dutyValidation(batchDraft)} onClick={batchApply}>预览并分配 {selection.size} 份文档</Btn>
<Btn disabled={busy} onClick={() => { if (!batchDirty || window.confirm('清空已选文档及未保存的批量职责?')) { fence.cancel('batch-preview'); setSelection(new Set()); setBatchDraft(blank()) } }}>清空选择</Btn></div>
</SettingsGroup>}
<Feedback error={error || roleError} notice={notice} />
</div>
}
+145
View File
@@ -0,0 +1,145 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Btn, Picker } from '@ui'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { memberAddPayload, memberRemovePayload, memberSetRolePayload } from '../rdmManagement'
import { Feedback, personItem, personLabel, projectContext, projectRoleLabel, readableError,
RoleFilter, useRequestFence, useRoleOptions, type Person, type MemberList } from './rdmSettingsShared'
export function RdmMembersSection({ project }: { project: Project }) {
const owner = useStore(s => s.me?.owner)
const context = projectContext(project, owner)
const fence = useRequestFence(context)
const writeError = projectWorkError(project)
const [list, setList] = useState<MemberList | null>(null)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [picked, setPicked] = useState<string[]>([])
const [role, setRole] = useState<'member' | 'owner'>('member')
const [roleId, setRoleId] = useState('')
const [match, setMatch] = useState<'exact' | 'fuzzy'>('fuzzy')
const [impact, setImpact] = useState<{ member: string; canRemove: boolean; lastOwner: boolean; reasons: string[]; documents: { docId: string; title?: string; label?: string; sectionId?: string; activeRound?: number }[] } | null>(null)
const { roles, error: roleError } = useRoleOptions(project, 'members', list?.canManageMembers === true)
const candidates = useRef(new Map<string, Person>())
const canManage = list?.canManageMembers === true && !writeError
const directoryReady = list?.accountDirectoryAvailable === true
const rowsRef = useRef(list?.members ?? [])
rowsRef.current = list?.members ?? []
const refresh = async (cursor = '') => {
const current = fence.begin('members')
try {
const out = await api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { cursor, limit: 50 })
if (!current()) return false
if (!Array.isArray(out.members)) throw new Error('成员查询接口未就绪')
setList(prev => cursor && prev ? { ...out, members: [...prev.members, ...out.members] } : out)
return true
} catch (e) { if (current()) { setError(readableError(e)); if (!cursor) setList(null) } return false }
}
useEffect(() => {
setList(null); setPicked([]); candidates.current.clear(); setRoleId(''); setRole('member')
setError(''); setNotice(''); setBusy(false); setImpact(null)
void refresh()
}, [context])
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
const current = fence.begin('candidates')
const out = await api.rdmProjectCommand<{ accounts: Person[]; next?: string }>(project, 'rdm.account.list', { query, cursor, limit, roleId, match })
if (!current()) return { items: [] }
if (!Array.isArray(out.accounts)) throw new Error('账号目录接口未就绪')
const joined = new Set(rowsRef.current.map(person => person.owner))
return { items: out.accounts.map(person => {
const candidate = { ...person, joined: person.joined === true || joined.has(person.owner) }
candidates.current.set(candidate.owner, candidate)
return personItem(candidate, true)
}), nextCursor: out.next || undefined }
}, [project, roleId, match, context])
const run = async (message: string, mutation: () => Promise<unknown>, clearPick = false) => {
if (!canManage || busy) return
const current = fence.begin('mutation')
setBusy(true); setError(''); setNotice('')
try {
await mutation()
if (!current()) return
if (clearPick) setPicked([])
const verified = await refresh()
if (current() && verified) setNotice(message)
} catch (e) {
if (!current()) return
setError(`${readableError(e)};已重新读取成员状态,请核对后再操作`)
await refresh()
} finally { if (current()) setBusy(false) }
}
const pickedPerson = candidates.current.get(picked[0] || '')
const addable = pickedPerson?.valid === true && pickedPerson.joined === false
const previewRemove = async (person: Person) => {
if (!canManage || busy) return
const current = fence.begin('impact'); setBusy(true); setError(''); setImpact(null)
try {
const out = await api.rdmProjectCommand<NonNullable<typeof impact>>(project, 'rdm.project.member.impact', { member: person.owner })
if (!current()) return
if (out.member !== person.owner || typeof out.canRemove !== 'boolean' || !Array.isArray(out.documents) || !Array.isArray(out.reasons)) throw new Error('成员职责影响查询尚未就绪,暂不能移除成员')
setImpact(out)
} catch (e) { if (current()) setError(readableError(e)) }
finally { if (current()) setBusy(false) }
}
return <div className="rdm-settings">
<div className="ad-create">
<div className="ad-row">
<RoleFilter roles={roles} value={roleId} disabled={!canManage || !!roleError} onChange={value => { fence.cancel('candidates'); candidates.current.clear(); setRoleId(value); setPicked([]) }} />
<select aria-label="人员匹配方式" className="lg-input" value={match} disabled={!canManage} onChange={e => { fence.cancel('candidates'); setMatch(e.target.value as 'exact' | 'fuzzy'); setPicked([]) }}>
<option value="fuzzy">模糊匹配</option><option value="exact">精确账号或姓名</option>
</select>
<Picker aria-label="选择人员" selected={picked} onSelect={setPicked} fetchPage={fetchPage}
selectedItems={pickedPerson ? [personItem(pickedPerson)] : []} disabled={!canManage || !directoryReady || busy}
reloadKey={`${context}:${roleId}:${match}:${list?.members.map(p => p.owner).join(',')}`}
placeholder="输入账号或姓名,或浏览候选…" />
<select aria-label="新成员项目身份" className="lg-input" value={role} disabled={!canManage || busy} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option><option value="owner">项目负责人</option>
</select>
<Btn variant="primary" disabled={!canManage || !directoryReady || !addable || busy} onClick={() => {
if (!pickedPerson || !addable) return
void run(`已添加 ${personLabel(pickedPerson)}`, () => api.rdmProjectCommand(project, 'rdm.project.member.add', memberAddPayload(project.id, pickedPerson.owner, role)), true)
}}>添加成员</Btn>
</div>
<p className="ad-hint">专业角色用于查找人员,项目身份用于项目管理。已加入人员请在下表调整身份;最后一位项目负责人不可移除或降级</p>
{list && !list.canManageMembers && <p className="ad-hint">你可以查看成员;添加、调整与移除需要管理项目成员权限</p>}
{list && !directoryReady && <p className="ad-hint">系统账号目录尚未就绪,暂不能添加成员</p>}
{writeError && <p className="ad-hint">{writeError}</p>}
</div>
<div className="rdm-table-scroll"><table className="ad-table">
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th>操作</th></tr></thead>
<tbody>{(list?.members ?? []).map(person => <tr key={person.owner}>
<td>{personLabel(person)}{person.name && <span className="ad-dim"> · {person.owner}</span>}</td>
<td>{person.profRole?.name || '未配置专业角色'}</td><td>{projectRoleLabel(person.role)}</td>
<td className="ad-dim">{person.addedBy || '—'}</td><td className="ad-dim">{person.addedAt ? new Date(person.addedAt).toLocaleString() : '—'}</td>
<td><button className="ad-mini" disabled={!canManage || busy} onClick={() => {
const next = person.role === 'owner' ? 'member' : 'owner'
if (window.confirm(`将「${personLabel(person)}」调整为${projectRoleLabel(next)}?`)) void run('已调整项目身份', () => api.rdmProjectCommand(project, 'rdm.project.member.set-role', memberSetRolePayload(project.id, person.owner, next)))
}}>{person.role === 'owner' ? '降为成员' : '升为负责人'}</button>{' '}
<button className="ad-mini" disabled={!canManage || busy} onClick={() => previewRemove(person)}>移除</button></td>
</tr>)}{!list && <tr><td colSpan={6}>{error ? '成员未能加载' : '加载中…'}</td></tr>}
{list?.members.length === 0 && <tr><td colSpan={6}>暂无成员</td></tr>}
</tbody>
</table></div>
{list?.next && <Btn disabled={busy} onClick={() => refresh(list.next)}>加载更多成员</Btn>}
{error && !list && <Btn onClick={() => { setError(''); void refresh() }}>重试</Btn>}
{impact && <div className="settings-group"><h4>移除成员 · {list?.members.find(person => person.owner === impact.member)?.name || impact.member}</h4>
{impact.documents.length > 0 && <><p className="ad-hint">该成员仍承担以下文档职责,请先在文档职责中完成交接</p><ul>{impact.documents.map(document => <li key={`${document.docId}:${document.sectionId || ''}`}>
{document.title || document.label || '受控文档'} <span className="ad-dim">{document.docId}{document.sectionId ? ' · 章节职责' : ''}{document.activeRound ? ` · 第 ${document.activeRound} 轮审核中` : ''}</span>
</li>)}</ul></>}
{impact.reasons.map(reason => <p key={reason} className="ad-hint">{reason}</p>)}
{impact.lastOwner && <p className="ad-hint">最后一位项目负责人不能移除,请先指定其他负责人</p>}
<div className="ad-row"><Btn disabled={busy || !canManage || !impact.canRemove || impact.lastOwner || impact.documents.length > 0} onClick={() => {
if (window.confirm(`确认移除「${impact.member}」?`)) void run('已移除项目成员', async () => { await api.rdmProjectCommand(project, 'rdm.project.member.remove', memberRemovePayload(project.id, impact.member)); setImpact(null) })
}}>确认移除</Btn><Btn disabled={busy} onClick={() => { fence.cancel('impact'); setImpact(null) }}>取消</Btn></div>
</div>}
<Feedback error={error || roleError} notice={notice} />
</div>
}
+192
View File
@@ -0,0 +1,192 @@
import { useCallback, useEffect, useState } from 'react'
import { Btn, Collapsible, Picker } from '@ui'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { actionLabel, describeSource } from '../rdmManagement'
import { Feedback, personItem, projectContext, projectRoleLabel, readableError, RoleFilter,
useRequestFence, useRoleOptions, type MemberList } from './rdmSettingsShared'
export interface MatrixAction {
action: string; label?: string; scope?: string; allowed: boolean; defaultAllowed?: boolean; capAllowed?: boolean
override?: null | boolean; source: string; sourceLabel?: string; reason?: string
}
interface MatrixRow {
member: string; name?: string; role: string; profRole?: { roleId: string; name: string } | null; actions: MatrixAction[]
}
interface MatrixOut extends MatrixRow { projectId: string; canConfigurePermissions?: boolean }
interface MatrixBatch { projectId: string; rows: MatrixRow[]; next?: string; count?: number; canConfigurePermissions?: boolean }
export function projectActions(actions: MatrixAction[]): MatrixAction[] {
return actions.filter(action => action.scope === 'project' && action.action !== 'project.create')
}
export function overrideMode(action: MatrixAction): 'default' | 'allow' | 'deny' | 'unavailable' {
return action.override === null ? 'default' : action.override === true ? 'allow' : action.override === false ? 'deny' : 'unavailable'
}
const actionGroup = (action: string) => action.startsWith('doc.') || action.startsWith('review.') || action === 'duty.assign' ? 'documents'
: action.startsWith('trace.') || action.startsWith('knowledge.') ? 'knowledge' : 'project'
const reasonText = (action: MatrixAction) => {
if (!action.reason) return action.allowed ? '具备项目动作权限;具体资源仍按职责与状态核验' : '当前项目策略未允许'
if (action.reason.includes('project.create') || action.reason === action.action) return '配置范围不符'
return /[\u4e00-\u9fff]/.test(action.reason) ? action.reason : '当前策略未允许,请联系项目负责人核对'
}
export function ActionDetailTable({ actions, canManage = false, busy = false, onOverride }: {
actions: MatrixAction[]; canManage?: boolean; busy?: boolean
onOverride?: (action: string, mode: 'default' | 'allow' | 'deny') => void
}) {
return <div className="rdm-table-scroll"><table className="ad-table">
<thead><tr><th>项目操作</th><th>权限</th><th>来源</th><th>说明</th>{canManage && <th>项目内配置</th>}</tr></thead>
<tbody>{projectActions(actions).map(action => {
const mode = overrideMode(action)
const supported = mode !== 'unavailable' && typeof action.capAllowed === 'boolean' && typeof action.defaultAllowed === 'boolean'
return <tr key={action.action}><td>{actionLabel(action.action, action.label)}</td>
<td><span className={`ad-chip${action.allowed ? ' ad-chip-admin' : ' ad-chip-warn'}`}>{action.allowed ? '允许' : '禁止'}</span></td>
<td>{action.sourceLabel && action.sourceLabel !== action.source ? action.sourceLabel : describeSource(action.source)}</td>
<td>{reasonText(action)}<Collapsible trigger="查看权限依据">
<p className="ad-hint">角色默认:{action.defaultAllowed === true ? '允许' : action.defaultAllowed === false ? '禁止' : '未提供'} · 管理员上限:{action.capAllowed === true ? '允许' : action.capAllowed === false ? '禁止' : '未提供'}</p>
<p className="ad-hint">项目内配置:{mode === 'default' ? '沿用默认' : mode === 'allow' ? '允许' : mode === 'deny' ? '禁止' : '尚未核验'}</p>
</Collapsible></td>
{canManage && <td><select className="lg-input" aria-label={`${actionLabel(action.action, action.label)}项目覆盖`} value={mode} disabled={busy || !supported}
onChange={e => onOverride?.(action.action, e.target.value as 'default' | 'allow' | 'deny')}>
{!supported && <option value="unavailable">配置尚未核验</option>}
<option value="default">沿用角色默认</option><option value="allow" disabled={action.capAllowed !== true}>项目内允许</option><option value="deny">项目内禁止</option>
</select>{action.capAllowed === false && <p className="ad-hint">管理员上限禁止授予</p>}</td>}
</tr>
})}</tbody>
</table></div>
}
export function RdmPermMatrixSection({ project }: { project: Project }) {
const me = useStore(s => s.me)
const context = projectContext(project, me?.owner)
const fence = useRequestFence(context)
const writeError = projectWorkError(project)
const [view, setView] = useState<'member' | 'matrix'>('member')
const [member, setMember] = useState(me?.owner || '')
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
const [batch, setBatch] = useState<MatrixBatch | null>(null)
const [canConfigure, setCanConfigure] = useState(false)
const [query, setQuery] = useState('')
const [roleId, setRoleId] = useState('')
const [projectRole, setProjectRole] = useState('')
const [group, setGroup] = useState('all')
const [expanded, setExpanded] = useState<{ member: string; action: string } | null>(null)
const [busy, setBusy] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const { roles, error: roleError } = useRoleOptions(project, 'permissions', canConfigure)
const queryMember = async (owner: string) => {
if (!owner) return false
const current = fence.begin('read')
setMatrix(null); setLoading(true); setError('')
try {
const out = await api.rdmProjectCommand<MatrixOut>(project, 'rdm.perm.check', { member: owner })
if (!current()) return false
if (out.member !== owner || !Array.isArray(out.actions)) throw new Error('权限查询结果与成员不一致')
setCanConfigure(out.canConfigurePermissions === true)
setMatrix(out)
return true
} catch (e) { if (current()) { setError(readableError(e)); setCanConfigure(false) } return false }
finally { if (current()) setLoading(false) }
}
const queryBatch = async (cursor = '') => {
if (!canConfigure) return false
const current = fence.begin('read')
setLoading(true); setError(''); if (!cursor) setBatch(null)
try {
const out = await api.rdmProjectCommand<MatrixBatch>(project, 'rdm.perm.matrix', { query, roleId, projectRole, cursor, limit: 20 })
if (!current()) return false
if (out.canConfigurePermissions !== true || !Array.isArray(out.rows)) throw new Error('没有查看项目权限矩阵的能力')
setBatch(previous => cursor && previous ? { ...out, rows: [...previous.rows, ...out.rows] } : out)
return true
} catch (e) { if (current()) setError(readableError(e)); return false }
finally { if (current()) setLoading(false) }
}
useEffect(() => {
fence.invalidate(); setView('member'); setMember(me?.owner || ''); setBatch(null); setMatrix(null)
setCanConfigure(false); setExpanded(null); setQuery(''); setRoleId(''); setProjectRole(''); setBusy(false); setNotice(''); setError('')
void queryMember(me?.owner || '')
}, [context])
useEffect(() => {
if (view !== 'matrix' || !canConfigure) return
fence.cancel('read'); setExpanded(null)
const timer = setTimeout(() => { void queryBatch() }, query ? 250 : 0)
return () => { clearTimeout(timer); fence.cancel('read') }
}, [view, query, roleId, projectRole, canConfigure])
const memberPage = useCallback(async (query: string, cursor: string, limit: number) => {
const out = await api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { query, roleId, projectRole, cursor, limit })
if (!Array.isArray(out.members) || out.canConfigurePermissions !== true) throw new Error('无权选择其他项目成员')
return { items: out.members.map(person => personItem(person)), nextCursor: out.next || undefined }
}, [project, roleId, projectRole])
const override = async (owner: string, actionId: string, mode: 'allow' | 'deny' | 'default') => {
const row = view === 'member' ? matrix : batch?.rows.find(row => row.member === owner)
const action = row?.actions.find(action => action.action === actionId && action.scope === 'project' && action.action !== 'project.create')
if (!canConfigure || writeError || busy || !action || overrideMode(action) === 'unavailable' || (mode === 'allow' && action.capAllowed !== true)) return
const current = fence.begin('mutation')
setBusy(true); setNotice(''); setError('')
try {
await api.rdmProjectCommand(project, mode === 'default' ? 'rdm.perm.unset' : 'rdm.perm.set',
{ member: owner, action: actionId, ...(mode === 'default' ? {} : { allow: mode === 'allow' }) })
if (!current()) return
const verified = view === 'matrix' ? await queryBatch() : await queryMember(owner)
if (current() && verified) setNotice(`${actionLabel(actionId)}:${mode === 'default' ? '已恢复角色默认' : mode === 'allow' ? '已配置为项目内允许' : '已配置为项目内禁止'}`)
} catch (e) {
if (!current()) return
const failure = readableError(e)
if (view === 'matrix') await queryBatch(); else await queryMember(owner)
if (current()) setError(`${failure};已尝试核对最新权限,未自动重试写入`)
} finally { if (current()) setBusy(false) }
}
const visible = (actions: MatrixAction[]) => projectActions(actions).filter(action => group === 'all' || actionGroup(action.action) === group)
const matrixActions = [...new Map((batch?.rows ?? []).flatMap(row => visible(row.actions)).map(action => [action.action, action])).values()]
const expandedRow = expanded ? batch?.rows.find(row => row.member === expanded.member) : undefined
return <div className="rdm-settings">
<div className="ad-create"><div className="ad-row">
<select aria-label="权限查看方式" className="lg-input" value={view} disabled={busy} onChange={e => {
const next = e.target.value as 'member' | 'matrix'
if (next === 'matrix' && !canConfigure) return
fence.cancel('read'); fence.cancel('mutation'); setExpanded(null); setNotice(''); setView(next)
if (next === 'member') void queryMember(member || me?.owner || '')
}}><option value="member">按成员查看</option><option value="matrix" disabled={!canConfigure}>权限矩阵</option></select>
<select aria-label="权限分组" className="lg-input" value={group} onChange={e => { setGroup(e.target.value); setExpanded(null) }}>
<option value="all">全部项目操作</option><option value="project">项目与成员</option><option value="documents">文档与审签</option><option value="knowledge">知识与追溯</option>
</select>
<RoleFilter roles={roles} value={roleId} disabled={!canConfigure || !!roleError || busy} onChange={value => { fence.cancel('read'); setRoleId(value); setExpanded(null) }} />
<select aria-label="项目身份筛选" className="lg-input" value={projectRole} disabled={!canConfigure || busy} onChange={e => { fence.cancel('read'); setProjectRole(e.target.value); setExpanded(null) }}>
<option value="">全部项目身份</option><option value="owner">项目负责人</option><option value="member">项目成员</option>
</select>
{view === 'member' && <Picker aria-label="选择权限成员" selected={member ? [member] : []}
selectedItems={[{ id: member, title: matrix?.name || (member === me?.owner ? me?.name || member : member), subtitle: member }]}
onSelect={ids => { fence.cancel('read'); setMember(ids[0] || me?.owner || ''); void queryMember(ids[0] || me?.owner || '') }}
fetchPage={memberPage} disabled={!canConfigure || busy} reloadKey={`${context}:${roleId}:${projectRole}`} />}
{view === 'matrix' && <input aria-label="搜索权限成员" className="lg-input" placeholder="输入账号或姓名" value={query} onChange={e => { fence.cancel('read'); setBatch(null); setQuery(e.target.value) }} />}
<Btn disabled={loading || busy} onClick={() => view === 'matrix' ? queryBatch() : queryMember(member)}>刷新权限</Btn>
</div>
<p className="ad-hint">默认显示自己的权限。查看其他成员、矩阵和配置需要权限管理能力;文档操作仍按具体资源、职责和审签状态核验</p>
</div>
{writeError && <p className="ad-hint">{writeError}</p>}
{loading && <p className="ad-hint" role="status">核验项目权限…</p>}
{view === 'member' && matrix && <>
<p>{matrix.name || matrix.member} · {projectRoleLabel(matrix.role)}{matrix.profRole?.name ? ` · ${matrix.profRole.name}` : ''}</p>
<ActionDetailTable actions={visible(matrix.actions)} canManage={canConfigure && !writeError} busy={busy} onOverride={(action, mode) => override(matrix.member, action, mode)} />
</>}
{view === 'matrix' && batch && <>
<div className="rdm-table-scroll"><table className="ad-table">
<thead><tr><th>成员</th>{matrixActions.map(action => <th key={action.action}>{actionLabel(action.action, action.label)}</th>)}</tr></thead>
<tbody>{batch.rows.map(row => <tr key={row.member}><td>{row.name || row.member}<div className="ad-dim">{projectRoleLabel(row.role)}{row.profRole?.name ? ` · ${row.profRole.name}` : ''}</div></td>
{matrixActions.map(action => {
const permission = row.actions.find(item => item.action === action.action && item.scope === 'project')
return <td key={action.action}>{permission ? <button className="ad-mini" aria-label={`${row.name || row.member} · ${actionLabel(action.action)} · ${permission.allowed ? '允许' : '禁止'}`}
onClick={() => setExpanded({ member: row.member, action: action.action })}>{permission.allowed ? '允许' : '禁止'}</button> : '未核验'}</td>
})}</tr>)}</tbody>
</table></div>
{batch.rows.length === 0 && <p className="ad-hint">没有匹配的项目成员</p>}
{batch.next && <Btn disabled={loading || busy} onClick={() => queryBatch(batch.next)}>加载更多成员</Btn>}
{expanded && <div className="settings-group"><h4>{expandedRow?.name || expanded.member} · 权限依据</h4>
<ActionDetailTable actions={expandedRow?.actions.filter(action => action.action === expanded.action) || []}
canManage={canConfigure && !writeError} busy={busy} onOverride={(action, mode) => override(expanded.member, action, mode)} /></div>}
</>}
<Feedback error={error || (canConfigure ? roleError : '')} notice={notice} />
</div>
}
+104
View File
@@ -0,0 +1,104 @@
import { Btn, SettingsGroup, SettingRow } from '@ui'
import type { SettingDesc } from '../contracts'
const groupNames: Record<string, string> = {
models: '模型与密钥', runtime: '运行环境', context: '上下文', retrieval: '检索',
ui: '界面', plugins: '系统插件', crossSession: '个人会话协作', tools: '工具服务', deliverables: '项目交付规则', telemetry: '运行观测',
}
const optionLabels: Record<string, string> = { modern: '现代', formal: '正式', minimal: '简洁',
dark: '深色', light: '浅色', system: '跟随系统', zh: '中文', en: '英文', auto: '自动', off: '关闭', on: '开启',
disabled: '关闭', enabled: '开启', relevant: '相关会话', all: '全部本人会话', none: '关闭',
direct: '直接对话', squad: '协作小队', zhCN: '简体中文', 'zh-CN': '简体中文', 'en-US': '英语',
}
function readableValue(value: unknown) {
if (value === true) return '开启'
if (value === false) return '关闭'
if (value === undefined || value === null || value === '') return '未配置'
const text = String(value)
return optionLabels[text] || text
}
export function scopedSchema(schema: SettingDesc[], scope: string) {
return schema.filter(desc => !desc.scope
? scope === 'user' || scope === 'project-personal'
: desc.scope === scope || (scope === 'project-personal' && desc.scope === 'user'))
}
export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } {
return desc.secret || desc.type === 'secret' ? { type: 'password', autoComplete: 'new-password' } : { type: 'text', autoComplete: 'off' }
}
export function SettingInput({ desc, value, onChange, disabled = false }: { desc: SettingDesc; value: any; onChange: (value: any) => void; disabled?: boolean }) {
if (desc.type === 'boolean') return <select aria-label={desc.label} value={value === true ? 'true' : value === false ? 'false' : ''} disabled={disabled}
onChange={e => onChange(e.target.value === '' ? undefined : e.target.value === 'true')}>
<option value="">沿用默认({readableValue(desc.default)})</option><option value="true">开启</option><option value="false">关闭</option>
</select>
if (desc.type === 'enum') return <select aria-label={desc.label} value={value ?? ''} disabled={disabled} onChange={e => onChange(e.target.value || undefined)}>
<option value="">沿用默认({readableValue(desc.default)})</option>
{(desc.options ?? []).map(option => <option key={option} value={option}>{optionLabels[option] || option}</option>)}
</select>
if (desc.type === 'number') return <input aria-label={desc.label} type="number" step="any" disabled={disabled} value={value ?? ''}
placeholder={`默认:${readableValue(desc.default)}`} onChange={e => onChange(e.target.value === '' || !Number.isFinite(e.target.valueAsNumber) ? undefined : e.target.valueAsNumber)} />
const secret = desc.secret || desc.type === 'secret'
return <input aria-label={desc.label} {...settingInputAttrs(desc)} disabled={disabled} value={value === '••••••' ? '' : value ?? ''}
placeholder={secret ? value === '••••••' ? '已配置,输入以替换' : '未配置,输入密钥' : `默认:${readableValue(desc.default)}`}
onChange={e => onChange(secret ? e.target.value || undefined : e.target.value)} />
}
export function SettingsFields({ schema, draft, defaults = {}, onChange, disabled = false, source = '个人配置' }: {
schema: SettingDesc[]; draft: Record<string, any>; defaults?: Record<string, any>
onChange: (key: string, value: any) => void; disabled?: boolean; source?: string
}) {
const groups: Record<string, SettingDesc[]> = {}
for (const desc of schema) (groups[desc.group] ??= []).push(desc)
return <>{Object.entries(groups).map(([group, descs]) => <SettingsGroup key={group} title={groupNames[group] || '其他设置'}>
{descs.map(desc => <SettingRow key={desc.key} label={desc.label} effect={desc.effect}
desc={`${desc.description || ''}${desc.description ? ' · ' : ''}${draft[desc.key] === undefined ? `沿用默认:${desc.secret ? '受保护配置' : readableValue(defaults[desc.key] ?? desc.default)}` : source}`}>
<SettingInput desc={{ ...desc, default: defaults[desc.key] ?? desc.default }} value={draft[desc.key]} disabled={disabled} onChange={value => onChange(desc.key, value)} />
{desc.type === 'string' && !desc.secret && <Btn disabled={disabled || draft[desc.key] === undefined} onClick={() => onChange(desc.key, undefined)}>沿用默认</Btn>}
</SettingRow>)}
</SettingsGroup>)}</>
}
export function flatten(map: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
const visit = (object: Record<string, any>, prefix = '') => {
for (const [key, value] of Object.entries(object || {})) {
const path = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object' && !Array.isArray(value)) visit(value, path)
else out[path] = value
}
}
visit(map); return out
}
function unflatten(map: Record<string, any>, preserveEmpty = false): Record<string, any> {
const out: Record<string, any> = {}
for (const [path, value] of Object.entries(map)) {
if (value === undefined || (value === '' && !preserveEmpty)) continue
const parts = path.split('.')
let current = out
for (const part of parts.slice(0, -1)) current = current[part] ??= {}
current[parts[parts.length - 1]] = value
}
return out
}
export function scopedDraft(values: Record<string, unknown>, schema: SettingDesc[]) {
const keys = new Set(schema.map(desc => desc.key))
return Object.fromEntries(Object.entries(flatten(values)).filter(([key]) => keys.has(key)))
}
/** A settings write is a merge patch; omitted overrides are deliberately removed. */
export function settingsMutation(initial: Record<string, any>, draft: Record<string, any>, schema: SettingDesc[]) {
const changed: Record<string, any> = {}
const unset: string[] = []
for (const desc of schema) {
const next = draft[desc.key]
if (Object.is(initial[desc.key], next)) continue
if (desc.secret || desc.type === 'secret') {
if (next === undefined || next === '••••••') continue
changed[desc.key] = next
} else if (next === undefined) unset.push(desc.key)
else changed[desc.key] = next
}
return { values: unflatten(changed, true), unset }
}
export function personalSettingsMutation(initial: Record<string, any>, draft: Record<string, any>, schema: SettingDesc[]) {
const mutation = settingsMutation(initial, draft, schema)
const values = flatten(mutation.values)
for (const key of mutation.unset) values[key] = null
return unflatten(values, true)
}
+98 -153
View File
@@ -1,165 +1,110 @@
// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义,
// 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。
// 重设计方案 阶段②(IA):设置瘦身——只留个人偏好(常规);智能体画像/插件管理/
// 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { useEffect, useState } from 'react'
import { api, type SettingsView } from '../api'
import { isFontSizeChoice, store, useStore } from '../store'
import type { ThemeChoice } from '../theme'
import type { SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
import { SettingsShell, SettingRow, SettingsGroup, Btn, EmptyState } from '@ui'
import { BorrowPanels } from './BorrowPanels'
import { flatten, scopedDraft, scopedSchema, SettingsFields, personalSettingsMutation } from './SettingsFields'
import { Feedback, readableError, useRequestFence } from './rdmSettingsShared'
type Section = 'settings'
// 分组显示名(项目设置面板共用)。
export const groupNames: Record<string, string> = {
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具',
}
export { SettingInput, settingInputAttrs } from './SettingsFields'
export function SettingsModal() {
const [section] = useState<Section>('settings')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
const [saved, setSaved] = useState(false)
const owner = useStore(s => s.me?.owner)
const projectId = useStore(s => s.currentProjectId)
const projectName = useStore(s => s.projects.find(project => project.id === projectId)?.name)
const theme = useStore(s => s.ui.theme)
const fontSize = useStore(s => s.ui.fontSize)
const fence = useRequestFence(`${owner || ''}:${projectId || ''}`)
const [section, setSection] = useState('settings')
const [settings, setSettings] = useState<SettingsView | null>(null)
const [projectSettings, setProjectSettings] = useState<SettingsView | null>(null)
const [draft, setDraft] = useState<Record<string, any>>({})
const [personalDraft, setPersonalDraft] = useState<Record<string, any>>({})
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const schema = settings ? scopedSchema(settings.schema, 'user') : []
const personalSchema = projectSettings ? scopedSchema(projectSettings.personalSchema || [], 'project-personal') : []
const initial = settings ? scopedDraft(settings.user, schema) : {}
const personalInitial = projectSettings ? scopedDraft(projectSettings.personalProject || {}, personalSchema) : {}
const dirty = JSON.stringify(draft) !== JSON.stringify(initial) || JSON.stringify(personalDraft) !== JSON.stringify(personalInitial)
const load = async (projectOnly = false) => {
const current = fence.begin('load')
setError('')
try {
if (!projectOnly) {
const out = await api.getSettings()
if (!current()) return false
setSettings(out); setDraft(scopedDraft(out.user, scopedSchema(out.schema, 'user')))
}
if (projectId) {
const out = await api.getSettings(projectId)
if (!current()) return false
const personal = scopedSchema(out.personalSchema || [], 'project-personal')
setProjectSettings(out); setPersonalDraft(scopedDraft(out.personalProject || {}, personal))
}
return true
} catch (e) { if (current()) setError(`个人设置无法读取:${readableError(e)}`); return false }
}
useEffect(() => {
api.getSettings().then(s => {
setSchema(s.schema)
setUser(flatten(s.user))
setDraft(flatten(s.user))
}).catch(() => undefined)
}, [])
const groups = useMemo(() => {
const g: Record<string, SettingDesc[]> = {}
for (const d of schema) (g[d.group] ??= []).push(d)
return g
}, [schema])
fence.invalidate(); setSettings(null); setProjectSettings(null); setDraft({}); setPersonalDraft({}); setNotice(''); setBusy(false)
void load()
}, [owner, projectId])
const depart = () => !dirty || window.confirm('个人设置尚未保存,离开后将丢弃这些改动。继续?')
const save = async () => {
const values = unflatten(draft)
await api.putUserSettings(values)
setUser(draft)
setSaved(true)
setTimeout(() => setSaved(false), 1500)
// hot 类设置即时生效:主题。
if (draft['ui.theme']) store.setUI({ theme: draft['ui.theme'] })
store.refreshPlugins()
if (busy || (section === 'settings' ? !settings : !projectSettings?.personalSchema)) return
const current = fence.begin('save')
setBusy(true); setError(''); setNotice('')
try {
if (section === 'settings') await api.putUserSettings(personalSettingsMutation(initial, draft, schema))
else if (projectId) await api.putPersonalProjectSettings(projectId, personalSettingsMutation(personalInitial, personalDraft, personalSchema))
if (!current()) return
if (await load()) {
if (!current()) return
setNotice('个人偏好已保存;按各项标注的生效时机应用')
void store.refreshPlugins()
}
} catch (e) {
if (!current()) return
const message = `保存结果未能确认:${readableError(e)};请核对最新设置后再保存`
await load()
if (current()) setError(message)
} finally { if (current()) setBusy(false) }
}
return (
<SettingsShell
title="设置" active={section} onSelect={() => undefined}
onClose={() => store.openSettings(false)}
sections={[
{ key: 'settings', label: '常规', icon: 'settings' },
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
actions={section === 'settings' && schema.length > 0 ? (
<>
{saved && <span style={{ color: 'var(--ok)', fontSize: 'var(--fs-ui-sm)', marginRight: 8 }}>✓ 已保存</span>}
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
</>
) : undefined}
>
{section === 'settings' && (
<SettingsGroup title="外观">
<SettingRow label="主题" desc="墨绿是可选身份皮肤。跟随系统只在深色与浅色之间切换。深色和浅色会随设置保存" effect="hot">
<select value={theme} onChange={e => {
const next = e.target.value as ThemeChoice
store.setUI({ theme: next })
if (next === 'dark' || next === 'light') setDraft(x => ({ ...x, 'ui.theme': next }))
}}>
<option value="dark">深色</option>
<option value="light">浅色</option>
<option value="molv">墨绿</option>
<option value="system">跟随系统</option>
</select>
</SettingRow>
<SettingRow label="界面字号" desc="小、标准、大、特大。只保存在这台设备上,图标和间距不跟随缩放" effect="hot">
<select value={fontSize} onChange={e => {
const next = Number(e.target.value)
if (isFontSizeChoice(next)) store.setUI({ fontSize: next })
}}>
<option value={13}>小</option>
<option value={14}>标准</option>
<option value={16}>大</option>
<option value={18}>特大</option>
</select>
</SettingRow>
</SettingsGroup>
)}
{section === 'settings' && Object.entries(groups).map(([g, descs]) => {
const rows = descs.filter(d => d.key !== 'ui.theme')
if (rows.length === 0) return null
return (
<SettingsGroup key={g} title={groupNames[g] ?? g}>
{rows.map(d => (
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
</SettingRow>
))}
</SettingsGroup>
)
})}
{section === 'settings' && <BorrowPanels />}
</SettingsShell>
)
return <SettingsShell title="个人设置" active={section}
onSelect={next => { if (!busy && depart()) { setDraft(initial); setPersonalDraft(personalInitial); setNotice(''); setSection(next) } }}
onClose={() => { if (depart()) { fence.invalidate(); store.openSettings(false) } }}
sections={[{ key: 'settings', label: '个人偏好', icon: 'settings' }, ...(projectId ? [{ key: 'project-personal', label: '我在当前项目的偏好', icon: 'folder' as const }] : [])]}
footer="个人偏好只影响本人;项目业务规则在项目设置中管理"
actions={(section === 'settings' ? settings : projectSettings?.personalSchema) ? <Btn variant="primary" icon="check" disabled={busy || !dirty} onClick={save}>保存个人设置</Btn> : undefined}>
{section === 'settings' && <>
<SettingsGroup title="外观">
<SettingRow label="主题" desc="立即应用到这台设备;深色和浅色可随个人偏好保存" effect="hot">
<select value={theme} aria-label="主题" disabled={busy || !settings} onChange={e => {
const value = e.target.value as ThemeChoice; store.setUI({ theme: value })
if (value === 'dark' || value === 'light') setDraft(previous => ({ ...previous, 'ui.theme': value }))
}}><option value="dark">深色</option><option value="light">浅色</option><option value="molv">墨绿</option><option value="system">跟随系统</option></select>
</SettingRow>
<SettingRow label="界面字号" desc="仅保存在这台设备上;图标和间距不随字号缩放" effect="hot">
<select value={fontSize} aria-label="界面字号" onChange={e => { const value = Number(e.target.value); if (isFontSizeChoice(value)) store.setUI({ fontSize: value }) }}>
<option value={13}>小</option><option value={14}>标准</option><option value={16}>大</option><option value={18}>特大</option>
</select>
</SettingRow>
</SettingsGroup>
{!settings && <><EmptyState icon="settings">{error || '读取个人偏好…'}</EmptyState>{error && <Btn onClick={() => load()}>重试</Btn>}</>}
{settings && <SettingsFields schema={schema.filter(desc => desc.key !== 'ui.theme')} draft={draft} defaults={flatten(settings.defaults)} disabled={busy} onChange={(key, value) => setDraft(previous => ({ ...previous, [key]: value }))} />}
<BorrowPanels />
</>}
{section === 'project-personal' && <>
<p className="ad-hint">当前项目:{projectName || '尚未选择'}。以下偏好仅影响本人;跨会话读取仅访问本人授权的会话,不开放其他成员的私聊、原件或草稿</p>
{!projectSettings && <><EmptyState icon="settings">{error || '读取当前项目的个人偏好…'}</EmptyState>{error && <Btn onClick={() => load(true)}>重试</Btn>}</>}
{projectSettings && !projectSettings.personalSchema && <EmptyState icon="settings">项目个人偏好接口尚未就绪,请升级服务后重试</EmptyState>}
{projectSettings?.personalSchema && personalSchema.length === 0 && <EmptyState icon="settings">当前没有可调整的个人项目偏好</EmptyState>}
{projectSettings?.personalSchema && <SettingsFields schema={personalSchema} draft={personalDraft}
defaults={{ ...flatten(projectSettings.personalDefaults || {}), ...flatten(projectSettings.personalUser || {}) }} source="本人在当前项目的偏好" disabled={busy}
onChange={(key, value) => setPersonalDraft(previous => ({ ...previous, [key]: value }))} />}
</>}
<Feedback error={error} notice={notice} />
</SettingsShell>
}
// 输入框语义(2026-10-12 增量 3):仅秘密字段用密码框;普通文本用 text——
// 无条件 type="password" 会把整排设置项当凭据表单(浏览器「password 不在 form 中/
// 缺少 username」警告来源之一),也把普通文本无谓遮蔽。
export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } {
const isSecret = desc.secret || desc.type === 'secret'
return isSecret
? { type: 'password', autoComplete: 'new-password' }
: { type: 'text', autoComplete: 'off' }
}
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
if (desc.type === 'boolean') {
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
}
if (desc.type === 'enum') {
return <select value={value} onChange={e => onChange(e.target.value)}>
{(desc.options ?? []).map(o => <option key={o} value={o}>{o}</option>)}
</select>
}
const isSecret = desc.secret || desc.type === 'secret'
const attrs = settingInputAttrs(desc)
return <input type={attrs.type} autoComplete={attrs.autoComplete} style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
onChange={e => onChange(e.target.value)} />
}
// 点路径扁平化/还原。
function flat(m: Record<string, any>, prefix: string, out: Record<string, any>) {
for (const [k, v] of Object.entries(m)) {
const key = prefix ? `${prefix}.${k}` : k
if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out)
else out[key] = v
}
}
export function flatten(m: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
flat(m, '', out)
return out
}
export function unflatten(flatMap: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(flatMap)) {
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
const parts = k.split('.')
let cur = out
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]] ??= {}
cur[parts[parts.length - 1]] = v
}
return out
}
+13 -8
View File
@@ -7,7 +7,7 @@ import { api } from '../api'
import { Icon } from '@ui'
import type { Conversation } from '../contracts'
import { relativeTime, filterTasks } from '../tasklist'
import { projectStage } from '../projectmeta'
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
import { buildWorkspaceEntries } from '../workspaces'
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
@@ -68,8 +68,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const [newProjectName, setNewProjectName] = useState('')
const createConversation = async (projectId?: string) => {
await store.createTask(projectId)
onConversationOpen?.()
try {
await store.createTask(projectId)
onConversationOpen?.()
} catch { /* store owns the actionable failure toast */ }
}
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
@@ -77,7 +79,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const currentProject = projects.find(p => p.id === currentProjectId)
const stage = projectStage(currentProject?.settings)
const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings)
const workError = currentProject ? projectWorkError(currentProject) : null
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
@@ -162,8 +165,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
onChange={e => setNewProjectName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && newProjectName.trim()) {
await store.createProject(newProjectName.trim())
setCreatingProject(false); setNewProjectName('')
try {
await store.createProject(newProjectName.trim())
setCreatingProject(false); setNewProjectName('')
} catch { /* keep the input available for a corrected name */ }
}
if (e.key === 'Escape') setCreatingProject(false)
}}
@@ -179,7 +184,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
)}
{rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">从「新建对话」开始,描述你想完成的工作。</div>
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
)}
{rest.map(t => taskRow(t))}
</div>
@@ -224,7 +229,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
{projects.map(p => {
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
const pstage = projectStage(p.settings)
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
return (
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
{p.name}
+59 -277
View File
@@ -3,16 +3,14 @@
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { useStore } from '../store'
import { Btn, EmptyState, Icon } from '@ui'
import { EmptyState, Icon } from '@ui'
import {
ROLE_ACTIONS, parseReviewers,
ROLE_ACTIONS,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload,
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload,
docFinalPayload, docStatusPayload, permCheckPayload, describeSource,
actionLabel,
rolesListPlaceholder, describeRoleListError,
} from '../rdmManagement'
@@ -27,28 +25,57 @@ interface RoleView {
accounts: string[] | null
}
interface RoleActionView { action: string; label: string; scope: 'project' | 'system'; configurable: boolean }
interface RoleListView {
roles: RoleView[]
roleActions?: RoleActionView[]
systemActions?: RoleActionView[]
canManageRoles?: boolean
canAssignRoles?: boolean
}
export function RdmRolesPanel() {
const me = useStore(s => s.me)
const [roles, setRoles] = useState<RoleView[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [catalog, setCatalog] = useState<RoleListView | null>(null)
const request = useRef(0)
const [newName, setNewName] = useState('')
// 动作编辑器
const [editRole, setEditRole] = useState('')
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
const [editAction, setEditAction] = useState<string>('doc.read')
const [editDef, setEditDef] = useState(false)
const [editCap, setEditCap] = useState(true)
// 绑定
const [assignOwner, setAssignOwner] = useState('')
const refresh = async () => {
const seq = ++request.current
try {
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
setErr(''); setRoles(out.roles ?? [])
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
const out = await api.rdmCommand<RoleListView>('rdm.role.list')
if (seq !== request.current) return
setErr(''); setRoles(out.roles ?? []); setCatalog(out)
} catch (e) {
if (seq !== request.current) return
setRoles(null); setCatalog(null); setErr(describeRoleListError(String(e)))
}
}
useEffect(() => { refresh() }, [])
useEffect(() => { refresh(); return () => { request.current++ } }, [])
const projectActions = catalog?.roleActions?.filter(a => a.scope === 'project' && a.configurable && a.action !== 'project.create')
?? [...ROLE_ACTIONS.filter(a => a !== 'project.create'), 'audit.read'].map(action => ({ action, label: actionLabel(action), scope: 'project' as const, configurable: false }))
const systemActions = catalog?.systemActions?.filter(a => a.scope === 'system')
?? [{ action: 'project.create', label: actionLabel('project.create'), scope: 'system' as const, configurable: false }]
const canManage = catalog?.canManageRoles === true && Array.isArray(catalog.roleActions)
const canAssign = catalog?.canAssignRoles === true
const sel = roles?.find(r => r.roleId === editRole)
const editCurrent = sel?.actions?.[editAction]
const canEditAction = canManage && !sel?.disabled && projectActions.some(a => a.action === editAction && a.configurable)
useEffect(() => {
setEditDef(!!editCurrent?.[0]); setEditCap(!!editCurrent?.[1])
}, [editRole, editAction, roles])
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
@@ -62,22 +89,19 @@ export function RdmRolesPanel() {
// rolesListPlaceholder 注释)。
const placeholder = rolesListPlaceholder(roles, err)
const sel = roles?.find(r => r.roleId === editRole)
const editCurrent = sel?.actions?.[editAction]
return (
<div>
<div className="ad-create">
<div className="ad-row">
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
onChange={e => setNewName(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()}
<button className="lg-submit ad-create-btn" disabled={busy || !canManage || !newName.trim()}
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
setNewName('')
})}>创建角色</button>
</div>
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
<p className="ad-hint">新角色尚无业务权限,配置后才会生效。项目权限可在角色上限内调整。</p>
</div>
<table className="ad-table">
@@ -85,13 +109,13 @@ export function RdmRolesPanel() {
<tbody>
{(roles ?? []).map(r => (
<tr key={r.roleId}>
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></td>
<td title={r.roleId}>{r.name}</td>
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
<td className="ad-dim">v{r.version}</td>
<td>{Object.keys(r.actions ?? {}).length} 项</td>
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
<td><button className="ad-mini" disabled={busy}
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
<td><button className="ad-mini" disabled={busy || !canManage}
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号的业务权限停止生效)`,
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
{r.disabled ? '启用' : '停用'}</button></td>
</tr>
@@ -108,33 +132,32 @@ export function RdmRolesPanel() {
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<select className="lg-input" value={editAction}
onChange={e => {
setEditAction(e.target.value)
const cur = sel?.actions?.[e.target.value]
setEditDef(!!cur?.[0]); setEditCap(cur ? cur[1] : true)
}}>
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
onChange={e => setEditAction(e.target.value)}>
{projectActions.map(a => <option key={a.action} value={a.action}>{a.label}</option>)}
<optgroup label="系统权限(此处仅供查看)">
{systemActions.map(a => <option key={a.action} value={a.action} disabled>{a.label}</option>)}
</optgroup>
</select>
<label className="ad-check"><input type="checkbox" checked={editDef}
<label className="ad-check"><input type="checkbox" checked={editDef} disabled={busy || !canEditAction}
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
<label className="ad-check"><input type="checkbox" checked={editCap}
<label className="ad-check"><input type="checkbox" checked={editCap} disabled={busy || !canEditAction}
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
<button className="lg-submit ad-create-btn" disabled={busy || !editRole || !canEditAction}
onClick={() => run(`已配置 ${sel?.name || '角色'} · ${actionLabel(editAction)}`,
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
</div>
{sel && editCurrent && <p className="ad-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
{sel && editCurrent && <p className="ad-hint">当前值:默认{editCurrent[0] ? '允许' : '禁止'},上限{editCurrent[1] ? '允许' : '禁止'}</p>}
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
<input className="lg-input" placeholder="成员账号" value={assignOwner}
onChange={e => setAssignOwner(e.target.value)} />
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
<option value="">选择角色…</option>
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<button className="lg-submit ad-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
<button className="lg-submit ad-create-btn" disabled={busy || !canAssign || !assignOwner.trim() || !editRole || sel?.disabled}
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${sel?.name || '所选角色'}`,
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
</div>
@@ -144,247 +167,6 @@ export function RdmRolesPanel() {
)
}
// ---- 项目设置:成员 ----
interface MemberRow { owner: string; role: string; addedBy: string; addedAt: string }
export function RdmMembersSection({ projectId }: { projectId: string }) {
const [rows, setRows] = useState<MemberRow[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [member, setMember] = useState('')
const [role, setRole] = useState<'member' | 'owner'>('member')
const refresh = async () => {
try {
const out = await api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
setRows(out.members ?? [])
} catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [projectId])
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refresh() }
catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
<div className="ad-create">
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="成员账号(owner)" value={member}
onChange={e => setMember(e.target.value)} />
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">member(成员)</option>
<option value="owner">owner(负责人)</option>
</select>
<button className="lg-submit ad-create-btn" disabled={busy || !member.trim()}
onClick={() => run(`已添加 ${member.trim()}`, async () => {
await api.rdmCommand('rdm.project.member.add', memberAddPayload(projectId, member.trim(), role))
setMember('')
})}>添加成员</button>
</div>
<p className="ad-hint">加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。</p>
</div>
<table className="ad-table">
<thead><tr><th>账号</th><th>项目角色</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
<tbody>
{(rows ?? []).map(m => (
<tr key={m.owner}>
<td className="ad-mono">{m.owner}</td>
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">OWNER</span> : <span className="ad-chip">MEMBER</span>}</td>
<td className="ad-dim">{m.addedBy || '—'}</td>
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
<td><button className="ad-mini" disabled={busy}
onClick={() => run(`已移除 ${m.owner}`,
() => api.rdmCommand('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button></td>
</tr>
))}
{rows !== null && rows.length === 0 && <tr><td colSpan={5} className="ad-dim">暂无成员</td></tr>}
{rows === null && <tr><td colSpan={5} className="ad-dim">加载中…</td></tr>}
</tbody>
</table>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:文档职责 · 整文审签 ----
interface DocSubmissionView {
round: number
state: string
digest: string
createdBy: string
createdAt: string
duties: { author: string; reviewers: string[]; approver: string }
reviews: Record<string, boolean>
sections: Record<string, number>
}
export function RdmDutiesSection({ projectId }: { projectId: string }) {
const [docId, setDocId] = useState('')
const [sub, setSub] = useState<DocSubmissionView | null | undefined>(undefined)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [author, setAuthor] = useState('')
const [reviewers, setReviewers] = useState('')
const [approver, setApprover] = useState('')
const cmd = <T,>(id: string, params: Record<string, unknown>) => api.rdmCommand<T>(id, params)
const refreshStatus = async (id: string) => {
if (!id) return
try {
const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id))
setSub(out.submission)
} catch (e) { setErr(String(e)); setSub(undefined) }
}
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refreshStatus(docId) }
catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
<div className="ad-create">
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
onChange={e => setDocId(e.target.value)} />
<Btn variant="ghost" icon="eye" disabled={busy || !docId}
onClick={() => refreshStatus(docId)}>查看整文状态</Btn>
</div>
</div>
{sub === null && <EmptyState icon="inbox">该文档尚无整文提交记录</EmptyState>}
{sub && (
<div className="settings-group">
<h4>第 {sub.round} 轮 · {sub.state === 'submitted' ? <span className="ad-chip ad-chip-warn">审签在途(全文锁定)</span> : sub.state === 'final' ? <span className="ad-chip ad-chip-admin">已终批</span> : <span className="ad-chip">已驳回</span>}</h4>
<div className="setting-row"><label>三权</label><span>编制 <b className="ad-mono">{sub.duties.author}</b> · 审核 <b className="ad-mono">{(sub.duties.reviewers ?? []).join('、')}</b> · 批准 <b className="ad-mono">{sub.duties.approver}</b></span></div>
<div className="setting-row"><label>审核进度</label><span>{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}</span></div>
<div className="setting-row"><label>冻结摘要</label><span className="ad-mono ad-dim">{(sub.digest ?? '').slice(0, 16)}…</span></div>
<div className="ad-row" style={{ marginTop: 8 }}>
<button className="lg-submit ad-create-btn" disabled={busy || sub.state !== 'submitted'}
onClick={() => run('整文终批完成(各章节已基线)',
() => cmd('rdm.review.doc.final', docFinalPayload(projectId, docId)))}>终批(批准人)</button>
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
onClick={() => run('已通过整文审核',
() => cmd('rdm.review.doc.approve', docApprovePayload(projectId, docId, '通过')))}>审核通过</button>
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
onClick={() => run('已驳回整文(解锁回编写态)',
() => cmd('rdm.review.doc.reject', docRejectPayload(projectId, docId, '退回修改')))}>驳回</button>
</div>
</div>
)}
{sub === undefined && docId && <p className="ad-hint">输入文档 ID 后查看整文状态。</p>}
<h4 className="settings-group-title" style={{ marginTop: 18 }}>分配文档三权(整文)</h4>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
onChange={e => setDocId(e.target.value)} />
<input className="lg-input lg-mono" placeholder="编制人(author)" value={author}
onChange={e => setAuthor(e.target.value)} />
<input className="lg-input lg-mono" placeholder="审核人(逗号分隔,≥1 且异于编制/批准)" value={reviewers}
onChange={e => setReviewers(e.target.value)} />
<input className="lg-input lg-mono" placeholder="批准人(approver)" value={approver}
onChange={e => setApprover(e.target.value)} />
</div>
<div className="ad-row">
<button className="lg-submit ad-create-btn"
disabled={busy || !docId || !author.trim() || !approver.trim() || parseReviewers(reviewers).length === 0}
onClick={() => run('已分配文档三权', () => cmd('rdm.review.assign.doc',
assignDocDutiesPayload(projectId, docId, author.trim(), parseReviewers(reviewers), approver.trim())))}>分配三权</button>
<button className="ad-mini" disabled={busy || !docId}
onClick={() => run('整文已提交审核(全文锁定)',
() => cmd('rdm.review.doc.submit', docSubmitPayload(projectId, docId)))}>整文提交(编制人)</button>
</div>
<p className="ad-hint">三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。</p>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:项目权限(矩阵解释读面,与实际判定同源——UI 不重算) ----
interface MatrixAction { action: string; allowed: boolean; source: string; reason?: string }
interface MatrixOut {
projectId: string; member: string; role: string
profRole: { roleId: string; name: string } | null
actions: MatrixAction[]
}
export function RdmPermMatrixSection({ projectId }: { projectId: string }) {
const me = useStore(s => s.me)
const [member, setMember] = useState('')
const [members, setMembers] = useState<MemberRow[] | null>(null)
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
.then(out => {
setMembers(out.members ?? [])
// 默认自省:普通成员只看自己的矩阵;负责人/管理员可切换他人。
if (me?.owner) setMember(prev => prev || me.owner)
})
.catch(e => setErr(String(e)))
}, [projectId, me?.owner])
const query = async (m: string) => {
if (!m) return
setErr(''); setBusy(true); setMatrix(null)
try {
setMatrix(await api.rdmCommand<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
<div className="ad-create">
<div className="ad-row">
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
<option value="">选择成员(默认自省)…</option>
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
</select>
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看矩阵</Btn>
</div>
<p className="ad-hint">普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。</p>
</div>
{matrix && (
<>
<div className="ad-row">
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目关系 <b>{matrix.role}</b>
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b>({matrix.profRole.roleId})</>}
</span>
</div>
<table className="ad-table">
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th></tr></thead>
<tbody>
{(matrix.actions ?? []).map(a => (
<tr key={a.action}>
<td className="ad-mono">{a.action}</td>
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
<td className="ad-dim">{describeSource(a.source)}</td>
<td className="ad-dim">{a.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</>
)}
{!matrix && !err && members !== null && <p className="ad-hint">选择成员后展示 12 项动作的有效权限与第一拒绝原因。</p>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
export { RdmMembersSection } from './RdmMembersSection'
export { RdmDutiesSection } from './RdmDutiesSection'
export { RdmPermMatrixSection } from './RdmPermMatrixSection'
+178 -20
View File
@@ -6,11 +6,23 @@ import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { SettingInput, flatten, scopedDraft, settingsMutation, scopedSchema } from './SettingsFields'
import { Feedback, readableError, useRequestFence, validVersion } from './rdmSettingsShared'
import type { SettingDesc } from '../contracts'
import { docBridge } from './Workbench'
import { randomPassword } from '../passwords'
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制 + 2026-10-13 恢复 Key)----
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
interface LoginKeyRow { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }
/** Key 状态徽标:复用既有 chip 体系,不新增颜色。 */
function keyStatusChip(status: string) {
if (status === 'unused') return <span className="ad-chip ad-chip-warn">待使用</span>
if (status === 'consumed') return <span className="ad-chip">已使用</span>
if (status === 'expired') return <span className="ad-chip">已过期</span>
return <span className="ad-chip">已撤销</span>
}
export function UsersPanel() {
const me = useStore(s => s.me)
@@ -22,14 +34,24 @@ export function UsersPanel() {
const [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
const [resetFor, setResetFor] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
// 登录 Key 面板:keyFor 打开指定用户的签发/状态视图;issued 明文仅签发响应持有一次。
const [keyFor, setKeyFor] = useState<string | null>(null)
const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null)
const [keys, setKeys] = useState<LoginKeyRow[] | null>(null)
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
const refreshKeys = async (owner: string) => {
try { setKeys(await api.adminListLoginKeys(owner)) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const openKeyPanel = async (owner: string) => {
setErr(''); setNotice(''); setIssued(null); setKeyFor(owner); setKeys(null)
await refreshKeys(owner)
}
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
@@ -42,16 +64,27 @@ export function UsersPanel() {
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const reset = async () => {
if (!resetFor) return
setErr('')
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
const issue = async () => {
if (!keyFor) return
setErr(''); setNotice(''); setBusy(true)
try {
await api.resetAdminUserPassword(resetFor, resetPw)
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
setResetFor(null); setResetPw('')
await refresh()
const out = await api.adminIssueLoginKey(keyFor)
setIssued({ key: out.key, expiresAt: out.expiresAt })
await refreshKeys(keyFor)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const copyKey = async () => {
if (!issued) return
try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') }
}
const revoke = async (id: string) => {
if (!keyFor) return
setErr(''); setBusy(true)
try {
await api.adminRevokeLoginKey(keyFor, id)
await refreshKeys(keyFor)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
@@ -69,18 +102,41 @@ export function UsersPanel() {
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。</p>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{resetFor ? (
{keyFor ? (
<div className="ad-reset">
<span>重置 <b>{resetFor}</b> 的密码:</span>
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
<div className="ad-row">
<span><b>{keyFor}</b> 的一次性登录 Key(忘记密码恢复)</span>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={issue}>{issued ? '重新签发(旧 Key 与受限会话立即失效)' : '生成 Key'}</button>
<button type="button" className="ad-mini" onClick={() => { setKeyFor(null); setIssued(null); setKeys(null) }}>关闭</button>
</div>
{issued && (
<div className="ad-row">
<input className="lg-input lg-mono" readOnly value={issued.key} aria-label="一次性登录 Key(仅显示一次)" onFocus={e => e.currentTarget.select()} />
<button type="button" className="ad-mini" onClick={copyKey}>复制</button>
</div>
)}
{issued && <p className="ad-hint">明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。</p>}
{keys !== null && keys.length > 0 && (
<table className="ad-table">
<thead><tr><th>签发时间</th><th>用途</th><th>状态</th><th>过期时间</th><th></th></tr></thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td className="ad-dim">{new Date(k.createdAt).toLocaleString()}</td>
<td>{k.purpose === 'recover' ? '恢复' : '激活'}</td>
<td>{keyStatusChip(k.status)}</td>
<td className="ad-dim">{new Date(k.expiresAt).toLocaleString()}</td>
<td>{k.status === 'unused' && <button className="ad-mini" disabled={busy} onClick={() => revoke(k.id)}>撤销</button>}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
) : (
<table className="ad-table">
@@ -94,7 +150,7 @@ export function UsersPanel() {
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
<button className="ad-mini" onClick={() => openKeyPanel(u.owner)}>生成登录 Key</button>
)}</td>
</tr>
))}
@@ -240,3 +296,105 @@ export function PluginWorkspacesPanel() {
</div>
)
}
// ---- 模型与服务配置(P2 系统受管项的唯一写入面)----
/**
* SystemSettingsPanel:消费 GET/PUT /api/settings/system。
* - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施);
* 非受管项属于用户/项目偏好,不在本面板出现。
* - 保存只提交变化字段与载入版本,未修改的掩码凭据不回传;显式清除发送空值。
* 保存后重新 GET 以服务端结果回填。
* - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。
*/
export function SystemSettingsPanel({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void } = {}) {
const owner = useStore(s => s.me?.owner)
const fence = useRequestFence(owner || '')
const [schema, setSchema] = useState<SettingDesc[] | null>(null)
const [draft, setDraft] = useState<Record<string, any>>({})
const [initial, setInitial] = useState<Record<string, any>>({})
const [defaults, setDefaults] = useState<Record<string, any>>({})
const [revision, setRevision] = useState<number | undefined>(undefined)
const [loaded, setLoaded] = useState(false)
const [forbidden, setForbidden] = useState(false)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const dirty = JSON.stringify(draft) !== JSON.stringify(initial)
useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false) }, [dirty, onDirtyChange])
useEffect(() => {
if (!dirty) return
const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault() }
window.addEventListener('beforeunload', beforeUnload)
return () => window.removeEventListener('beforeunload', beforeUnload)
}, [dirty])
const load = async () => {
const current = fence.begin('load')
setErr(''); setForbidden(false)
try {
const out = await api.getSystemSettings()
if (!current()) return false
const schema = scopedSchema(out.schema, 'system')
const values = scopedDraft(out.system, schema)
setSchema(schema); setDraft(values); setInitial(values); setDefaults(flatten(out.defaults))
setRevision(out.systemRevision)
setLoaded(true)
return true
} catch (e) {
if (!current()) return false
if (String(e).includes('403')) { setForbidden(true); setLoaded(true) }
else { setErr(readableError(e)); setLoaded(true); setSchema(null) }
return false
}
}
useEffect(() => { fence.invalidate(); setLoaded(false); setSchema(null); setDraft({}); setInitial({}); setNotice(''); setBusy(false); void load() }, [owner])
const groups: Record<string, SettingDesc[]> = {}
for (const d of schema ?? []) (groups[d.group] ??= []).push(d)
const save = async () => {
if (!validVersion(revision) || !schema || busy || !dirty) return
const current = fence.begin('save')
setErr(''); setNotice(''); setBusy(true)
try {
const mutation = settingsMutation(initial, draft, schema)
await api.putSystemSettings(mutation.values, revision, mutation.unset)
if (current() && await load()) setNotice('系统配置已保存;需重启的项在服务重启后生效')
} catch (e) {
if (!current()) return
const message = String(e).includes('409') ? '系统配置已被其他管理员修改;请核对最新配置后重新调整' : `保存结果未能确认:${readableError(e)};请核对最新配置后再保存`
await load()
if (current()) setErr(message)
} finally { if (current()) setBusy(false) }
}
if (!loaded) return <EmptyState icon="settings">加载系统设置…</EmptyState>
if (forbidden) return <EmptyState icon="shield">系统设置仅管理员可配置</EmptyState>
if (!schema) return <><EmptyState icon="settings">{err || '系统设置未能读取'}</EmptyState><Btn onClick={load}>重试</Btn></>
return (
<div>
<p className="ad-hint" style={{ marginBottom: 14 }}>
模型、密钥与运行基础设施为系统受管配置:仅此面板(管理员)可写,用户与项目设置中的同名项不再生效。
</p>
{Object.entries(groups).map(([group, descs]) => (
<SettingsGroup key={group} title={group === 'models' ? '模型' : group === 'tools' ? '工具服务' : group === 'telemetry' ? '遥测' : group === 'runtime' ? '运行基础设施' : group}>
{descs.map(d => (
<SettingRow key={d.key} label={d.label} desc={`${d.description || ''} · ${draft[d.key] === undefined ? '沿用系统默认' : d.secret ? '受保护的系统凭据' : '系统配置'}`} effect={d.effect}>
<SettingInput desc={{ ...d, default: defaults[d.key] ?? d.default }} value={draft[d.key]} disabled={busy || !validVersion(revision)} onChange={v => { setNotice(''); setDraft(prev => ({ ...prev, [d.key]: v })) }} />
{d.secret || d.type === 'secret' ? <Btn disabled={busy || !validVersion(revision)} onClick={() => {
if (window.confirm(`清除「${d.label}」配置?保存后该系统凭据将撤销`)) setDraft(previous => ({ ...previous, [d.key]: '' }))
}}>清除配置</Btn> : d.type === 'string' ? <Btn disabled={busy || !validVersion(revision) || draft[d.key] === undefined} onClick={() => setDraft(previous => ({ ...previous, [d.key]: undefined }))}>沿用默认</Btn> : null}
</SettingRow>
))}
</SettingsGroup>
))}
<div className="ad-row" style={{ marginTop: 12 }}>
<Btn variant="primary" icon="check" disabled={busy || !validVersion(revision) || !dirty} onClick={save}>保存系统配置</Btn>
</div>
{!validVersion(revision) && <p className="ad-hint">系统配置版本尚未就绪,暂不能保存</p>}
<Feedback error={err} notice={notice} />
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react'
import { Icon, type PickItem } from '@ui'
import { api } from '../api'
import type { Project } from '../contracts'
import { useStore } from '../store'
export interface Person {
owner: string; name?: string; valid?: boolean; role?: string; projectRole?: string; joined?: boolean
profRole?: { roleId: string; name: string } | null
dutyEligibility?: { author: { allowed: boolean; reason?: string }; reviewer: { allowed: boolean; reason?: string }; approver: { allowed: boolean; reason?: string } }
addedBy?: string; addedAt?: string
}
export interface MemberList {
members: Person[]; next?: string; count?: number; accountDirectoryAvailable?: boolean
canManageMembers?: boolean; canAssignDuties?: boolean; canConfigurePermissions?: boolean
}
interface RoleOption { roleId: string; name: string }
export const projectRoleLabel = (role?: string) => role === 'owner' ? '项目负责人' : role === 'member' ? '项目成员' : '未加入项目'
export const personLabel = (person: Person) => person.name || person.owner
export function personItem(person: Person, adding = false): PickItem {
const disabledReason = person.valid !== true ? '账号有效状态尚未核验' : adding && person.joined ? '已加入,请在成员表中调整项目身份' : undefined
return { id: person.owner, title: personLabel(person),
subtitle: [person.owner, person.profRole?.name, !adding ? projectRoleLabel(person.role || person.projectRole) : ''].filter(Boolean).join(' · '),
badge: person.joined && adding ? '已加入' : undefined, disabled: !!disabledReason, disabledReason }
}
export const selectedPeople = (ids: string[], people: Person[]) => ids.map(id => personItem(people.find(p => p.owner === id) ?? { owner: id }))
/** A context change invalidates requests immediately, before effects run. */
export class RequestFence {
private context: string
private epoch = 0
private sequences = new Map<string, number>()
constructor(context: string) { this.context = context }
setContext(context: string) { if (context !== this.context) { this.context = context; this.invalidate() } }
invalidate() { this.epoch++; this.sequences.clear() }
capture() { const epoch = this.epoch; return () => epoch === this.epoch }
cancel(channel: string) { this.sequences.set(channel, (this.sequences.get(channel) ?? 0) + 1) }
begin(channel: string) {
const epoch = this.epoch
const seq = (this.sequences.get(channel) ?? 0) + 1
this.sequences.set(channel, seq)
return () => epoch === this.epoch && seq === this.sequences.get(channel)
}
}
export function useRequestFence(context: string) {
const ref = useRef(new RequestFence(context))
ref.current.setContext(context)
useEffect(() => () => ref.current.invalidate(), [])
return ref.current
}
export function projectContext(project: Project, owner?: string) {
return `${owner || ''}:${project.id}:${project.settings?.['rdm.projectId'] || ''}:${project.settings?.['rdm.state'] || ''}`
}
export function useRoleOptions(project: Project, purpose: 'members' | 'duties' | 'permissions', enabled: boolean) {
const owner = useStore(s => s.me?.owner)
const context = projectContext(project, owner)
const fence = useRequestFence(context)
const [roles, setRoles] = useState<RoleOption[]>([])
const [error, setError] = useState('')
useEffect(() => {
const current = fence.begin('roles')
setRoles([]); setError('')
if (!enabled) return
api.rdmProjectCommand<{ roles: RoleOption[] }>(project, 'rdm.role.options', { purpose })
.then(out => { if (current()) { if (!Array.isArray(out.roles)) throw new Error('专业角色选项接口未就绪'); setRoles(out.roles) } })
.catch(() => { if (current()) setError('专业角色筛选暂不可用,请重试') })
return () => fence.cancel('roles')
}, [context, purpose, enabled])
return { roles, error }
}
export function RoleFilter({ roles, value, onChange, disabled = false }: {
roles: RoleOption[]; value: string; onChange: (role: string) => void; disabled?: boolean
}) {
return <select aria-label="专业角色筛选" className="lg-input" value={value} disabled={disabled} onChange={e => onChange(e.target.value)}>
<option value="">全部专业角色</option>
{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
</select>
}
export function Feedback({ error, notice }: { error?: string; notice?: string }) {
return <>{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{error && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{error}</div>}</>
}
export function readableError(error: unknown): string {
const raw = String(error).replace(/^(?:Error|TypeError):\s*/, '')
if (raw.includes('unknown or disabled command')) return '该功能尚未装配到当前服务,请升级服务后重新打开'
const text = raw.replace(/^\d{3}:\s*/, '')
try {
const body = JSON.parse(text)
if (typeof body?.receipt?.reason === 'string') return readableError(body.receipt.reason.replace(/^(?:rejected|failed):\s*/i, ''))
if (typeof body.error === 'string') return readableError(body.error)
if (typeof body.message === 'string') return readableError(body.message)
if (body && typeof body === 'object') return '操作暂未完成,请重新读取状态后再试'
} catch { /* A plain service message is also valid. */ }
if (/[\u4e00-\u9fff]/.test(text)) return text
if (raw.startsWith('403:') || /denied|forbidden/i.test(raw)) return '当前账号没有权限执行此操作,请联系项目负责人核对'
if (raw.startsWith('409:') || /version.*conflict|revision.*mismatch/i.test(raw)) return '数据已经更新,请核对最新结果后重新操作'
if (/fetch|network|connection/i.test(raw)) return '网络连接中断,请核对最新状态;写入结果可能尚未确认'
return '操作暂未完成,请重新读取状态后再试'
}
export const validVersion = (version: unknown): version is number => typeof version === 'number' && Number.isSafeInteger(version) && version >= 0
+2
View File
@@ -270,6 +270,8 @@ export interface SettingDesc {
default?: unknown; options?: string[];
effect: 'hot' | 'new-run' | 'restart';
secret?: boolean;
/** Setting ownership; an omitted scope retains legacy personal compatibility. */
scope?: 'system' | 'user' | 'project' | 'project-personal';
}
// ---- 插件 ----
+38
View File
@@ -3,3 +3,41 @@ export function projectStage(settings?: Record<string, unknown> | null): string
const stage = settings?.stage
return typeof stage === 'string' && stage.trim() !== '' ? stage.trim() : null
}
/** Service metadata describes readiness; permission is always checked by the server. */
export function projectWorkError(project: { settings?: Record<string, unknown> } | undefined): string | null {
if (!project) return '项目已不可用,请刷新项目列表或联系项目负责人'
const settings = project.settings
if (!settings || (!('rdm.state' in settings) && !('rdm.projectId' in settings))) return null
switch (settings['rdm.state']) {
case 'ready':
if (typeof settings['rdm.projectId'] === 'string' && settings['rdm.projectId'].trim()) return null
break
case 'provisioning': return '项目正在准备,请稍后刷新项目列表再试'
case 'failed': return '项目准备失败,请联系项目负责人重试创建'
case 'archived': return '项目已归档,请联系项目负责人恢复后再新建对话'
case 'closed': return '项目已关闭,请选择其他项目或联系项目负责人'
}
return '项目尚未绑定,请联系项目负责人完成项目接入'
}
export function projectStateLabel(settings?: Record<string, unknown>): string | null {
switch (settings?.['rdm.state']) {
case 'provisioning': return '准备中'
case 'failed': return '准备失败'
case 'unbound': return '未接入'
case 'archived': return '已归档'
case 'closed': return '已关闭'
default: return null
}
}
export function projectActionError(error: unknown): string {
const raw = String(error)
if (/\b401\b/.test(raw)) return '登录已失效,请重新登录后再试'
if (/\b403\b/.test(raw)) return '当前账号无法管理此项目,请联系项目负责人'
if (/\b404\b/.test(raw)) return '项目已不可用,请刷新项目列表或联系项目负责人'
if (/\b409\b/.test(raw)) return '项目名称已被占用或项目状态已变化,请更换名称或刷新后再试'
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
return raw.replace(/^Error:\s*/, '')
}
+58 -8
View File
@@ -2,6 +2,36 @@
// 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与
// api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不
// 携带任何身份字段(G1-5)。
import type { Project } from './contracts'
import { projectWorkError } from './projectmeta'
export function rdmCommandProjectId(project: Project): string {
const id = project.settings?.['rdm.projectId']
if (typeof id !== 'string' || !id.trim()) {
throw new Error('项目尚未绑定,请联系项目负责人完成项目接入')
}
return id.trim()
}
/** Historical project reads remain available; this does not grant read permission. */
export function rdmProjectReadError(project: Project | undefined): string | null {
const state = project?.settings?.['rdm.state']
if (project && (state === 'ready' || state === 'archived' || state === 'closed')) {
try { rdmCommandProjectId(project); return null }
catch (e) { return String(e).replace(/^Error:\s*/, '') }
}
return projectWorkError(project) ?? '项目尚未绑定,请联系项目负责人完成项目接入'
}
const PROJECT_READ_COMMANDS = new Set([
'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
'rdm.duty.tree', 'rdm.role.options', 'rdm.account.list', 'rdm.project.member.impact',
])
export function rdmProjectCommandError(project: Project, command: string): string | null {
return PROJECT_READ_COMMANDS.has(command) ? rdmProjectReadError(project) : projectWorkError(project)
}
/** 系统管理「RDM 角色权限」分区消费的命令面。 */
export const RDM_ROLES_COMMANDS = [
@@ -17,12 +47,14 @@ export const RDM_MEMBER_COMMANDS = [
'rdm.project.member.list',
'rdm.project.member.add',
'rdm.project.member.remove',
'rdm.project.member.set-role', 'rdm.project.member.impact', 'rdm.account.list', 'rdm.role.options',
] as const
/** 项目设置「项目权限」页消费的命令面(矩阵解释 + 成员清单)。 */
export const RDM_PERM_MATRIX_COMMANDS = [
'rdm.perm.check',
'rdm.project.member.list',
'rdm.perm.matrix', 'rdm.perm.set', 'rdm.perm.unset', 'rdm.role.options',
] as const
/** 项目设置「文档职责 · 整文审签」页消费的命令面。 */
@@ -33,6 +65,8 @@ export const RDM_DOC_REVIEW_COMMANDS = [
'rdm.review.doc.reject',
'rdm.review.doc.final',
'rdm.review.doc.status',
'rdm.duty.catalog', 'rdm.duty.tree', 'rdm.duty.get', 'rdm.project.member.list',
'rdm.role.options', 'rdm.review.assign', 'rdm.review.assign.doc.batch',
] as const
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
@@ -58,7 +92,7 @@ export const rdmDocReviewReady = (cmds: CommandListItem[] | null | undefined) =>
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_PERM_MATRIX_COMMANDS)
/** RDM 动作清单(12 项,与 RDM perms.AllActions 对齐;角色动作编辑器选项)。 */
/** 旧动作目录用于只读占位;可编辑目录及当前权限由服务端返回。 */
export const ROLE_ACTIONS = [
'project.create', 'project.archive', 'member.manage', 'duty.assign',
'stage.advance', 'doc.read', 'doc.write', 'doc.publish', 'review.participate',
@@ -76,10 +110,12 @@ export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ role
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
({ projectId, member, role })
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
export const assignDocDutiesPayload = (
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
) => ({ projectId, docId, author, reviewers, approver })
expectedDutyVersion: number, confirmInvalidation = false,
) => ({ projectId, docId, author, reviewers, approver, expectedDutyVersion, confirmInvalidation })
export const docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId })
export const docApprovePayload = (projectId: string, docId: string, note?: string) =>
@@ -93,7 +129,7 @@ export const docStatusPayload = (projectId: string, docId: string) => ({ project
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
/** 内部来源码只进入按需技术详情。 */
export function describeSource(source: string): string {
switch (source) {
case 'admin': return '系统管理员'
@@ -101,10 +137,26 @@ export function describeSource(source: string): string {
case 'override': return '项目内覆盖'
case 'relation-default': return '项目关系默认'
case 'non-member': return '非项目成员'
default: return source
case 'scope': return '配置范围不符'
case 'error': return '暂时无法核验'
case 'cap': case 'cap-denied': return '管理员权限上限'
case 'project': return '项目内配置'
case 'default': return '角色默认'
default: return '来源未提供'
}
}
const ACTION_LABELS: Record<string, string> = {
'project.create': '创建项目', 'project.archive': '归档项目', 'member.manage': '管理项目成员',
'duty.assign': '分配文档职责', 'stage.advance': '推进项目阶段', 'doc.read': '读取文档',
'doc.write': '编制与编辑文档', 'doc.publish': '发布受控文档', 'review.participate': '参与文档审签',
'trace.edit': '编辑追溯关系', 'knowledge.publish': '发布项目知识', 'project.settings': '配置项目设置',
}
export function actionLabel(action: string, label?: string): string {
return ACTION_LABELS[action] || (label && label !== action ? label : '其他项目操作')
}
/** 角色清单表格占位行文案:加载中仅在无错误时成立——rdm.role.list 被拒后
* 停留「加载中…」是 2026-10-13 8788 锁死事故的误导性表象(表格永远在转圈)。 */
export function rolesListPlaceholder(roles: unknown[] | null, err: string): string {
@@ -112,12 +164,10 @@ export function rolesListPlaceholder(roles: unknown[] | null, err: string): stri
return err ? '角色清单未加载(见下方提示)' : '加载中…'
}
/** rdm.role.list 错误文案映射:域管理员权限拒绝给人话 + 恢复通道;其余错误
* 原样保留(服务端 reason 变化不至于空白)。台账单管理员制见 RDM G1-4/U1。 */
/** 角色管理须有独立系统权限;不引导使用已退出此链路的台账管理员移交。 */
export function describeRoleListError(raw: string): string {
if (raw.includes('不是系统管理员')) {
return '当前账号不是 RDM 域管理员(台账单管理员制),无法读取角色清单;'
+ '请联系现任管理员经 rdm.perm.transfer 移交,或由部署侧受信通道恢复'
return '当前账号无权管理系统专业角色,请联系具有角色管理权限的系统管理员'
}
return raw
}
+149 -33
View File
@@ -2,6 +2,8 @@
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { type AdminZoneSection, api, connectEvents } from './api'
import { auth } from './auth'
import { projectActionError, projectWorkError } from './projectmeta'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
@@ -9,7 +11,7 @@ import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
import { rdmAuditReady } from './workspaces'
import { rdmAuditReady, rdmExportReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement'
type FontSizeChoice = 13 | 14 | 16 | 18
@@ -119,6 +121,8 @@ interface AppState {
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
rdmAuditAvailable: boolean
/** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */
rdmExportAvailable: boolean
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
rdmRolesAvailable: boolean
rdmMembersAvailable: boolean
@@ -192,6 +196,7 @@ let state: AppState = {
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
rdmAuditAvailable: false,
rdmExportAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
@@ -279,6 +284,10 @@ let sse: { close: () => void } | null = null
let projectSse: { close: () => void } | null = null
let projectSeq = 0
let conversationEpoch = 0
let identityEpoch = 0
let projectsRequest = 0
let tasksRequest = 0
let meRequest = 0
let streamSeq = 0
let detailRequest = 0
let detailSeq = 0
@@ -398,24 +407,58 @@ export function useStore<T>(selector: (s: AppState) => T): T {
export const store = {
getState: () => state,
getIdentityEpoch: () => identityEpoch,
resetIdentity() {
identityEpoch++; projectsRequest++; tasksRequest++; meRequest++
sse?.close(); sse = null
projectSse?.close(); projectSse = null
conversationEpoch++; detailRequest++; cardsRequest++; projectCardsRequest++
projectSeq = 0; streamSeq = 0; detailSeq = 0; detailVersion = 0
cardVersion = 0; projectCardVersion = 0
cardVersions = {}; projectCardVersions = {}; cardPatches = {}; projectCardPatches = {}
runObservations = {}; messageObservations = {}; hitlObservations = {}
actionInflight.clear()
for (const key of Object.keys(actionFeedbackOwners)) delete actionFeedbackOwners[key]
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
convRestoreDone = true
persistConvId(null)
set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv',
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
settingsOpen: false, changePwOpen: false, trackerRunId: null, workspaceSurfacePin: null,
previewExpanded: false, filesView: 'list', showArchived: false })
},
async refreshProjects() {
const epoch = identityEpoch
const request = ++projectsRequest
try {
const projects = await api.listProjects()
if (epoch !== identityEpoch || request !== projectsRequest) return
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
set({ projects, currentProjectId: current?.id ?? null, error: null })
if (current) await store.openProject(current.id)
} catch (e) {
set({ error: String(e) })
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
}
store.refreshTasks()
store.refreshMe()
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
},
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
async refreshTasks(includeArchived = state.ui.showArchived) {
const epoch = identityEpoch
const request = ++tasksRequest
try {
const tasks = await api.listTasks(includeArchived)
if (epoch !== identityEpoch || request !== tasksRequest) return
set({ tasks })
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
if (!convRestoreDone) {
@@ -429,14 +472,35 @@ export const store = {
},
async refreshMe() {
try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ }
const epoch = identityEpoch
const request = ++meRequest
try {
const me = await api.me()
if (epoch !== identityEpoch || request !== meRequest) return
const changed = state.me !== null && state.me.owner !== me.owner
if (changed) store.resetIdentity()
set({ me })
if (changed) {
await store.refreshProjects()
void store.refreshProfiles()
void store.refreshPlugins()
}
} catch { /* 身份不可用时侧栏只显示通用入口 */ }
},
async createProject(name: string) {
const p = await api.createProject(name)
await store.refreshProjects()
store.toast(`项目「${p.name}」已创建`)
return p
const epoch = identityEpoch
try {
const p = await api.createProject(name)
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
await store.refreshProjects()
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
store.toast(`项目「${p.name}」已创建`)
return p
} catch (e) {
if (epoch === identityEpoch) store.toast(`项目创建失败:${projectActionError(e)}`)
throw e
}
},
async openProject(projectId: string) {
@@ -465,28 +529,43 @@ export const store = {
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
* 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */
async createTask(projectId?: string) {
let pid = projectId ?? state.currentProjectId
if (!pid && state.projects.length > 0) pid = state.projects[0].id
if (!pid) {
const p = await store.createProject('默认项目')
pid = p.id
const epoch = identityEpoch
try {
let pid = projectId ?? state.currentProjectId
if (!pid && state.projects.length > 0) pid = state.projects[0].id
if (!pid) {
const p = await store.createProject('默认项目')
pid = p.id
}
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
const error = projectWorkError(state.projects.find(p => p.id === pid))
if (error) throw new Error(error)
if (pid !== state.currentProjectId) await store.openProject(pid)
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
const c = await api.createConversation(pid)
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
await store.refreshTasks()
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
await store.openTask(c.id)
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
} catch (e) {
if (epoch === identityEpoch) store.toast(`对话创建失败:${projectActionError(e)}`)
throw e
}
if (pid !== state.currentProjectId) await store.openProject(pid)
const c = await api.createConversation(pid)
await store.refreshTasks()
await store.openTask(c.id)
},
/** 兼容旧入口(⌘/命令总线) */
async createConversation(title?: string) {
if (title) store.toast('已在当前项目创建任务')
await store.createTask()
if (title) store.toast('已在当前项目创建任务')
},
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
async openTask(convId: string) {
const epoch = identityEpoch
const t = state.tasks.find(x => x.id === convId)
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
if (epoch !== identityEpoch) return
await store.openConversation(convId)
},
@@ -996,8 +1075,9 @@ export const store = {
const convId = state.currentConvId
const pid = state.currentProjectId
const epoch = conversationEpoch
const identity = identityEpoch
const startedVersion = card.conversationId === state.currentConvId ? cardVersion : projectCardVersion
const sameContext = () => state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
const sameContext = () => identity === identityEpoch && state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
actionInflight.add(card.id)
const feedbackOwner = startActionFeedback(card.id)
try {
@@ -1035,57 +1115,81 @@ export const store = {
else await store.loadProjectCards()
}
} finally {
actionInflight.delete(card.id)
if (identity === identityEpoch) actionInflight.delete(card.id)
}
},
async cardPin(card: ActivityCard, pinned: boolean) {
try { store.upsertCard((await api.cardPin(card.id, pinned)).card) } catch (e) { store.toast(`置顶失败:${e}`) }
const epoch = identityEpoch
try {
const out = await api.cardPin(card.id, pinned)
if (epoch === identityEpoch) store.upsertCard(out.card)
} catch (e) { if (epoch === identityEpoch) store.toast(`置顶失败:${e}`) }
},
async cardDismiss(card: ActivityCard) {
try { store.upsertCard((await api.cardDismiss(card.id)).card) } catch (e) { store.toast(`关闭失败:${e}`) }
const epoch = identityEpoch
try {
const out = await api.cardDismiss(card.id)
if (epoch === identityEpoch) store.upsertCard(out.card)
} catch (e) { if (epoch === identityEpoch) store.toast(`关闭失败:${e}`) }
},
async refreshArtifacts(projectId: string) {
const epoch = identityEpoch
try {
const artifacts = await api.listArtifacts(projectId)
if (state.currentProjectId !== projectId) return
if (epoch !== identityEpoch || state.currentProjectId !== projectId) return
set({ artifacts })
} catch { /* 项目删除竞态等,静默 */ }
},
async refreshProfiles() {
try { set({ profiles: await api.listProfiles() }) } catch { /* ignore */ }
const epoch = identityEpoch
try {
const profiles = await api.listProfiles()
if (epoch === identityEpoch) set({ profiles })
} catch { /* ignore */ }
},
async refreshPlugins() {
const epoch = identityEpoch
try {
const list = await api.listPlugins() as { panels?: PluginPanel[] }[]
if (epoch !== identityEpoch) return
const panels = list.flatMap(p => p.panels ?? [])
set({ pluginPanels: panels })
} catch { /* ignore */ }
try {
const ws = await api.listWorkspaces()
if (epoch !== identityEpoch) return
set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ }
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
try {
const cmds = await api.listCommands()
if (epoch !== identityEpoch) return
set({
rdmAuditAvailable: rdmAuditReady(cmds),
rdmExportAvailable: rdmExportReady(cmds),
rdmRolesAvailable: rdmRolesReady(cmds),
rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDocReviewReady(cmds),
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
})
} catch { set({ rdmAuditAvailable: false }) }
} catch {
if (epoch !== identityEpoch) return
set({ rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false })
}
try {
const sections = await api.adminZoneSections()
if (epoch !== identityEpoch) return
set({ adminSections: sections ?? [] })
} catch { /* ignore */ }
try {
const settings = await api.getSettings()
if (epoch !== identityEpoch) return
const runtime = settings.user.runtime
const mock = !!runtime && typeof runtime === 'object' && (runtime as { mock?: unknown }).mock === true
set({ runtimeMock: mock })
@@ -1094,15 +1198,16 @@ export const store = {
async uploadAttachment(file: File): Promise<string | null> {
const pid = state.currentProjectId
const epoch = identityEpoch
if (!pid) return null
try {
const up = await api.upload(pid, file)
if (state.currentProjectId !== pid) return null
if (epoch !== identityEpoch || state.currentProjectId !== pid) return null
store.toast(`已收到材料:${up.filename}${up.parsed ? ' · 已读取内容' : ''}`)
await store.refreshArtifacts(pid)
return up.artifactId
return epoch === identityEpoch && state.currentProjectId === pid ? up.artifactId : null
} catch (e) {
if (state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
if (epoch === identityEpoch && state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
return null
}
},
@@ -1140,9 +1245,10 @@ export const store = {
connectProject() {
const pid = store.getState().currentProjectId
const epoch = identityEpoch
if (!pid) return
projectSse?.close()
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (epoch === identityEpoch && state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
},
applyProjectEvent(e: StreamEvent) {
@@ -1183,8 +1289,10 @@ export const store = {
},
async cardToProject(card: ActivityCard) {
const epoch = identityEpoch
try {
const out = await api.cardToProject(card.id)
if (epoch !== identityEpoch) return
store.upsertCard(out.origin) // 会话视图:原卡 dismissed 留痕
const list = store.getState().projectCards
const i = list.findIndex(c => c.id === out.card.id)
@@ -1192,17 +1300,23 @@ export const store = {
store.toast('已留档到项目收件箱')
store.setInboxScope('project')
} catch (e) {
store.toast(`留档失败:${e}`)
if (epoch === identityEpoch) store.toast(`留档失败:${e}`)
}
},
async tidyCards() {
const epoch = identityEpoch
// U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。
const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger')
for (const c of done) {
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
if (epoch !== identityEpoch) return
try {
const out = await api.cardDismiss(c.id)
if (epoch !== identityEpoch) return
store.upsertCard(out.card)
} catch { /* 单卡失败不阻断 */ }
}
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
if (epoch === identityEpoch && done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
},
closeWorkspace() { setUI({ activeWorkspace: null }) },
/** 铃铛:打开右栏并切到待处理。哪一侧有待办就看哪一侧。 */
@@ -1300,6 +1414,8 @@ export const store = {
},
}
auth.onIdentityChange(() => store.resetIdentity())
function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null {
if (state.runs.find(r => r.id === _runId)?.kind === 'subagent') return state.live
if (state.live && state.live.runId !== _runId && !isTerminal(state.runs.find(r => r.id === state.live?.runId)?.status)) return state.live
+19
View File
@@ -512,6 +512,17 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
.ad-table tr:last-child td { border-bottom: none; }
.rdm-settings .ad-row { flex-wrap: wrap; align-items: center; }
.rdm-settings .ad-row .lg-input { flex: 1 1 160px; }
.rdm-settings .ad-row .picker { flex: 2 1 220px; min-width: 180px; }
.rdm-settings .ui-setting-control .picker { width: 100%; }
.rdm-filter-row { margin: 12px 0; }
.rdm-table-scroll { overflow-x: auto; margin: 12px 0; }
.rdm-table-scroll th { white-space: nowrap; }
.rdm-table-scroll td { vertical-align: top; min-width: 100px; }
.rdm-table-scroll td:first-child { min-width: 48px; }
.rdm-duty-tree { list-style: none; padding-left: 16px; margin: 8px 0; }
.rdm-duty-tree li { margin: 8px 0; }
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.ad-dim { color: var(--text-dim); }
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
@@ -627,6 +638,14 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
.rdm-audit-coverage { margin-top: 12px; }
.rdm-audit-export { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-export-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; }
.rdm-audit-export-kind { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-export-kind .ui-input { min-width: 180px; height: 32px; font-size: var(--fs-ui-sm); }
.rdm-audit-export-draft { padding: 8px 10px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); }
.rdm-audit-export-draft p { margin: 0 0 8px; }
.rdm-audit-export-result { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-export-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
+18
View File
@@ -30,6 +30,24 @@ export const RDM_AUDIT_COMMANDS = [
'rdm.inspect.history',
] as const
/** 概览页导出区单独探测。不并入上面的七命令就绪门。 */
export const RDM_EXPORT_COMMANDS = [
'rdm.inspect.export.create',
'rdm.inspect.export.status',
'rdm.inspect.export.download',
] as const
/** 三条导出命令都启用才显示概览导出区。缺一或清单不可用则隐藏。 */
export function rdmExportReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return RDM_EXPORT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
/** 正式报告被完整性门拒绝时,界面先确认再带 draft 重试。 */
export function exportNeedsDraft(message: string): boolean {
return /不完整|草稿/.test(message)
}
/** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */
export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
+18 -1
View File
@@ -1,7 +1,7 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
@@ -30,3 +30,20 @@ describe('humanChangePasswordError(改密门)', () => {
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
})
})
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
})
it('未知错误剥状态码前缀透传', () => {
expect(humanRedeemError('400: bad')).toBe('bad')
})
})
describe('humanChangePasswordError(登录恢复新增文案)', () => {
it('并发凭据变化给出重新登录指引', () => {
expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录')
})
})
+363
View File
@@ -0,0 +1,363 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
import { store } from '../src/store'
import type { ActivityCard, Conversation, Project } from '../src/contracts'
import { assignDocDutiesPayload, memberAddPayload } from '../src/rdmManagement'
const project = (id: string, settings?: Project['settings']): Project => ({
id, name: id, settings, createdAt: '', updatedAt: '',
})
const ready = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })
const task = (id: string): Conversation => ({ id, projectId: 'host-1', title: id, createdAt: '', updatedAt: '' })
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(ok => { resolve = ok })
return { promise, resolve }
}
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
let projects: Project[]
let tasks: Conversation[]
let requests: { path: string; init?: RequestInit }[]
let route: (path: string, init?: RequestInit) => Promise<Response>
beforeEach(async () => {
projects = []; tasks = []; requests = []
const saved = new Map<string, string>()
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
setItem: (key: string, value: string) => { saved.set(key, value) },
removeItem: (key: string) => { saved.delete(key) } })
route = async (path, init) => {
if (path === '/api/projects' && !init?.method) return response(projects)
if (path.startsWith('/api/conversations') && !init?.method) return response(tasks)
if (path === '/api/me') return response({ owner: 'alice', name: 'Alice', auth: true })
if (path.endsWith('/artifacts')) return response([])
if (path.endsWith('/cards')) return response({ cards: [] })
if (path.endsWith('/surfaces')) return response([])
if (['/api/profiles', '/api/plugins', '/api/workspaces', '/api/commands', '/api/admin-zone/sections'].includes(path)) return response([])
if (path === '/api/settings') return response({ schema: [], defaults: {}, user: {}, project: {} })
if (path.startsWith('/api/commands/')) return response({ payload: { ok: true } })
return response({})
}
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
requests.push({ path, init })
return route(path, init)
})
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
auth.beginSession()
await store.refreshProjects()
await store.refreshTasks()
store.setUI({ lastProjectId: null })
requests = []
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('project command identity', () => {
it.each(['archived', 'closed'])('preserves canonical member, permission and duty reads for a %s project', async state => {
const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
const commands = ['rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get']
for (const command of commands) await api.rdmProjectCommand(historical, command, { docId: 'doc-2' })
expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
{ path: '/api/commands/rdm.project.member.list', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.perm.check', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.perm.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.review.doc.status', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.catalog', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.get', params: { projectId: 'domain-7', docId: 'doc-2' } },
])
})
it.each(['archived', 'closed'])('still blocks project and document mutations for a %s project', async state => {
const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
for (const command of ['rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.setRole',
'rdm.review.assign.doc', 'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final']) {
await expect(api.rdmProjectCommand(historical, command)).rejects.toThrow()
}
expect(requests).toEqual([])
})
it.each(['rdm.perm.matrix', 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.project.member.setRole'])('uses the same canonical boundary for %s', async command => {
await api.rdmProjectCommand(ready, command, { projectId: 'another-host', member: 'bob' })
expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
{ path: `/api/commands/${command}`, params: { projectId: 'domain-7', member: 'bob' } },
])
})
it('sends member and duty commands to the canonical domain project from service metadata', async () => {
await api.rdmProjectCommand(ready, 'rdm.project.member.add', memberAddPayload(ready.id, 'bob'))
await api.rdmProjectCommand(ready, 'rdm.review.assign.doc', assignDocDutiesPayload(ready.id, 'doc-2', 'alice', ['bob'], 'carol', 3))
expect(requests.map(r => JSON.parse(String(r.init?.body)).params)).toEqual([
{ projectId: 'domain-7', member: 'bob', role: 'member' },
{ projectId: 'domain-7', docId: 'doc-2', author: 'alice', reviewers: ['bob'], approver: 'carol', expectedDutyVersion: 3, confirmInvalidation: false },
])
})
it.each([
project('host-1', { 'rdm.state': 'unbound' }),
project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'failed' }),
project('host-1', { 'rdm.projectId': 'domain-7' }),
project('host-1'),
])('does not invent a domain ID or send commands for an unready binding: %j', async p => {
await expect(api.rdmProjectCommand(p, 'rdm.project.member.list')).rejects.toThrow()
expect(requests).toEqual([])
})
})
describe('project work and mutation failures', () => {
it('keeps private history readable for an archived project while refusing new work', async () => {
projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'archived' })]
tasks = [task('private-history')]
const previous = route
route = async (path, init) => {
if (path === '/api/conversations/private-history') return response({ conversation: tasks[0],
messages: [{ id: 'own-message', conversationId: 'private-history', role: 'user', parts: [{ type: 'text', text: 'My history' }], createdAt: '' }],
runs: [], activeHitl: [], lastSeq: 0 })
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
return previous(path, init)
}
await store.refreshProjects()
await store.openTask('private-history')
await expect(store.createTask()).rejects.toThrow()
expect(store.getState().currentConvId).toBe('private-history')
expect(store.getState().messages.map(m => m.id)).toEqual(['own-message'])
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
})
it.each([ready, project('host-1')])('creates private work with the host ID in ready RDM and ordinary base mode: %j', async p => {
projects = [p]
await store.refreshProjects()
const previous = route
route = async (path, init) => {
if (path === '/api/projects/host-1/conversations' && init?.method === 'POST') {
tasks = [task('new-private')]
return response(tasks[0])
}
if (path === '/api/conversations/new-private') {
return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 })
}
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
return previous(path, init)
}
await store.createTask()
expect(store.getState().currentConvId).toBe('new-private')
expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/projects/host-1/conversations'])
})
it.each(['provisioning', 'failed', 'unbound', 'archived', 'closed'])('does not create a fallback project or conversation for a selected %s project', async state => {
projects = [project('host-1', { 'rdm.state': state }), project('host-2')]
await store.refreshProjects()
requests = []
await expect(store.createTask()).rejects.toThrow()
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
expect(store.getState().currentProjectId).toBe('host-1')
expect(store.getState().toast).toMatch(/项目.*(稍后|负责人|恢复|关闭)/)
})
it('does not bypass a selected project absent from the current list', async () => {
await store.openProject('revoked-project')
requests = []
await expect(store.createTask()).rejects.toThrow()
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
})
it.each(['delete', 'rename'])('rejects a denied %s instead of resolving success', async action => {
route = async () => response({ error: 'project administration required' }, 403)
const mutation = action === 'delete' ? api.deleteProject('host-1') : api.renameProject('host-1', 'renamed')
await expect(mutation).rejects.toThrow(/403/)
})
it('retains selection and gives an actionable message when project creation is rejected', async () => {
projects = [ready]
await store.refreshProjects()
route = async () => response({ error: 'name reserved' }, 409)
await expect(store.createProject('Taken')).rejects.toThrow()
expect(store.getState().currentProjectId).toBe('host-1')
expect(store.getState().toast).toMatch(/名称.*(其他|更换)/)
expect(store.getState().toast).not.toContain('已创建')
})
it('checks a denied settings save instead of resolving success', async () => {
route = async () => response({ error: 'denied' }, 403)
await expect(api.putProjectSettings('host-1', {}, 3)).rejects.toThrow(/403/)
})
})
describe('identity-scoped state', () => {
it('resets state on a successful cookie login, without relying on a component callback', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
route = async () => response({ owner: 'bob', name: 'Bob', isAdmin: false, mustChangePassword: false, passwordSet: true, sessionScope: 'normal' })
await api.login('bob', 'password-for-test')
expect(store.getState().projects).toEqual([])
expect(store.getState().tasks).toEqual([])
})
it('clears old state as soon as logout starts, even while the server response is pending', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
const pending = deferred<Response>()
route = async () => pending.promise
const logout = api.logout()
expect(store.getState().projects).toEqual([])
expect(store.getState().tasks).toEqual([])
pending.resolve(response({ ok: true }))
await logout
})
it('clears the previous account when the authenticated owner changes during identity refresh', async () => {
await store.refreshMe()
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
const previous = route
projects = []; tasks = []
route = (path, init) => path === '/api/me' ? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true })) : previous(path, init)
await store.refreshMe()
expect(store.getState()).toMatchObject({ me: { owner: 'bob' }, projects: [], tasks: [], currentProjectId: null, currentConvId: null })
})
it('clears projects, private tasks, previews and persisted selections on a new session', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
store.openSurface('surface-alice')
store.setUI({ projectSettingsId: 'host-1', adminZoneOpen: true, activeWorkspace: 'private-workspace' })
localStorage.setItem('as.conv', 'alice-private')
auth.beginSession()
expect(store.getState()).toMatchObject({ projects: [], tasks: [], me: null, currentProjectId: null, currentConvId: null, artifacts: [], previewTabs: {}, previewActive: {} })
expect(store.getState().ui).toMatchObject({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null })
expect(localStorage.getItem('as.conv')).toBeNull()
expect(JSON.parse(localStorage.getItem('as.ui') ?? '{}').lastProjectId).toBeNull()
})
it('drops old project responses after switching accounts even when IDs are shared', async () => {
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects' ? old.promise : previous(path, init)
const pending = store.refreshProjects()
auth.beginSession()
projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })]
route = previous
await store.refreshProjects()
old.resolve(response([project('alice-only')]))
await pending
expect(store.getState().projects.map(p => p.id)).toEqual(['host-1'])
expect(store.getState().currentProjectId).toBe('host-1')
})
it('drops an old private task list after logout and a new session', async () => {
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/conversations' ? old.promise : previous(path, init)
const pending = store.refreshTasks()
auth.logout()
auth.beginSession()
tasks = [task('bob-private')]
route = previous
await store.refreshTasks()
old.resolve(response([task('alice-private')]))
await pending
expect(store.getState().tasks.map(t => t.id)).toEqual(['bob-private'])
})
it('drops an old artifact response when the next account selects the same project', async () => {
await store.openProject('host-1')
const old = deferred<Response>()
const previous = route
route = (path, init) => path.endsWith('/artifacts') ? old.promise : previous(path, init)
const pending = store.refreshArtifacts('host-1')
auth.beginSession()
route = previous
await store.openProject('host-1')
old.resolve(response([{ id: 'alice-artifact', projectId: 'host-1', owner: 'alice', name: 'Alice private', kind: 'file', size: 10, createdAt: '', updatedAt: '' }]))
await pending
expect(store.getState().artifacts).toEqual([])
})
it.each(['action', 'pin', 'dismiss', 'to-project', 'tidy'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => {
projects = [ready]
await store.refreshProjects()
const old = deferred<Response>()
const previous = route
route = (path, init) => path.startsWith('/api/cards/') ? old.promise : previous(path, init)
const card: ActivityCard = { id: 'alice-card', projectId: 'host-1', kind: 'progress', status: 'active', title: 'Alice private work', createdAt: '', updatedAt: '' }
if (operation === 'tidy') store.upsertCard({ ...card, status: 'done' })
const pending = operation === 'action' ? store.cardAction(card, 'finish')
: operation === 'pin' ? store.cardPin(card, true)
: operation === 'dismiss' ? store.cardDismiss(card)
: operation === 'tidy' ? store.tidyCards() : store.cardToProject(card)
auth.beginSession()
route = previous
await store.refreshProjects()
old.resolve(response({ card: { ...card, status: 'done' }, origin: { ...card, status: 'dismissed' } }))
await pending
expect(store.getState().projectCards).toEqual([])
expect(store.getState().cards).toEqual([])
})
it('does not reopen a previous account conversation after its project load finishes late', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects()
await store.openProject('another-project')
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects/host-1/artifacts' ? old.promise : previous(path, init)
const pending = store.openTask('alice-private')
auth.beginSession()
tasks = []
route = previous
await store.refreshProjects()
old.resolve(response([]))
await pending
expect(store.getState().currentConvId).toBeNull()
expect(requests.map(r => r.path)).not.toContain('/api/conversations/alice-private')
})
it('does not resolve old task creation as success after a new login', async () => {
projects = [ready]
await store.refreshProjects()
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects/host-1/conversations' ? old.promise : previous(path, init)
const pending = store.createTask()
auth.beginSession()
route = previous
await store.refreshProjects()
old.resolve(response(task('alice-created')))
await expect(pending).rejects.toThrow(/登录状态/)
expect(store.getState().currentConvId).toBeNull()
})
it('does not return a previous account attachment ID when artifact refresh finishes after login', async () => {
projects = [ready]
await store.refreshProjects()
const old = deferred<Response>()
const started = deferred<void>()
const previous = route
route = (path, init) => {
if (path.endsWith('/uploads')) return Promise.resolve(response({ artifactId: 'alice-upload', filename: 'alice.txt', size: 3 }))
if (path.endsWith('/artifacts')) { started.resolve(); return old.promise }
return previous(path, init)
}
const pending = store.uploadAttachment(new File(['abc'], 'alice.txt'))
await started.promise
auth.beginSession()
route = previous
await store.refreshProjects()
old.resolve(response([]))
expect(await pending).toBeNull()
})
})
describe('export capability identity', () => {
it('drops the old identity export capability before any new command refresh', async () => {
const previous = route
route = async (path, init) => path === '/api/commands'
? response(['rdm.inspect.export.create', 'rdm.inspect.export.status', 'rdm.inspect.export.download']
.map(id => ({ id, enabled: true }))) : previous(path, init)
await store.refreshPlugins()
expect(store.getState().rdmExportAvailable).toBe(true)
auth.beginSession()
expect(store.getState().rdmExportAvailable).toBe(false)
})
})
+4 -4
View File
@@ -60,8 +60,8 @@ describe('命令载荷构造(身份只经认证会话——载荷不含任何
expect(memberRemovePayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol' })
})
it('文档职责与整文审签载荷', () => {
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave'))
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave' })
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave', 7, true))
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave', expectedDutyVersion: 7, confirmInvalidation: true })
expect(docSubmitPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
expect(docApprovePayload('p1', 'd1', '通过')).toEqual({ projectId: 'p1', docId: 'd1', note: '通过' })
expect(docRejectPayload('p1', 'd1', '依据不足')).toEqual({ projectId: 'p1', docId: 'd1', reason: '依据不足' })
@@ -87,12 +87,12 @@ describe('项目权限矩阵页(rdm.perm.check 消费)', () => {
it('矩阵查询载荷(身份只经认证会话)', () => {
expect(permCheckPayload('p1', 'bob')).toEqual({ projectId: 'p1', member: 'bob' })
})
it('来源标签:解释读面的人话映射;未知来源回退原文', () => {
it('来源标签:解释读面的人话映射;未知来源不暴露内部代码', () => {
expect(describeSource('admin')).toBe('系统管理员')
expect(describeSource('prof-role-default')).toContain('角色')
expect(describeSource('override')).toContain('覆盖')
expect(describeSource('relation-default')).toContain('关系')
expect(describeSource('non-member')).toContain('成员')
expect(describeSource('weird')).toBe('weird')
expect(describeSource('weird')).toBe('来源未提供')
})
})
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { ActionDetailTable, overrideMode, projectActions, type MatrixAction } from '../src/components/RdmPermMatrixSection'
import { DutyTree, dutyValidation, normalizeDuties, documentAssignmentImpact } from '../src/components/RdmDutiesSection'
import { personItem, readableError, RequestFence } from '../src/components/rdmSettingsShared'
const action: MatrixAction = { action: 'doc.write', scope: 'project', allowed: false, defaultAllowed: true,
capAllowed: false, override: true, source: 'cap', sourceLabel: '管理员权限上限' }
describe('project settings authorization presentation', () => {
it('preserves the explicit override even when a cap makes effective permission false', () => {
expect(overrideMode(action)).toBe('allow')
expect(overrideMode({ ...action, allowed: true, override: false })).toBe('deny')
expect(overrideMode({ ...action, override: undefined })).toBe('unavailable')
const html = renderToStaticMarkup(<ActionDetailTable actions={[action]} canManage />)
expect(html).toMatch(/value="allow"[^>]*disabled/)
expect(html).toContain('管理员上限禁止授予')
})
it('excludes all system actions including misleadingly scoped project creation from every project detail', () => {
const actions = [{ ...action, action: 'project.create' }, { ...action, scope: 'system' }, { ...action, scope: undefined }, action]
expect(projectActions(actions)).toEqual([action])
const html = renderToStaticMarkup(<ActionDetailTable actions={actions} canManage />)
expect(html).not.toContain('project.create')
expect(html).not.toContain('创建项目')
expect(html).toContain('编制与编辑文档')
})
it('joined or unverified accounts cannot be added through candidates', () => {
expect(personItem({ owner: 'bob', valid: true, joined: true }, true)).toMatchObject({ disabled: true, badge: '已加入' })
expect(personItem({ owner: 'bob' }, true)).toMatchObject({ disabled: true, disabledReason: '账号有效状态尚未核验' })
expect(personItem({ owner: 'bob', name: '李工', valid: true, joined: false }, true)).toMatchObject({ title: '李工', disabled: false })
})
it('only document nodes can enter document selection; chapters are visible context', () => {
const html = renderToStaticMarkup(<DutyTree selected="" onSelect={() => {}} nodes={[
{ id: 's', kind: 'section', label: '章节 A', selectable: true, docId: 'mistaken-doc', children: [] },
{ id: 'd', kind: 'doc', label: '方案文档', selectable: true, docId: 'real-doc', children: [] },
]} />)
expect(html).toContain('章节 A · 章节</span>')
expect(html).toMatch(/<button[^>]*>方案文档<\/button>/)
expect(html).not.toMatch(/<button[^>]*>章节 A/)
})
it('normalizes frozen domain roles without using project identity as signing authority', () => {
expect(normalizeDuties({ Author: 'author', Reviewers: ['reviewer'], Approver: 'approver' })).toEqual({ author: 'author', reviewers: ['reviewer'], approver: 'approver' })
expect(dutyValidation({ author: 'alice', reviewers: ['alice'], approver: 'bob' })).toContain('不同成员')
})
it('distinguishes unavailable capabilities, permission denial and readable service reasons', () => {
expect(readableError(new Error('400: unknown or disabled command: rdm.duty.tree'))).toContain('尚未装配')
expect(readableError(new Error('403: denied'))).toContain('没有权限')
expect(readableError(new Error('409: {"error":"职责版本冲突,请刷新"}'))).toBe('职责版本冲突,请刷新')
})
it('shows the command rejection reason without exposing its raw receipt envelope', () => {
const error = new Error('400: {"receipt":{"commandId":"rdm.review.assign.doc","state":"rejected","reason":"rejected: 职责版本冲突,请刷新"}}')
expect(readableError(error)).toBe('职责版本冲突,请刷新')
})
it('does not warn that unchanged duties or independently assigned chapters will be invalidated', () => {
const roles = { author: 'a', reviewers: ['r'], approver: 'p' }
const document = { docId: 'doc', title: '方案', assigned: true, roles, active: { round: 2 },
sections: [{ sectionId: 'chapter', title: '独立章节', roles, overridden: true, active: { state: 'in-review' } }] }
expect(documentAssignmentImpact(document, roles)).toBe(false)
expect(documentAssignmentImpact({ ...document, active: null }, { ...roles, author: 'b' })).toBe(false)
expect(documentAssignmentImpact({ ...document, active: null, sections: [{ ...document.sections[0], overridden: false }] }, { ...roles, author: 'b' })).toBe(true)
})
})
describe('late response fences used by all settings sections', () => {
it('discards pending reads after a context switch before effects can clear the form', async () => {
const fence = new RequestFence('alice:project-a')
const old = fence.begin('read')
const result = Promise.resolve().then(() => old() ? 'private-a' : 'discarded')
fence.setContext('bob:project-a')
expect(await result).toBe('discarded')
expect(fence.begin('read')()).toBe(true)
})
it('cancels old document reads while preserving an independent current request', () => {
const fence = new RequestFence('alice:project-a')
const first = fence.begin('detail')
const list = fence.begin('catalog')
const second = fence.begin('detail')
expect(first()).toBe(false); expect(second()).toBe(true); expect(list()).toBe(true)
fence.cancel('detail'); expect(second()).toBe(false)
fence.invalidate(); expect(list()).toBe(false)
})
})
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { RdmDutiesSection, RdmMembersSection, RdmPermMatrixSection, RdmRolesPanel } from '../src/components/adminRdmSections'
import { describeSource } from '../src/rdmManagement'
import type { Project } from '../src/contracts'
vi.mock('../src/store', () => ({
useStore: (select: (state: unknown) => unknown) => select({ me: { owner: 'alice', name: 'Alice', isAdmin: true } }),
}))
const project: Project = {
id: 'host-project', name: '研发项目', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'rdm-project', 'rdm.state': 'ready' },
}
describe('approved project settings interactions', () => {
it('offers a professional role filter separate from project identity when adding a member', () => {
const html = renderToStaticMarkup(<RdmMembersSection project={project} />)
expect(html).toContain('aria-label="专业角色筛选"')
expect(html).toContain('项目负责人')
expect(html).toContain('账号或姓名')
})
it('offers document ID/title searching and a tree/list view without manual ID assignment', () => {
const html = renderToStaticMarkup(<RdmDutiesSection project={project} />)
expect(html).toContain('aria-label="搜索文档"')
expect(html).toContain('树形')
expect(html).toContain('列表')
})
it('keeps the multi-member matrix disabled until caller management capability is known', () => {
const html = renderToStaticMarkup(<RdmPermMatrixSection project={project} />)
expect(html).toMatch(/<option[^>]*(?:value="matrix"[^>]*disabled|disabled[^>]*value="matrix")[^>]*>/)
})
it('renders readable action names in system role configuration', () => {
const html = renderToStaticMarkup(<RdmRolesPanel />)
expect(html).toMatch(/value="project.create"[^>]*>创建项目<\/option>/)
expect(html).toMatch(/value="duty.assign">分配文档职责<\/option>/)
})
it('never offers the system creation privilege as an editable project role action', () => {
const html = renderToStaticMarkup(<RdmRolesPanel />)
expect(html).toMatch(/<option[^>]*(?:value="project.create"[^>]*disabled|disabled[^>]*value="project.create")[^>]*>创建项目<\/option>/)
})
it('does not show raw permission source codes when the server adds a known scope denial', () => {
expect(describeSource('scope')).toBe('配置范围不符')
expect(describeSource('error')).toBe('暂时无法核验')
expect(describeSource('unrecognized-source')).toBe('来源未提供')
})
})
+3 -3
View File
@@ -26,10 +26,10 @@ describe('describeRoleListError', () => {
const denied = 'Error: 400: {"receipt":{"commandId":"rdm.role.list","state":"rejected",'
+ '"reason":"rejected: 权限拒绝:weichen 不是系统管理员(全局动作 project.settings)"}}'
it('maps the domain-admin denial to a readable message with the transfer channel', () => {
it('explains denied system-role authority without suggesting the retired transfer bypass', () => {
const got = describeRoleListError(denied)
expect(got).toContain('不是 RDM 域管理员')
expect(got).toContain('rdm.perm.transfer')
expect(got).toContain('当前账号无权管理系统专业角色')
expect(got).not.toContain('rdm.perm.transfer')
expect(got).not.toContain('"receipt"')
})
+33
View File
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
afterEach(() => vi.restoreAllMocks())
describe('versioned shared settings API', () => {
it('sends the loaded project revision and settings in the strict envelope', async () => {
let url: unknown, init: RequestInit | undefined
vi.spyOn(auth, 'fetch').mockImplementation(async (path, options) => {
url = path; init = options
return new Response(JSON.stringify({ ok: true, projectRevision: 8 }))
})
await api.putProjectSettings('host/id', { deliverables: { style: 'formal' } }, 7)
expect(url).toBe('/api/settings/project/host%2Fid')
expect(JSON.parse(String(init?.body))).toEqual({ values: { deliverables: { style: 'formal' } }, expectedRevision: 7 })
})
it('rejects permission and conflict failures for all settings writers', async () => {
vi.spyOn(auth, 'fetch').mockResolvedValue(new Response('changed', { status: 409 }))
await expect(api.putSystemSettings({}, 3)).rejects.toThrow('409')
vi.spyOn(auth, 'fetch').mockResolvedValue(new Response('denied', { status: 403 }))
await expect(api.putUserSettings({})).rejects.toThrow('403')
})
it('sends genuine inheritance removal without guessing a default value', async () => {
let init: RequestInit | undefined
vi.spyOn(auth, 'fetch').mockImplementation(async (_path, options) => {
init = options
return new Response(JSON.stringify({ ok: true, projectRevision: 8 }))
})
await api.putProjectSettings('host/id', {}, 7, ['deliverables.style'])
expect(JSON.parse(String(init?.body))).toEqual({ values: {}, expectedRevision: 7, unset: ['deliverables.style'] })
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { SettingInput } from '../src/components/SettingsModal'
import { scopedSchema } from '../src/components/SettingsFields'
import { BorrowPanels } from '../src/components/BorrowPanels'
import type { SettingDesc } from '../src/contracts'
vi.mock('../src/store', () => ({ useStore: (select: (state: unknown) => unknown) => select({ ui: {} }) }))
const field = (type: SettingDesc['type'], options?: string[]): SettingDesc => ({
key: 'deliverables.style', group: 'deliverables', label: '交付风格', type, options, effect: 'new-run',
})
describe('setting editors preserve types and readable values', () => {
it('keeps legacy personal plugin settings visible only in personal scopes', () => {
const schema: SettingDesc[] = [
{ ...field('string'), key: 'plugin.legacy', scope: undefined },
{ ...field('string'), key: 'plugin.user', scope: 'user' },
{ ...field('string'), key: 'plugin.project-personal', scope: 'project-personal' },
{ ...field('string'), key: 'plugin.project', scope: 'project' },
{ ...field('string'), key: 'plugin.system', scope: 'system' },
]
const keys = (scope: string) => scopedSchema(schema, scope).map(desc => desc.key)
expect(keys('user')).toEqual(['plugin.legacy', 'plugin.user'])
expect(keys('project-personal')).toEqual(['plugin.legacy', 'plugin.user', 'plugin.project-personal'])
expect(keys('project')).toEqual(['plugin.project'])
expect(keys('system')).toEqual(['plugin.system'])
})
it('uses a number input for numeric values rather than submitting arbitrary strings', () => {
expect(renderToStaticMarkup(<SettingInput desc={field('number')} value={12} onChange={() => {}} />)).toContain('type="number"')
})
it('shows readable enum names and a distinct inherited choice', () => {
const html = renderToStaticMarkup(<SettingInput desc={field('enum', ['modern', 'formal', 'minimal'])} value="" onChange={() => {}} />)
expect(html).toContain('沿用默认')
expect(html).toContain('>现代</option>')
expect(html).toContain('>正式</option>')
expect(html).toContain('>简洁</option>')
})
it('personal supplementary panels no longer offer system credentials', () => {
const html = renderToStaticMarkup(<BorrowPanels />)
expect(html).not.toContain('新增密钥')
expect(html).not.toContain('主模型')
expect(html).toContain('条件注入')
expect(html).toContain('用户命令')
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { personalSettingsMutation, settingsMutation } from '../src/components/SettingsFields'
import type { SettingDesc } from '../src/contracts'
const schema: SettingDesc[] = [
{ key: 'tools.limit', group: 'tools', label: '上限', type: 'number', effect: 'new-run', scope: 'system' },
{ key: 'models.chat.apiKey', group: 'models', label: '密钥', type: 'secret', secret: true, effect: 'hot', scope: 'system' },
]
describe('settings merge patch behavior', () => {
it('omits unchanged masked credentials and removes a numeric override without storing its default', () => {
expect(settingsMutation({ 'tools.limit': 20, 'models.chat.apiKey': '••••••' }, { 'tools.limit': undefined, 'models.chat.apiKey': '••••••' }, schema))
.toEqual({ values: {}, unset: ['tools.limit'] })
})
it('sends explicit secret revocation as an empty string rather than losing it through flattening', () => {
expect(settingsMutation({ 'models.chat.apiKey': '••••••' }, { 'models.chat.apiKey': '' }, schema))
.toEqual({ values: { models: { chat: { apiKey: '' } } }, unset: [] })
})
it('does not forward fields outside the visible scoped schema', () => {
expect(settingsMutation({}, { 'tools.limit': 0, 'project.create': true }, schema))
.toEqual({ values: { tools: { limit: 0 } }, unset: [] })
})
it('uses explicit null deletion for personal inheritance instead of omitting a merged field', () => {
expect(personalSettingsMutation({ 'tools.limit': 20 }, { 'tools.limit': undefined }, schema))
.toEqual({ tools: { limit: null } })
})
})
+26 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces'
import { buildWorkspaceEntries, rdmAuditReady, rdmExportReady, exportNeedsDraft, RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } from '../src/workspaces'
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
@@ -72,4 +72,29 @@ describe('rdmAuditReady', () => {
expect(rdmAuditReady(null)).toBe(false)
expect(rdmAuditReady(undefined)).toBe(false)
})
it('导出命令不参与七命令就绪门', () => {
expect(rdmAuditReady(all)).toBe(true)
const withExportOff = [
...all,
{ id: 'rdm.inspect.export.create', enabled: false },
]
expect(rdmAuditReady(withExportOff)).toBe(true)
})
})
describe('rdmExportReady', () => {
const audit = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
const exp = RDM_EXPORT_COMMANDS.map(id => ({ id, enabled: true }))
it('三条导出命令启用才显示导出区', () => {
expect(rdmExportReady([...audit, ...exp])).toBe(true)
expect(rdmExportReady(audit)).toBe(false)
expect(rdmExportReady(exp.filter(c => c.id !== 'rdm.inspect.export.download'))).toBe(false)
})
it('正式报告不完整时才要求草稿确认', () => {
expect(exportNeedsDraft('正式报告不完整(合规缺口)——传 draft:true 生成草稿')).toBe(true)
expect(exportNeedsDraft('项目不存在')).toBe(false)
})
})