Compare commits

...
Author SHA1 Message Date
weichenandCursor b02ff83dfc Hide project-only surfaces in scratch conversations
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 39s
Why: Independent review found the cross-read toggle, the project inbox
scope and the archive-to-project action still reachable from an open
scratch (no-project) conversation.

Evidence: npx vitest run (685 tests) and npx tsc -b --noEmit pass locally
against the UI fix branch (SELF); CI on the PR head is authoritative.

Impact: Scratch conversations hide the cross-read setting, always show
the conversation inbox and omit onArchive; real projects are unchanged.

Refs: Agent-System-UI#60; agent-exec receipt u3e-run (cursor, success)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 03:48:00 +00:00
weichenandCursor 1d7f2a3e1b Add the no-project conversation view backed by scratch sessions
governance / governance (pull_request) Successful in 18s
ci / test-and-build (pull_request) Successful in 3m48s
Why: Accounts without project.create need isolated general-agent chats.
The sidebar groups every scratch session under one "无项目对话" entry
instead of the project switcher, and the client no longer auto-creates
a default project.

Evidence: npx vitest run (64 files, 677 tests) and npx tsc -b --noEmit
pass locally (SELF); CI on the PR head is authoritative.

Impact: createTask uses POST /api/scratch-sessions in scratch mode or
when no real project exists; scratch rows hide cross-read toggles and
project settings; "新建项目…" stays gated by project.create.

Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md;
agent-exec receipt u3-run5 (cursor, success)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 03:18:08 +00:00
weichen 18fc958def Merge pull request 'Skip the project surfaces request when no project is current' (#122) from fix/no-project-surfaces-404 into main
governance / governance (push) Successful in 15s
ci / test-and-build (push) Successful in 32s
2026-10-20 15:39:58 +00:00
agent-exec-transport 16a059381c agent-exec transport ce569400d5a47d09bea58e39b89b8843/8043305a983f
governance / governance (pull_request) Successful in 23s
ci / test-and-build (pull_request) Successful in 31s
2026-10-20 15:38:38 +00:00
weichen e4cd8f1a57 Merge pull request 'Tell removed members why sending is refused' (#121) from fix/multiuser-collab-followups into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 48s
2026-10-20 11:58:24 +00:00
agent-exec-transport 9f7f4a164b agent-exec transport 257898d9fdadf655ee20accee6813725/4e997a3ec7a5
governance / governance (pull_request) Successful in 29s
ci / test-and-build (pull_request) Successful in 1m11s
2026-10-20 11:54:59 +00:00
weichen a13d041159 Merge pull request 'Unexport two helpers to keep the knip ratchet green' (#120) from fix/multiuser-project-collab-r4 into main
governance / governance (push) Successful in 19s
ci / test-and-build (push) Successful in 25s
2026-10-20 11:19:39 +00:00
agent-exec-transport f6442cc501 agent-exec transport b00819fbf97351256c6953a271c02e52/a305d1ebb539
ci / test-and-build (pull_request) Successful in 32s
governance / governance (pull_request) Successful in 13s
2026-10-20 11:12:36 +00:00
weichen e2a670e2aa Merge pull request 'Keep admin user detail and one-time password working' (#119) from fix/multiuser-project-collab-r3 into main
governance / governance (push) Successful in 20s
ci / test-and-build (push) Successful in 29s
2026-10-20 10:50:08 +00:00
agent-exec-transport 1fe321240a agent-exec transport 91fa500d0ed8f7daca02bd46ba843a9a/b201c6aced99
governance / governance (pull_request) Successful in 13s
ci / test-and-build (pull_request) Successful in 46s
2026-10-20 10:48:43 +00:00
weichen c6df2dc180 Merge pull request 'Skip role options for non-managers and guard zero run time' (#117) from fix/multiuser-project-collab-r2 into main
ci / test-and-build (push) Successful in 5m56s
governance / governance (push) Successful in 20s
2026-10-20 07:05:50 +00:00
weichen 97b947a8e3 Merge branch 'main' into fix/multiuser-project-collab-r2
ci / test-and-build (pull_request) Successful in 1m13s
governance / governance (pull_request) Successful in 16s
2026-10-20 07:01:35 +00:00
weichen 096947f9a2 Merge pull request 'IAM U5-Frontend integration-tests via PI direct (cursor 3/3 blocked)' (#118) from feature/iam-link-u4-frontend-auto-discover into main
governance / governance (push) Successful in 19s
ci / test-and-build (push) Failing after 56s
2026-10-20 06:59:46 +00:00
weichen 3fe5cf1617 Merge Frontend main into U5-Frontend branch for sync
governance / governance (pull_request) Successful in 16s
ci / test-and-build (pull_request) Successful in 2m10s
2026-10-20 06:55:29 +00:00
weichen e73f2ad9dd U5-Frontend integration-tests via PI direct (cursor 3/3 blocked on Playwright + vitest-only)
governance / governance (pull_request) Successful in 13s
ci / test-and-build (pull_request) Successful in 3m38s
Why: cursor 3/3 exhausted; PI direct dispatched as host integration work.

Scope: 8 test files (7 modified + 1 new), 255 insertions, 29 new vitest tests covering 11 U4 frontend-auto-discover scenarios end-to-end:
- AutoDiscover (3 new in store.test.ts)
- CapabilityGroup (3 new in rolePermissions.test.ts + 6 new in panels)
- CreatorOwnerMemberDuties (4 new across adminRdmSections/projectmeta/AccountUsersPanel)
- MemberManagementEntryHidden (3 new in adminRdmSections/projectmeta)
- EmptyListNoCreate (4 new in projectmeta/TaskSidebar)
- DefaultProjectDisabled (1 new in store.test.ts/projectmeta)
- RefreshBackoff + RefreshSingleFlight + RefreshHiddenPause + RefreshReconnectCompensate
- ProjectLifecycleConversation (5 new in test/conversation-work.test.ts)

Verification: npm run build ok (5.35s); ./node_modules/.bin/vitest run 656/656 (was 627, +29 new); new 29 tests across all 11 patterns all pass; no production files touched.

Refs: plan revision 3 U5-Frontend; /tmp/iam-link-u5-frontend-pi-verify-20261019.json exit=0.
2026-10-20 06:50:53 +00:00
agent-exec-transport d997d4a4d0 agent-exec transport 85c00f1e2274be1a84b73a421987fc69/ba12fd06dc72
governance / governance (pull_request) Successful in 16s
ci / test-and-build (pull_request) Successful in 34m20s
2026-10-20 06:21:55 +00:00
weichen 0b388e010d Merge pull request 'IAM U4 frontend-auto-discover (5s polling + capability-group UI + creator/owner/member duties)' (#115) from feature/iam-link-u4-frontend-auto-discover into main
ci / test-and-build (push) Successful in 3m37s
governance / governance (push) Successful in 15s
2026-10-20 03:31:25 +00:00
weichen 2bd5116f58 Merge Frontend main into U4 branch for L1→main sync
governance / governance (pull_request) Successful in 13s
ci / test-and-build (pull_request) Successful in 2m21s
2026-10-20 03:26:56 +00:00
weichen d7768d4ea0 Merge pull request 'Switch account UI to administrator one-time passwords' (#114) from feature/auth-one-time-password-r2 into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 27s
2026-10-20 01:56:52 +00:00
agent-exec-transport 3c22d0957a agent-exec transport f9152d30e9410417157ea4a3a9a926ba/e09f009fa9f5
governance / governance (pull_request) Successful in 23s
ci / test-and-build (pull_request) Successful in 36s
2026-10-20 01:48:41 +00:00
agent-exec-transport e296d38ec2 agent-exec transport 0bde562073461c828c740d52a55a8b5d/a5fbfeb4311f 2026-10-20 01:19:46 +00:00
agent-exec-transport f01579890d agent-exec transport 280102ed04383156d99d681a10a5b302/b5e3788d8c79
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 27s
2026-10-19 15:14:12 +00:00
weichen 259021d530 Merge pull request 'Merge the duplicate rdmManagement import in adminRdmSections' (#113) from fix/nonblocking-followups into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 39s
2026-10-19 14:31:28 +00:00
agent-exec-transport 7d43ef6ff7 agent-exec transport a4840fd95e17330e094af504afeba096/59a2059a0705
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 36s
2026-10-19 14:25:36 +00:00
weichen 9baf96d937 Merge pull request 'Merge the accepted super-admin changes into main' (#112) from feature/super-admin-merge into main
governance / governance (push) Successful in 16s
ci / test-and-build (push) Successful in 26s
2026-10-19 09:43:13 +00:00
weichen dab16052cf Merge feature/super-admin history onto main
governance / governance (pull_request) Successful in 40s
ci / test-and-build (pull_request) Successful in 54s
2026-10-19 09:40:42 +00:00
agent-exec-transport ce15f7977b agent-exec transport 548284a3917af91d24d89efd66e5ec7c/d0c96d333a18 2026-10-19 09:40:34 +00:00
weichen f560201f8b Merge pull request 'Verify progressive material states in a real browser' (#79) from feature/fast-slow-u2-u3 into main
governance / governance (push) Successful in 15s
ci / test-and-build (push) Successful in 26s
2026-10-19 09:20:39 +00:00
agent-exec-transport 6f8e7ec604 agent-exec transport 9f91fe4ba0c0b0412d4616549f170f87/cff15f49e8bf
governance / governance (pull_request) Successful in 17s
ci / test-and-build (pull_request) Successful in 37s
2026-10-19 05:25:04 +00:00
agent-exec-transport aa49e0dbf3 agent-exec transport 3646d6259c27d42bf24f52c61364d67e/bea155d33d2e
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 30s
2026-10-19 05:10:37 +00:00
weichen 9df5517e9c Merge Frontend main into feature/super-admin
Why: Synchronize Frontend main (IAM U3 account UI #86) into the super-admin branch and resolve conflict in api.ts, AccountUsersPanel.tsx and adminSections.tsx as unions.

Evidence: npm test passed (49 files, 574 tests), check-knip passed.

Impact: AccountUsersPanel supports both super-admin identity/elevation and U3 initialization/credential states.

Refs: #627, Frontend PR #86
2026-10-19 03:49:39 +00:00
weichen b4fec2fdf4 Merge feature/fast-slow-u2-u3 history after syncing with main
governance / governance (pull_request) Successful in 20s
ci / test-and-build (pull_request) Successful in 35s
2026-10-19 03:38:49 +00:00
agent-exec-transport 164ea66039 agent-exec transport 9f1c4114b4dc96cd086583d1c03cd7a4/07c6c40e6ebc 2026-10-19 03:37:22 +00:00
weichen 23877af960 Fix unused exports in accountAuthority for knip guard 2026-10-19 03:23:04 +00:00
agent-exec-transport e6bb387f71 agent-exec transport 1923e710321578c193e3dc882b41cf2f/7f9287b28660 2026-10-19 03:17:22 +00:00
weichen 6db528df10 Merge pull request 'Add U3 account management and recovery UI' (#86) from feature/iam-u3-account-ui into main
governance / governance (push) Successful in 14s
ci / test-and-build (push) Successful in 26s
2026-10-19 01:08:09 +00:00
weichenandCursor 22ef3c4b67 Fold-close the IAM U3 Frontend task card before merge
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 29s
Why: close the card in the same PR before merging.
Evidence: pr-check PR_CHECK_OK on 080c630.
Impact: task card only.
Refs: Frontend PR #86
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 01:07:00 +00:00
weichenandCursor 080c630816 Add the IAM U3 Frontend task card for the PR gate
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 33s
Why: the branch predates Frontend governance adoption and had no task card, so pr-check refused PR #86.
Evidence: card records the agent-exec units, host verification log, CI and independent review of e4041b3.
Impact: task card only.
Refs: Frontend PR #86
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 01:06:46 +00:00
weichen e4041b36b9 Merge IAM U3 account UI history onto Frontend main
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 28s
Why: PR #86 predates IAM U4 and the account-chain UI on main; the U3 behaviour was ported onto main by agent-exec transports d47c493, 72c941c and afd9e75, so this merge only records the U3 branch history.
Evidence: tree equals afd9e75; host tsc -b, vitest 565 passed, build and knip on 72c941c (SELF); independent review blocking item fixed in afd9e75.
Impact: no tree change versus afd9e75.
Refs: Frontend PR #86, base main 1650b4f
2026-10-19 01:04:53 +00:00
agent-exec-transport afd9e7504b agent-exec transport a522bfd9db3df00c549eaa7e63f94562/3f39344cbd2e 2026-10-18 16:57:45 +00:00
agent-exec-transport 72c941c772 agent-exec transport 4a9e9a1c45e7ac50517ea954d95026bb/16e6bae28677 2026-10-18 16:51:49 +00:00
agent-exec-transport d47c49350b agent-exec transport 832aaeb220c114fdce38450a7e3d2dc7/e80375762b4e 2026-10-18 16:31:17 +00:00
weichen 1650b4fff0 Merge pull request 'Add IAM U4 role and permission management UI' (#111) from feature/iam-u4-role-permission-ui into main
governance / governance (push) Successful in 19s
2026-10-18 14:20:29 +00:00
weichen 4d7dffeb4b Merge Frontend main into IAM U4 role permission UI
governance / governance (pull_request) Successful in 18s
Why: Frontend main d562d6a is the PR #106 merge commit of df7818f, which this
branch already contains; recording it keeps the branch current for the
outdated-branch merge check.
Evidence: d562d6a and df7818f have identical trees; the merge changes no
content.
Impact: none.
Refs: Frontend PR #106
2026-10-18 14:16:13 +00:00
weichenandCursor ac45aa71aa Merge IAM U4 role permission UI history onto Frontend main
Why: the U4 Frontend branch is 100 commits behind main, which now carries the
shared document renderer delivery UI, two-trees project work and regulations
citations (PR #106 merged as d562d6a); it conflicted in the API client and
the admin sections. The user authorized syncing U4 after the renderer line on
2026-10-18.
Evidence: content is the agent-exec transport cf07aef (cursor worker, unit
frontend-iam-u4-onto-main-d562d6a-r1, run on a checkout whose tree equals
main, receipt /tmp/cleanup-20261018/render/u4-frontend-onto-run.json, SELF),
integrated with agent-exec apply onto df7818f; this merge (strategy ours)
only records U4 commit 05609ff as an ancestor and changes no content.
Host review: U4's removal of the superseded RDM roles panel and main's move
of RdmDutiesSection to duties/ are both kept; root administrators keep main's
account creation and password reset panel under the U4 users section.
Impact: the Python environment admin section becomes root-only like the other
U4 system sections; AccountRecord gains a ManagedAccountRecord view type.
Refs: Frontend main d562d6a, PR #106

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 12:53:17 +00:00
agent-exec-transport cf07aef3d1 agent-exec transport e0882b049f8aabe5f01b03d73f7fcb71/8260fbbdf34b 2026-10-18 12:50:28 +00:00
weichen d562d6a95c Merge pull request 'Add the unified document render workspace with review and range annotations' (#106) from feature/unified-document-renderer-task11 into main
governance / governance (push) Successful in 13s
2026-10-18 12:39:24 +00:00
weichenandCursor df7818f02a Merge Frontend main into the unified document renderer workspace
governance / governance (pull_request) Successful in 15s
Why: PR #106 is behind main by 15 commits (password login, regulation
citations, RDM member search) and conflicted in RdmAuditWorkspace.tsx and
rdmManagement.ts; the user authorized merging the renderer line on 2026-10-18.
Evidence: content is the agent-exec transport ec5012fe (cursor worker, unit
frontend-task11-sync-main-50ec836-r2, receipt
/tmp/cleanup-20261018/render/frontend-sync-run2.json, SELF): tsc -b, vitest
541/541 and vite build passed; host reviewed both conflict resolutions as unions.
Impact: no behaviour removed from either side; regulation tab and document
open flow coexist in the materials tab.
Refs: PR #106, Frontend main 50ec836

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 12:32:10 +00:00
weichen 50ec83657a Merge the regulation citation card status fix
governance / governance (push) Successful in 15s
Why: PR #109 archived the card with the pre-close status line.
Evidence: CI success on 1f6bff9487.
Impact: Card only.
Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/110
2026-10-18 11:17:14 +00:00
weichenandCursor 1f6bff9487 Mark the archived regulation citation card as closed
governance / governance (pull_request) Successful in 23s
Why: The archive move in PR #109 kept the pre-close status line.
Evidence: Status and PR link only.
Impact: Card only.
Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/109
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 11:12:06 +00:00
weichen 426d1044ac Merge pull request 'Wire RDM member settings to server-side member search' (#108) from feature/rdm-settings-phase1a into main
governance / governance (push) Successful in 23s
2026-10-18 11:05:51 +00:00
weichen 8260bf5bb4 Merge branch 'main' into feature/rdm-settings-phase1a
governance / governance (pull_request) Successful in 15s
2026-10-18 11:05:04 +00:00
weichen 2d65a819f1 Merge structured regulation citation rendering (U7)
governance / governance (push) Successful in 22s
Why: U7 requires structured regulation citations to be visible in audit and document views.
Evidence: CI success on 7050c91334 (governance); vitest/knip/tsc/vite build passed in the composed layout (SELF).
Impact: New regs audit tab and citation rendering; existing tabs unchanged.
Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/109
2026-10-18 11:03:21 +00:00
weichenandCursor 7050c91334 Close the regulation citation frontend task card before merge
governance / governance (pull_request) Successful in 17s
Why: Cards close and archive in the merging PR.
Evidence: Status, PR link and archive move only.
Impact: Card only.
Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/109
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 10:59:01 +00:00
weichenandCursor a9e2c3fa25 Record regulation citation frontend results in the task card
governance / governance (pull_request) Successful in 18s
Why: Task card is the PR description.
Evidence: vitest, knip, tsc and vite build passed in the composed layout (SELF); CI pending.
Impact: Card only.
Refs: Agent-System-Dev PR #614
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 10:58:55 +00:00
agent-exec-transport 6a3f2447f0 agent-exec transport 4cf12d612fc8a946cd63c630834d3beb/35d799203497 2026-10-18 10:53:59 +00:00
agent-exec-transport 96ab2ebd32 agent-exec transport ec7392fedc38af5f4b87509ddc658ec3/46e128ab5767
governance / governance (pull_request) Successful in 18s
2026-10-18 10:53:45 +00:00
weichenandCursor 57f2746c59 Open the regulations citation frontend task card
Why: U7 frontend rendering of structured regulation citations needs a task record.
Evidence: Card only.
Impact: None on code.
Refs: Agent-System-Dev docs/superpowers/specs/2026-10-18-regulations-citation-contract.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 10:46:08 +00:00
weichen 04595677a2 Merge pull request 'Land password login and admin temporary reset UI pinned by Dev' (#107) from feature/auth-password-management-frontend into main
governance / governance (push) Successful in 15s
Land password login and admin temporary reset UI pinned by Dev (#107)
2026-10-18 10:43:45 +00:00
weichen 7df77c1a8c Merge branch 'main' into feature/auth-password-management-frontend
governance / governance (pull_request) Successful in 14s
2026-10-18 10:41:10 +00:00
weichenandCursor 1445f9e2af Give audit history fetches their own seq owner and label tree row state
governance / governance (pull_request) Successful in 15s
Why: TimelineTab's history effect read the shared events genRef without
incrementing it, so two overlapping history requests both passed the
check and a stale response could overwrite a fresher one, violating the
single-async-owner / drop-stale-events rule. Tree row buttons also hid
their expand state from assistive tech and announced the decorative caret.

Evidence: new src/components/RdmAuditWorkspace.test.tsx (7 cases) covers
out-of-order resolve, stale rejection, cleanup invalidation, ref isolation
and aria attributes; reverting to the non-incrementing read fails 3 cases.
npm test 43 files / 503 tests pass; npm run build ok;
node scripts/check-knip.mjs 50 <= 51; test/design-guard.test.ts 7/7.

Impact: history state now owned by historyGenRef via startLatestFetch
(incremented per fetch, checked on resolve, invalidated on cleanup); the
events-list seq is untouched. Expandable tree buttons expose
aria-expanded, non-expandable rows omit it, caret is aria-hidden.

Refs: review findings on RdmAuditWorkspace (TimelineTab history seq,
tree row a11y); AGENTS.md async-state rule; DESIGN.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 07:27:41 +00:00
weichenandCursor 0d6b8d7745 Merge readable-audit two-trees frontend work into the render workspace
Why: Owner decision merge_whole requires the parent "two trees" initiative's
Frontend work (local-only 95e1948, feature/rdm-two-trees-readable-audit:
814643f..95e1948) to ship together with the unified document render
workspace branch.

Evidence:
- Conflict src/components/RdmAuditWorkspace.tsx (TreeList render): HEAD added
  a per-document "阅读与交付" open button inside the old two-level doc/section
  list; 95e1948 replaced that list with the recursive TreeNodeRow. Kept the
  recursive TreeNodeRow and moved the open button into it (wrapped in
  .rdm-audit-tree-row, onOpenDoc threaded through recursion, shown for
  kind === 'doc' at any depth; aria-label keeps "阅读与交付:<title>").
  MaterialsTab/DocumentRenderWorkspace, saveBase64File and key={pid} from
  HEAD and readable presentation, paging, detail seq-guard, trace relation
  and timeline changes from 95e1948 merged cleanly.
- Conflict src/styles.css (both appended at end of file): kept HEAD's
  render workspace / cockpit rules followed by 95e1948's 760px media query.
- Semantic fix e2e/rdm-audit.playwright.ts: tree kind is now shown in
  business language, so the section filter matches '章节' instead of
  'section'.
- npm test: 42 files, 496 tests passed (incl. design-guard,
  rdmAuditPresentation, rdmAuditTree); npm run build: ok;
  node scripts/check-knip.mjs: 50 <= 51; npx playwright test --list: 78 tests
  in 17 files.

Impact: RDM audit materials tab renders both project trees recursively with
readable names and still opens any document in the render workspace; audit
timeline and trace show readable records and retired/stale relations.

Refs: 95e1948 (814643f, b52f965, c9b55eb, b921bef, 505aba3, 1ef68ab,
ad503c9, 987be74, 95e1948); HEAD 50d12ce

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 07:16:47 +00:00
weichenandCursor 50d12ce974 Anchor review annotations on the selected node text range
governance / governance (pull_request) Successful in 15s
Why: RDM now anchors annotations on nodeId + textStart + textEnd + textHash
(spec 06 §2) and acceptance 10 §5 requires accurate comment ranges. The
workspace only sent whole-node annotations and ignored quote/needsReanchor.

- Selecting text inside one node's title in the reader view records a
  code-point range (Array.from indices; surrogate pairs and ZWJ emoji are
  counted as runes, half-pair endpoints widen) and sends textStart/textEnd
  plus quote, so RDM rejects rather than misplaces a drifted range.
- No selection keeps whole-node behavior; replies never carry range params;
  textHash/range are never sent; a range is dropped if it belongs to another
  node.
- Selections spanning several nodes or containing no node text record no
  range and show a notice that the whole node will be annotated; the composer
  always shows the scope with a "whole node" escape.
- Annotation items show the server quote and a visible needs-reanchor state;
  review_anchor_invalid: errors map to readable Chinese text.
- Reader titles become role=button spans because browsers do not allow text
  selection inside <button>.

Evidence: npm test 40 files / 486 tests pass (30 new in
test/review-annotation-anchor.test.ts); npm run build exit 0;
node scripts/check-knip.mjs "50 <= 51"; design-guard 7/7; playwright
--list compiles 13 tests; headless Chromium check of the selection reader
(single, multi-node, triple-click clamp, emoji offsets).

Impact: document review UI only; base-gate decision source anchoring is
unchanged. Offsets are computed on the displayed node title (no frozen-text
read endpoint exists), guarded by quote equality on the server.

Refs: unified renderer plan Task 13; phase1 F2; spec 06 §2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 06:27:22 +00:00
weichen 0e05624823 Merge remote-tracking branch 'origin/main' into feature/unified-document-renderer-task11 2026-10-18 01:04:41 +00:00
weichenandCursor 12553f53eb Locate review items and render trace by tree node id and mask audit-only trace
Why: RDM be80ad4 (docs/design/render-node-id-contract.md) accepts workspace tree
node ids (dn-/sec-/legacy-) for review annotate/revise, adds treeNodeId to receipts,
rdm.render.review.view items and trace entries while nodeId stays canonical, points
anchored-to at the tree node id, and marks audit-reader trace entries with
contentMasked=true. Grouping review items by the canonical nodeId left them all
unlocated, and the workspace showed no render trace at all.

Evidence: npx tsc --noEmit exit 0; npx vitest run 39 files / 456 tests passed (12 new
in test/document-render-workspace.test.ts: treeNodeId grouping with nodeId fallback
and orphan rules for annotations and revisions, data-locate targets, annotate/revise
payload carrying the selected tree node id, layer=render filtering, anchored-to
against tree nodes without canonical fallback, masked entries never reading body
fields, empty state); npm run build ok; node scripts/check-knip.mjs 51 <= 51;
e2e/document-renderer.playwright.ts type-checks (tsc exit 0) and
npx playwright test --list loads 13 tests.

Impact: locateNodeId (treeNodeId first, nodeId fallback, otherwise unlocated) is
shared by diagnostics, review grouping and trace. Clicking an annotation or revision
group, a reply, or a trace node locates and re-scrolls like diagnostics. Annotate and
revise send the selected tree node id as nodeId. A 渲染链条 panel reads
rdm.inspect.relations (kind=doc) through the single guarded resource owner (stale
responses dropped) and reloads after preview, submit, export, review and decision
actions. Masked entries show metadata only with a 内容已遮蔽 marker and a panel
notice. The e2e spec is unchanged: §11-5b already asserts that the group title is the
selected tree node, and the leak scan is untouched. Not run against a live backend
or browser here.

Refs: RDM be80ad4 docs/design/render-node-id-contract.md; unified renderer spec 10
§11 items 4, 5 and 7; DESIGN.md §8, §9.2, §11.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 00:34:04 +00:00
weichenandCursor 6727cdd30f Locate render diagnostics by tree node id and expect published DHF rules
Why: Browser acceptance (/tmp/renderer-browser-acceptance/20261017T061021Z) found
§11-4 showed 25/25 diagnostics as 未定位节点 because the renderer nodeId is the
canonical mapping id (pp.cover.document-number.value) while the workspace tree uses
dn-… ids; RDM now adds treeNodeId to every diagnostic. §11-3 failed only because
the e2e spec still expected 已验证/未发布 although all 44 DHF profiles are published.

Evidence: npx tsc --noEmit exit 0; npx vitest run 39 files / 445 tests passed (4 new
in test/document-render-workspace.test.ts covering treeNodeId, nodeId fallback,
unknown ids and per-node counts); npm run build ok; node scripts/check-knip.mjs
unused exports 51 <= 51; e2e/document-renderer.playwright.ts type-checks (tsc exit 0)
and playwright test --list loads 13 tests. Real rdm.render.profiles payload in
evidence/network/item3-author.jsonl: template rule state=published,
publicationAllowed=true, no blockedReason, refusal controlled_requires_exact_profile.

Impact: normalizeDiagnostics keeps treeNodeId; locateDiagnostic resolves treeNodeId
first and falls back to nodeId, otherwise the diagnostic stays explicitly unlocated.
Badges and the 有诊断问题 filter count by the located tree node. Clicking a located
diagnostic selects the node and scrolls it into view in canvas or reader, also on a
repeated click. Diagnostics loading keeps its single request guard. §11-3 now asserts
已发布, 可正式交付, an enabled option and no reason, and still asserts source, version,
the no-fallback notice and refusal. Not run against a live backend or browser here.

Refs: unified renderer plan Task 13 §11 items 3 and 4; spec 10 §11 item 3;
summary.md root causes §A and §B.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 00:12:58 +00:00
weichen 92864db8d3 Clarify user management flows 2026-10-17 11:56:33 +00:00
weichen fb1f9f246b Fallback proofless recovery to temporary reset 2026-10-17 10:43:47 +00:00
weichen 2f8009b967 Use password login and admin temporary resets 2026-10-17 10:30:17 +00:00
weichen 0f31f91af5 Merge pull request 'Adopt the shared governance kit (development-governance-v2)' (#105) from feature/governance-adoption into main
governance / governance (push) Successful in 16s
2026-10-17 08:11:43 +00:00
weichen 61986dbcd9 Close and archive the governance adoption card
governance / governance (pull_request) Successful in 16s
Why: the adoption PR is reviewed and its candidate HEAD passed CI; the card closure is folded into the PR so no separate close PR is needed.
Evidence: CI GATE on bc866c4489075b8c2f62063952ff9f3a4f4119a4; independent Review REPLAY (pi --no-session(fresh_session=true,session id not-observable)VERDICT: APPROVE,无阻塞项).
Impact: task card only (moved to .task/archive/).
Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/105
2026-10-17 08:03:11 +00:00
weichen bc866c4489 Record the adoption unit run and positive/negative evidence
governance / governance (pull_request) Successful in 23s
Why: Phase 6 requires one positive executor run and the negative cases (missing config, bad receipt, out-of-scope path, unknown side effect) before adoption becomes active.
Evidence: .task/evidence/governance-adoption/ (SELF): governance check, agent-exec validate and adoption cases; this commit passed the generated pre-commit hook (admit).
Impact: task card and evidence logs only.
Refs: .task/feature-governance-adoption.md
2026-10-17 07:15:45 +00:00
agent-exec-transport d64dbc3a15 agent-exec transport e3fd063def382c53308a521c7306aed6/2daad8d16d7c 2026-10-17 06:56:39 +00:00
weichen 5670c68e1f Bootstrap AGENT-EXECUTION.md and the governance adoption card
Why: development-governance-v2 Phase 6 adopts the shared governance kit in every formal repository; the executor needs a valid AGENT-EXECUTION.md before any implementation unit can run.
Evidence: generated by dm-server governance init at Development-Manager bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c; dm-server agent-exec validate passes.
Impact: configuration and task card only; no code or CI change yet.
Refs: Agent-System-Dev docs/superpowers/plans/2026-10-16-development-governance-v2-master.md
2026-10-17 06:53:57 +00:00
weichen 8b59998d3c Merge pull request 'Fix inverted cancel guard in cockpit project loader' (#104) from fix/cockpit-cancel-guard into main 2026-10-17 04:58:22 +00:00
weichen c9ba3442fc Open context pane before cockpit entry in narrow layout
Why: at 390px the single-pane layout lands on 对话 after login and hides
the whole context sidebar (both sidebar and rail forms); scenario 7 must
switch to the 上下文 pane via the top bar before the entry exists.

Evidence: tsc clean, vitest green, build green (SELF); seven-scenario
rerun to be recorded on the Dev I4 card.

Impact: e2e spec only.

Refs: rollout I4
2026-10-17 04:47:52 +00:00
weichen ed23de28df Accept rail-form cockpit entry in narrow layout
Why: at 390px the compact layout swaps the sidebar for an icon rail; the
cockpit entry remains reachable as an icon button but not as .tsb-tool,
so scenario 7 timed out waiting for the sidebar form.

Evidence: tsc clean, vitest green, build green (SELF); seven-scenario
rerun to be recorded on the Dev I4 card.

Impact: e2e spec only.

Refs: rollout I4
2026-10-17 04:40:28 +00:00
weichen 83e81d12ac Sync panorama node detail on refresh; scope scenario 4 to the fixture doc
Why: the cockpit refresh reloaded overview and tree but left the node
detail at its old snapshot, so the 刷新对齐 banner persisted after
refreshing (DV-R05 wants every projection to advance). PanoramaNode now
refetches when the overview snapshotRevision advances. Scenario 4 also
scoped its tree clicks to the 验证主体报告 branch — the unscoped first
caret expanded an unrelated rdm-self doc.

Evidence: tsc clean, vitest green, build green (SELF); seven-scenario
rerun to be recorded on the Dev I4 card.

Impact: cockpit panorama + e2e spec.

Refs: rollout I4
2026-10-17 04:30:32 +00:00
weichen e503cc9e93 Drive cockpit consistency scenario with a committed mutation
Why: scenario 4 relied on a pending draft proposal advancing the
snapshot watermark, but rdm.draft.propose commits nothing (wm:58 before
and after) and draft adoption is high-danger (client cannot forge
Confirmed). rdm.reqs.import with a unique probe item is a committed
low-danger mutation that advances the watermark — the honest DV-R05
driver.

Evidence: tsc clean, vitest green, build green (SELF); wm 58->60 probe
against the seeded data recorded in session.

Impact: e2e spec only.

Refs: rollout I4
2026-10-17 04:21:47 +00:00
weichen c3d8205839 Show version block in panorama node view
Why: the I4 scenarios assert the version collapsible for a selected
section in the panorama (not only the reader); the panorama node view
had no version block, so .cockpit-versions-toggle never existed there.
Also normalize null list projections in the panorama node detail fetch
(same Go nil-slice issue fixed for the panel earlier).

Evidence: tsc clean, vitest green, build green (SELF); seven-scenario
rerun to be recorded on the Dev I4 card.

Impact: cockpit panorama only.

Refs: rollout I4
2026-10-17 04:07:58 +00:00
weichen dd159c24d0 Normalize null list projections in cockpit node detail
Why: rdm.cockpit.node projects empty conflicts as items:null (Go nil
slice) while sources arrive as []; the panel read items.length on null
and crashed, so the conflicts empty state never rendered (I4 e2e
scenario 3).

Evidence: tsc clean, vitest green, build green (SELF); seven-scenario
rerun to be recorded on the Dev I4 card.

Impact: cockpit panel only.

Refs: rollout I4
2026-10-17 03:58:53 +00:00
weichenandCursor 0d4d1be198 Anchor render workspace decisions to a committed source message
Why: The base gate (cmdbus.RequiresProjectWorkDecision) refuses rdm.review.doc.approve/reject/final unless the request carries a committed conversation source as top-level conversationId and anchorMessageId. The workspace sent bare params, so the decision buttons were refused in the real product.

Evidence: vitest 39 files / 441 tests passed (22 new in test/document-decision.test.ts); npx tsc --noEmit clean; npm run build ok; check-knip unused exports 51 <= 51; e2e/document-renderer.playwright.ts type-checks and lists 13 tests. Request shape from pins/agent-system-backend: internal/server/server.go:1276-1304 (body decode), internal/cmdbus/cmdbus.go:109-134 and 385, internal/app/app.go:646-665 (preflight), server.go:1010-1150 (message POST returns the committed message).

Impact: approve/reject/final first post a Chinese decision message to the host project's 文档审签记录 conversation (reused, or created), then dispatch with its id as the anchor (entrance button). Identity is never sent. Any failing step shows a readable refusal and nothing is dispatched. Double-click is ignored while an action is in flight, and retries reuse the operation id so the server returns the same source message. rdmCommand takes an optional source; rdm.render.* is unchanged. The e2e spec drives §11-5c through the workspace only and asserts the anchored, committed receipt. Not run against a live backend or browser here.

Refs: unified renderer plan Task 11; RDM cmdbus gate (pins/agent-system-backend internal/cmdbus/cmdbus.go:109-134); live-verify sequence /tmp/live-verify/run.sh lines 56-70 and 140-146.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 03:41:10 +00:00
weichen a56ce3e1df Fix cockpit project option field: payload is projectId, not id
Why: the local ProjectView interface invented 'id'; rdm.project.list
returns projectId. Every option value was undefined, so auto-select set
pid to undefined and the overview request was never issued — cockpit
showed 加载概览… forever after the project list loaded (I4 e2e scenario 1).

Evidence: tsc clean, vitest green, build green (SELF); network probe shows
rdm.cockpit.overview 200 and .cockpit-stats rendered; seven-scenario rerun
to be recorded on the Dev I4 card.

Impact: cockpit entry only.

Refs: rollout I4
2026-10-17 03:16:47 +00:00
weichen 10f8388bb8 Auto-select first visible project on cockpit entry
Why: the cockpit rendered the project dropdown with no project selected
and an empty main area — the design's 首屏 default (first visible project
drives the overview) was never implemented; caught by I4 e2e scenario 1
which got past the project list but never saw .cockpit-stats.

Evidence: tsc clean, vitest green, build green (SELF); seven-scenario
rerun to be recorded on the Dev I4 card.

Impact: cockpit entry only.

Refs: rollout I4
2026-10-17 02:45:17 +00:00
weichenandCursor 22f59cd1b2 Cover review decisions, rule binding and the full package in the renderer e2e spec
Why: The renderer browser acceptance still recorded GAP markers for approve, final, rule binding and the full package, which the workspace now provides; the spec must exercise those entries through the UI first, falling back to the API only when an entry is absent.

Evidence: tsc --noEmit on e2e/document-renderer.playwright.ts exits 0; playwright test --list loads 13 tests (adds §11-2b and §11-3b); the spec still skips without PLAYWRIGHT_BASE_URL and RBA_OUT, so no browser run was possible here; leak scan in §11-8 is untouched.

Impact: approve and final are attempted from the workspace and asserted by success text or a readable failure; the package download asserts a ZIP header; binding asserts the refresh and the controlled-document refusal copy. Not run against a real product in this session.

Refs: unified renderer acceptance §11 items 2, 3, 5 and 6; RDM 7169944 (review decision contract), 4b7ac2a (profile bind), 75ba50c (package).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 02:36:02 +00:00
weichenandCursor 7f3538da22 Add review decisions, rule binding and package download to the render workspace
Why: Task 11 requires the document render workspace to offer approve, reject and final decisions, rule bind/unbind for document, document type and project default, and the full delivery package, so reviewers and settings holders no longer need other screens.

Evidence: vitest 38 files / 419 tests passed (new test/document-render-actions.test.ts covers decision entries from frozen duties, decision payloads without identity fields, package availability, refusal codes, binding candidates and payloads, perm gating and SSR visibility); tsc --noEmit clean; knip unused exports 51 <= baseline 51.

Impact: decision entries show only when the command is enabled and the frozen submission lists the session user; binding needs rdm.perm.check project.settings allowed=true; package appears only for ready deliveries; organization-default binding is not offered from the workspace (domain admin only).

Refs: unified renderer plan Task 11, 05 section 9, 06 section 6, 07 sections 1 and 5; RDM plugins/rdm/plugin.go review.doc.* and render.profile.* schemas (7169944, 4b7ac2a, 75ba50c).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 02:32:45 +00:00
weichen 5ede8df52a Fix inverted cancel guard in cockpit project loader
Why: #102 rewrote the bare-array fallback into a multi-line callback and
turned 'if (!c) setProjects(...)' into an early 'if (!c) return', which
skips setProjects on the normal (non-cancelled) path — the cockpit then
hangs on 加载中 forever, caught by I4 e2e scenario 1.

Evidence: tsc clean, vitest 324/324, build green (SELF); seven-scenario
browser suite rerun recorded on the Dev I4 card.

Impact: cockpit entry only.

Refs: rollout I4
2026-10-17 02:28:47 +00:00
weichenandCursor d5e6b12e75 Merge origin/main into the document render workspace branch
Why: origin/main carries the cockpit, account and duty-settings work; the render workspace branch needs its rdmRender capability state kept beside the new rdmCockpitAvailable flag.

Evidence: store.ts conflicts resolved by keeping both sides in the identity reset and capability refresh paths; tsc --noEmit clean; vitest run 37 files / 394 tests passed; npm run build passed; knip unused exports 51 <= baseline 51.

Impact: refresh failure and identity reset now clear rdmCockpitAvailable and rdmRender together; no behavior removed from either side.

Refs: unified renderer plan Task 11 (09-implementation-plan.md); Dev precheck verdict SHARED_WORKSPACE_BUSY recorded in the task report.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 02:22:39 +00:00
weichen 49ba8b1735 Merge pull request 'Stabilize cockpit e2e login: settle identity refresh before entry click' (#103) from feature/document-visualization-cockpit-i4 into main 2026-10-16 15:39:52 +00:00
weichen b594d2748e Stabilize cockpit e2e login: settle identity refresh before entry click
Why: after login the store's identity epoch and the capability probe each
settle once more; the app closes open workspaces on identity change, so
clicking the sidebar entry too early raced the overlay close (intermittent
加载中 hang, caught across I4 e2e runs).

Evidence: instrumented probe (rows=2 parsed then overlay closed on identity
bump); settled-login rerun follows on the Dev I4 card.

Impact: spec stability only.

Refs: rollout I4
2026-10-16 15:39:11 +00:00
weichen afd661c932 Merge pull request 'Unwrap projects payload in cockpit project loader' (#102) from fix/cockpit-project-list-shape into main 2026-10-16 15:32:02 +00:00
weichen c9ef3c0f0a Unwrap projects payload in cockpit project loader
Why: rdm.project.list returns { projects: [...] }; the cockpit component
assumed a bare array and always rendered 暂无可见研发项目, caught by I4 e2e
scenario 1.

Evidence: tsc/vitest/build green (SELF); browser e2e rerun recorded on the
Dev I4 card.

Impact: cockpit entry only.

Refs: rollout I4
2026-10-16 15:31:52 +00:00
weichen 72f1a259e1 Merge pull request 'Wire cockpit flag into sidebar workspace entries' (#101) from fix/cockpit-sidebar-entry into main 2026-10-16 15:28:37 +00:00
weichen 804170ec66 Wire cockpit flag into sidebar workspace entries
Why: TaskSidebar builds its own workspace entry list without the cockpit
flag, so the 文档驾驶舱 entry never appeared in the sidebar even when the
command probe passed (caught by I4 e2e scenario 1).

Evidence: tsc/vitest/vite build green (SELF); cockpit e2e suite re-run via
Dev scripts/e2e.sh follows on the Dev I4 card.

Impact: sidebar entry visibility only; no behavior change elsewhere.

Refs: rollout I4
2026-10-16 15:27:16 +00:00
weichen 7759d8077f Merge pull request 'Add cockpit seven-scenario Playwright suite (I4)' (#100) from feature/document-visualization-cockpit-i4 into main 2026-10-16 14:33:21 +00:00
weichen 8d43b1de12 Add cockpit seven-scenario Playwright suite (I4)
Why: implement the reviewed I4 browser acceptance - seven scenarios over the
cockpit: first-entry stats/tree/panorama, tree-section reader positioning on
the shared selection, four evidence tabs with counts and honest empty/
restricted states, pending-probe adoption via in-session API then refresh
(snapshot and version block advance together, DV-R05), route-abort outage
keeping the previous snapshot with a retry entry (DV-R08), non-member
account sees no projects (DV-R06 negative), and 390px/18px/keyboard/reduced-
motion usability (DV-R07).

Evidence: tsc green; suite driven by Dev scripts/e2e.sh (seed: cockpit-e2e
structure YAML via the structure-content loader, pending probe proposal,
non-member account); GATE is the Dev-side e2e.sh run recorded on the Dev I4
card.

Impact: e2e spec only; no product source change.

Refs: rollout I4
2026-10-16 14:32:58 +00:00
weichen d66bd7941c Merge pull request 'Raise knip baseline to 51 for cockpit I3 surface' (#99) from fix/cockpit-i3-knip into main 2026-10-16 12:52:02 +00:00
weichen a022257b40 Raise knip baseline to 51 for cockpit I3 surface
Why: I3 adds the reader/evidence-panel module whose contract types
(documentCockpit.ts) are consumed by tests and the I4 combination work; the
repo's knip ratchet exists for exactly this deliberate, documented raise.
Code paths themselves reduced first (attempted de-export churned unrelated
surface, reverted in favor of the baseline bump).

Evidence: knip 51 <= 51 via scripts/check-knip.mjs; vitest 324/324; tsc and
vite build green.

Impact: baseline file only.

Refs: rollout I3
2026-10-16 12:51:53 +00:00
weichen 3f6ced099c Merge pull request 'Add cockpit reader view and evidence panel (I3)' (#98) from feature/document-visualization-cockpit-i3 into main 2026-10-16 12:21:48 +00:00
weichen 86770e35fb Add cockpit reader view and evidence panel (I3)
Why: implement the reviewed I3 increment - continuous body reading with
per-section status lines, version summary with collapsed line-diff (backend
versions projection from RDM PR #420), right evidence panel with
sources/history/impacts/conflicts tabs, panorama-reader positioning on the
shared selection, per user+project view preferences (panorama/reader,
density, panel tab), strict-seq history merging and honest restricted states.

Evidence: 10 new pure-logic tests (prefs validation, version summary and
diff marks, strict history merge drops, reader assembly three-state); vitest
324/324 green; tsc and vite build green; tokens-only styles.

Impact: cockpit-only; no fourth column, no standalone graph (relations stay
in the right panel); reader entry is explicit (panorama button / view toggle).

Refs: rollout I3
2026-10-16 12:21:24 +00:00
weichen 65ab7a8a44 Merge pull request 'Guard the admin create path with a browser e2e scenario' (#97) from fix/admin-create-e2e-guard into main 2026-10-16 11:22:07 +00:00
weichen 119fff96e8 Guard the admin create path with a browser e2e scenario
Why: the 400 (unknown field name) outage shipped because no automated
check drove the UsersPanel create path against the strict backend
contract; this scenario pins the request body, the pending notice, the
table row state and the CLI handoff hint into the composition suite.
Evidence: passing run against the deployed shared dev (SELF, task card).
Impact: e2e/ only; no production code.
Refs: backend 8dfc223, frontend #94
2026-10-16 10:47:41 +00:00
weichen 96ec03d461 Merge pull request 'Add document cockpit workspace consuming rdm.cockpit.* (I2)' (#96) from feature/document-visualization-cockpit-i2 into main 2026-10-16 09:04:20 +00:00
weichen 17996cf4dd Add document cockpit workspace consuming rdm.cockpit.* (I2)
Why: implement the reviewed I2 increment - statistics band, structure tree
with lazy section pages, panorama center (project overview + node summary),
shared selection/filter, snapshot-consistency banner and honest states, per
the frozen contract (Dev docs/superpowers 2026-10-16, rollout section 7).

Evidence: vitest 314/314 green (14 new pure-logic tests covering capability
gate, true-zero vs unavailable stats, filter toggle, snapshot equality and
update notice, context key, selection survival, honest badges); tsc -b and
vite production build green.

Impact: new workspace entry (rdm-cockpit, gated on the seven cockpit
commands); no changes to existing surfaces; reader view and right panel
land in I3.

Refs: rollout I2
2026-10-16 09:03:48 +00:00
weichen dbb5f0462f Merge pull request 'Close the account chain UI: forced-change current password and recovery proposals' (#95) from fix/account-chain-ui into main 2026-10-16 08:03:14 +00:00
weichen 125261410c Merge remote-tracking branch 'origin/main' into fix/account-chain-ui 2026-10-16 08:03:01 +00:00
weichen e895a4f6a8 Close the account chain UI against the current backend contract
Why: the forced password gate still submitted an empty current password
after the backend began requiring it (current_required), locking every
account seeded with an initial password out of the workbench, and the
admin user panel had no client for the owner-confirmed recovery
proposal contract that replaced one-time key issuing (HTTP 410).
Evidence: npm test 300 passed; tsc -b && vite build ok; knip issues
54 <= baseline 55 (SELF).
Impact: mode-aware ChangePasswordGate with current-password input,
admin recovery proposal panel, owner-side account settings section,
recovery proposal client methods; no backend changes.
Refs: backend 8dfc223, b9c0a34; frontend #94
2026-10-16 07:49:25 +00:00
weichen 46e99fedbb Merge pull request 'Align admin user creation with the pending-account contract' (#94) from fix/admin-user-create-contract into main 2026-10-16 07:48:49 +00:00
weichen bcb81719ce Drop the login-key issue client and fix hint punctuation
Why: independent review flagged the issue client still targeting the 410
endpoint and a hint sentence ending with a period.
Evidence: npm run build + npm test re-run on the fix (SELF).
Impact: api client surface only.
Refs: review brief /tmp/review-brief-admin-user-contract.md
2026-10-16 07:15:43 +00:00
weichenandCursor 520e7df58b Scan active probe bodies for restricted content in renderer spec
Why: Probes sent with page.request do not raise page response events, so
the §11-8 leak scan only covered passive page responses.

Evidence: Final run 20261016T065123Z item8-scan.json counted 18 responses,
all passive; probe bodies were checked only for rejection and payload.

Impact: Every probe and host API body is scanned for project A names, the
controlled and uncontrolled document titles and review markers.

Refs: unified renderer plan Task 13 §11
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 06:52:43 +00:00
weichenandCursor 123f4fb052 Rewrite document renderer browser spec for real-product §11 acceptance
Why: The Task 11 spec was never run and assumed a seed, login gate and
navigation the real product does not have. §11 acceptance needs all nine
items covered with three duties, two projects, auth on, screenshots,
network logs and leak scanning for the user without permission.

Evidence: Driven by /tmp/renderer-browser-acceptance/browser-acceptance.sh
against RDM d63f1db; per-test results, screenshots, downloads and network
logs under $RBA_OUT/evidence; receipt composed into browser-receipt.json.

Impact: Product capability gaps are recorded as soft GAP/BLOCKER failures
naming the owning repository instead of being skipped; steps only reachable
by direct commands in the user's own session are tagged API_ASSISTED.

Refs: unified renderer plan Task 13 §11
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 06:49:39 +00:00
weichenandCursor 36f2fbea34 Fix render workspace gaps found by real-product browser acceptance
Why: Running §11 against RDM d63f1db showed three Frontend defects. A
validated template rule with a server blockedReason was disabled for
preview, although the server only restricts formal delivery. A reviewer
opening the document in another session could not find the author's
submission until an export existed. Template-instance blocks without a
title (cover, sign-off table, fields) rendered as blank canvas nodes and
reader headings.

Evidence: npx vitest run (35 files, 363 tests passed); npm run build ok;
node scripts/check-knip.mjs (43 <= 43); browser run under
/tmp/renderer-browser-acceptance/20261016T064052Z (item1 172 nodes,
preview generated with the validated template rule).

Impact: Preview is offered for validated/draft rules while formal submit
stays blocked; the current submission comes from rdm.review.doc.status;
untitled nodes show "<kind>(<templateBlockId>)".

Refs: unified renderer plan Task 13 §11
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 06:49:28 +00:00
weichen d8e610aebe Align admin user creation with the pending-account contract
Why: backend 8dfc223 replaced POST /api/admin/users with strict
{owner, profile} decoding and returned 410 for login-key issuing, so the
UI create form failed with 400 unknown field name. Creation now sends
{owner, profile:{name}} only; password input, admin checkbox and the
login-key issue entry are removed; notice points to the local CLI
initialize-account flow. passwords.ts lost its only consumer.
Evidence: npm run build + npm test (34 files/293 passed) + knip identical
to baseline on the task worktree (SELF).
Impact: UsersPanel and api client only; no style/token changes.
Refs: backend 8dfc223, b9c0a34
2026-10-16 06:41:27 +00:00
weichen 3bbb918795 Merge pull request 'Gate project override settings to owners and admins' (#93) from fix/duty-settings-gating into main 2026-10-16 05:10:17 +00:00
weichenandCursor de65026238 Align document render workspace with real RDM server payloads
Why: Independent review of a81e94a..85428b4 found five contract mismatches
against the RDM server: profile sources document-type/project-default were
dropped, the refusal object {code,message} was ignored, submit receipts were
read as `id` instead of `submissionId`, diagnostics were expected inside
preview/export receipts (they live in a separate diagnostics delivery blob),
and rdm.inspect.tree was treated as recursive although it returns one level.

Evidence: New tests use the server JSON shapes (RenderProfileOptions,
RenderDeliveryReceipt, DocSubmissionRec, rdmRenderDownload, InspectTree); 26
of them fail against the previous implementation. npx vitest run: 35 files,
359 tests passed; npm run build: ok; scripts/check-knip.mjs: unused exports
43 <= 43; git diff --cached --check: clean.

Impact: Rules panel lists all five sources with Chinese labels and keeps the
server's selected default; refusals render readably; a new submission is
auto-selected; diagnostics are fetched via rdm.render.download kind=diagnostics
under a sequence/epoch guard with readable failures and a Chinese receipt
state label; the document tree is walked breadth-first (depth 12, 2000 nodes,
concurrency 4) with a truncation notice. Playwright spec asserts per-node
diagnostic anchors after preview (authored, not run).

Refs: unified renderer plan Task 11 review findings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 01:29:13 +00:00
weichenandCursor 85428b4633 Add document render workspace with profiles, preview, review and downloads
Why: unified renderer Task 11 needs a reader/delivery UI over the RDM
document tree: four-level rule selection with controlled-document
no-fallback explanation, preview with node-linked diagnostics, a
structure canvas and a linear reader sharing one selection and filter,
review annotations/revisions, and per-delivery downloads.

Evidence: npx vitest run -> 35 files, 338 tests passed (45 new in
test/document-render-workspace.test.ts, design-guard green);
npm run build -> tsc -b + vite build ok; npx knip -> findings identical
to the pre-existing baseline (check-knip 43 <= 43); git diff --check
clean. e2e/document-renderer.playwright.ts authored, not executed.

Impact: new src/documentRenderPresentation.ts (pure helpers) and
src/components/DocumentRenderWorkspace.tsx, opened from a per-document
"阅读与交付" action in the audit materials tree; store gains an
rdmRender capability probe (fail-closed); audit export reuses the shared
base64 file saver; token-only styles appended to styles.css. Async
state is owned per hook with request guards plus identity epoch, and
the workspace remounts per identity/project/document scope.

Refs: unified renderer plan Task 11
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 23:44:39 +00:00
weichen 05609ff61c Page the live catalog to an editable permission before editing
Why: the real catalog leads with protected system actions whose checkboxes are disabled; the live flow must advance permission groups to reach an editable action.

Evidence: deterministic 16/16 unchanged.

Impact: live test navigation only.

Refs: feature/iam-u4-role-permission-ui
2026-10-15 08:39:24 +00:00
weichen 5c50943731 Order the live IAM acceptance flow for a fresh database
Why: the live composition starts with zero roles; the real permission catalog table renders only after a role is selected, so the catalog assertion must follow role creation.

Evidence: deterministic 16/16 unchanged; live layer reruns via iam-u4-role-acceptance.sh.

Impact: live test order only; no behavior change.

Refs: feature/iam-u4-role-permission-ui
2026-10-15 08:36:29 +00:00
weichen f2b5532b7b Add the IAM U4 browser acceptance slice and admin draft guards
Why: stage 7 (DR6-DR10) requires repeatable browser evidence; the reviewed flows lacked a disable confirmation, role-scoped draft lifetime with an explicit leave decision, and status announcements.

Evidence: 16/16 deterministic Playwright scenarios pass on an isolated preview (route-driven, no live services); 283 unit tests and build/knip stay green; live-layer cases ride the same file under IAM_E2E_LIVE.

Impact: RolePermissionsPanel keeps per-roleId drafts, guards zone leave/refresh, adds a disable confirm strip (name + binding count + impact + cancel) and restores save focus; AdminZone consults the leave guard; AccountUsersPanel notice becomes role=status; new e2e/iam-role-authority.playwright.ts.

Refs: feature/iam-u4-role-permission-ui
2026-10-15 07:41:35 +00:00
weichen 95e1948a5f Distinguish retired and stale trace relations
Why: Retired edges and compliance edges awaiting review both fell back to a generic lifecycle label.

Evidence: Presentation tests, production build, knip, and diff checks pass.

Impact: The trace network now shows 已停用 and 待复核 as distinct human-readable states.

Refs: readable trace network
2026-10-15 06:52:42 +00:00
weichen 987be7442f Show source and risk operations in readable audit
Why: Work-source decomposition, detected risks, human answers, and structure decisions were recorded but absent from the audit timeline.

Evidence: Focused presentation/tree tests, production build, and knip baseline pass.

Impact: Audit filters and rows now include the complete business-operation feed without raw payloads.

Refs: TT-N03 TT-R01 W4b
2026-10-15 06:42:16 +00:00
weichen ad503c9535 Complete recursive audit tree navigation
Why: Review found unsupported node detail calls, discarded child pages, shallow trace selection, stale detail responses, invalid operation filters, and missing audit identity.

Evidence: 35 frontend test files with 285 tests pass; production build, knip baseline, and diff check pass.

Impact: Both trees paginate recursively, trace selection covers every depth, and audit rows preserve valid operation, object, and source semantics.

Refs: rdm-two-trees-latest-templates
2026-10-15 06:19:33 +00:00
weichen 20c20b7959 Merge remote-tracking branch 'origin/main' into feature/iam-u4-role-permission-ui 2026-10-15 06:05:58 +00:00
weichen 1ef68abb8c Merge remote-tracking branch 'origin/main' into feature/rdm-two-trees-readable-audit 2026-10-15 06:05:19 +00:00
weichen 9d0ae331c7 Harden role permission administration flows
Why: Fine-grained viewers and managers need independent data loading, safe conflict recovery, and complete paged role access.

Evidence: Frontend 281 tests, production build, and knip baseline passed; independent final review found no remaining findings.

Impact: Adds action-specific controls, server-side role filters and paging, matrix cap editing, and sequence-safe draft rebasing.

Refs: feature/iam-u4-role-authority
2026-10-15 02:28:03 +00:00
weichen 1fc9dabc13 Remove superseded role administration panels
Why: The Backend-authoritative role and account views replace the legacy local role and user panels.

Evidence: npm test; npm run build; node scripts/check-knip.mjs; UI npm test.

Impact: Keeps one role administration surface and restores the unused-export baseline.

Refs: feature/iam-u4-role-authority
2026-10-15 01:19:02 +00:00
weichen 505aba36c1 Reuse trace endpoint context in details
Why: Object details render the same relationship row and must identify the endpoint relative to that object.

Evidence: Type checking identified the missing selected-object context after the endpoint fix.

Impact: Relationship rendering is consistent in the trace view and object details.

Refs: rdm-two-trees-latest-templates
2026-10-14 17:43:23 +00:00
weichen b921bef6a6 Show the opposite trace endpoint
Why: The trace view selected an endpoint by lexical ID order and could present the current section as its own source.

Evidence: Focused presentation tests cover both selected directions of a source-to-section relationship.

Impact: Source and impact columns now identify the object opposite the selected node.

Refs: rdm-two-trees-latest-templates
2026-10-14 17:42:45 +00:00
weichen c9b55eb502 Name untitled template nodes clearly
Why: Recursive template nodes without titles were rendered as repeated generic project objects in the audit tree.

Evidence: Focused presentation tests pass, including a regression for stable readable fallback names.

Impact: Template-derived paragraphs, tables, rows, cells, fields, and attachments remain distinguishable in the document tree.

Refs: rdm-two-trees-latest-templates
2026-10-14 17:38:42 +00:00
weichen 70f0d2d41f Add role and permission configuration views
Why: Administrators and delegated system managers need focused role, user, and permission controls with search, filters, single-role editing, and a matrix view.

Evidence: Frontend 280 tests, production build, paired UI 163 tests, and design guards passed.

Impact: Replaces the RDM role panel with Backend role authority and adds optional user profiles, unique role assignment, and explicit system-grant editing.

Refs: Focused role and permission system task 7
2026-10-14 17:37:51 +00:00
weichen bd736f528c Add the role permission client model
Why: Single-role and matrix views need one typed Backend contract and one draft model so filtering cannot mutate authorization scope.

Evidence: Focused draft-model tests passed and the Frontend production build completed.

Impact: Adds role, binding, catalog, and system-grant APIs without changing visible UI yet.

Refs: Focused role and permission system task 6
2026-10-14 17:31:41 +00:00
weichen b52f9654a0 Render both project trees recursively
Why: Project document and knowledge structures can contain nested nodes whose children must remain navigable beyond the first section level.

Evidence: A RED test first showed child-bearing sections were not expandable; presentation and design-guard tests, knip baseline 43, and npm run build now pass.

Impact: Recursively render any node marked with children while preserving current document fallback and readable node states.

Refs: TT-N01, TT-N02, TT-D01, TT-U01; two trees one graph vertical slice
2026-10-14 17:08:38 +00:00
weichen 814643f277 Present RDM audit records in business language
Why: Project members need to understand who changed what, why, and with what impact without reading operation codes or raw audit payloads.

Evidence: A RED presentation test first failed on the missing humanization layer; 276 frontend tests passed, design guard 10 checks passed, knip stayed at baseline 43, and npm run build passed.

Impact: Humanize tree, relation, state, and operation vocabulary; render structured reason, impact, source, and outcome fields; raw event detail is no longer shown in the audit timeline.

Refs: TT-U01, TT-U02, TT-U03, TT-U04; two trees one graph stage-five plan
2026-10-14 17:04:06 +00:00
weichen bc9b17a665 Register CI secret cleanup before writing the key
ci / test-and-build (pull_request) Successful in 36s
Why: An early filesystem failure could exit before the UI deploy key cleanup trap existed.

Evidence: Parsed workflow confirms the EXIT trap precedes the first secret write.

Impact: Normal checkout failures remove both transient credential files from the runner.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:24:12 +00:00
weichen a1bd3e2983 Verify the Node archive used by Frontend CI
ci / test-and-build (pull_request) Successful in 1m5s
Why: The CI runner executed a mirror-downloaded toolchain without authenticating its contents.

Evidence: The pinned SHA256 matches the signed Node.js v22.23.3 release manifest and the current mirror archive.

Impact: CI fails before extraction if the Node archive differs from the reviewed release artifact.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:22:50 +00:00
weichen 93ee723131 Fetch private UI in CI with read-only deploy key
ci / test-and-build (pull_request) Successful in 48s
Why: The Gitea job token cannot read the private UI repository and this server lacks HTTPS deploy-token API support.

Evidence: Repository-scoped UI deploy key read was verified; Frontend Actions secret UI_DEPLOY_KEY is listed; prior CI run 3214 confirmed the missing credential gate.

Impact: Exact-pin CI uses a read-only SSH key and pinned host identity, deleting the transient key after checkout.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:14:07 +00:00
weichen ee735f2e2e Use dedicated read access for private UI CI dependency
ci / test-and-build (pull_request) Failing after 0s
Why: Gitea job token can fetch Frontend but receives repository-not-found for private UI.

Evidence: PR CI run 3213 checkout log shows Frontend fetch succeeded and UI fetch failed; Frontend secret listing lacks SUBMODULE_TOKEN.

Impact: CI now requests the dedicated UI read credential and fails clearly until it is provisioned.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:08:41 +00:00
weichen 3203e8d4f3 Accept platform CI server URL
ci / test-and-build (pull_request) Failing after 1s
Why: The runner may inject an internal Gitea URL rather than the public browser URL.

Evidence: PR CI run 3212 exited at checkout preflight before any fetch; updated YAML parses locally.

Impact: Checkout validates the injected URL shape and logs nonsecret event context.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:07:50 +00:00
weichen bed8a5c766 Run Frontend test and build in Gitea CI
ci / test-and-build (pull_request) Failing after 1s
Why: The Frontend repository lacked an exact-HEAD CI gate for U3 delivery.

Evidence: Workflow syntax parsed locally; Frontend and pinned UI tests and build passed locally; platform run pending.

Impact: PR and main checks now fetch fixed Frontend/UI commits and run install, tests, and production build.

Refs: IAM U3 delivery gate
2026-10-14 16:07:12 +00:00
weichen 055476d135 Clarify confirmed recovery proposal notice
Why: A confirmed proposal should not instruct the owner to confirm it again.

Evidence: Frontend tests and production build passed at the changed source.

Impact: Confirmation notice describes the existing state and keeps credential authority with the owner.

Refs: IAM U3 account recovery
2026-10-14 16:03:49 +00:00
weichen 34f6a1bf2f Handle terminal recovery proposal outcomes
Why: A lost proposal response followed by revocation could leave admins with an ambiguous result, and a late response could overwrite a newly selected account.

Evidence: Frontend 272 tests and production build passed; isolated browser replay covered lost response, terminal retry, and account switch.

Impact: Recovery notices reflect current proposal state, and stale account requests cannot update the selected detail.

Refs: IAM U3 account directory and recovery
2026-10-14 16:02:17 +00:00
weichen 2e7e5b7a64 Reconcile U3 account writes and recovery proposals
Why: A successful mutation from a later search page reused an invalid signed cursor and falsely reported a write conflict; a lost recovery-proposal response left a committed proposal hidden and retried with a fresh key.

Evidence: Isolated browser red runs reproduced the stale-page duplicate warning and unknown proposal status; after the fix the combined page-two and lost-response flow passed, as did 272 Frontend tests and a production build.

Impact: Successful writes restart pagination and keep their success status, while uncertain proposal outcomes re-read metadata and reuse the same idempotency key for explicit retry.

Refs: IAM U3 Stage 7; Frontend PR #86
2026-10-14 15:53:46 +00:00
weichen 36ac754300 Merge remote-tracking branch 'origin/main' into feature/iam-u3-account-ui 2026-10-14 15:43:12 +00:00
weichen 1544781c28 Verify queued and failed material states in browser
Why: Users must see truthful first replies when work queues or cannot start.

Evidence: RED and GREEN HTTP browser receipts plus Frontend 269 tests and production build.

Impact: Adds two task-owned Playwright scenarios; production UI is unchanged.

Refs: U3 r0.5 independent review finding
2026-10-14 09:13:25 +00:00
weichen cfabed8fae Cover safe material intake across browser paths
Why: The first reply and task reuse need end-to-end checks across long and small material inputs.

Evidence: Dev evidence receipts for ten Playwright scenarios, Frontend tests, and build.

Impact: Adds browser-only coverage; production UI and API behavior are unchanged.

Refs: U3 r0.5 implementation plan Task 4
2026-10-14 08:40:21 +00:00
weichen a961b19ebb Normalize legacy duty fields at the UI boundary
Why: RDM duty.get publishes capitalized domain role keys, so a selected document appeared to have no assigned people in the new stable-ID picker.

Evidence: SELF receipts /tmp/iam-u3-rdm-ui-wire-compat.json and /tmp/iam-u3-frontend-wire-compat-test.json; normal pinned-UI build passed.

Impact: The workbench accepts both existing capitalized and lower-camel role fields without changing the RDM public response contract.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 05:01:26 +00:00
weichen 010540f203 Treat duplicate recovery responses as status-only
Why: A lost confirmation or grant-rotation response must not imply that the one-time plaintext can be read again.

Evidence: SELF receipt /tmp/iam-u3-frontend-idempotent-test.json and a normal pinned-UI build after the change.

Impact: The owner view shows current proposal status and offers an explicit new-grant action only when the server returns no plaintext.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 04:58:46 +00:00
weichen 731c9b7776 Close account setup and recovery flows in the workbench
Why: The old user panel accepted initial passwords, exposed administrator-issued recovery keys, and used owner-only candidate selections.

Evidence: SELF receipts /tmp/iam-u3-frontend-normal-combo-build.json, /tmp/iam-u3-frontend-normal-combo-test.json, /tmp/iam-u3-browser-final.json, and /tmp/iam-u3-rdm-ui-browser-replay.json.

Impact: Adds optional profiles, server-backed account search, effective-state controls, metadata-only proposals, owner confirmation and recovery, current-required password mode, and stable-ID RDM pickers. Verified with the Dev-pinned UI component SHA.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 04:54:05 +00:00
weichen c9d5acf089 Count batched failure notices in browser validation
Why: One system reminder can contain multiple failed child notices, so the browser test must wait for the whole batch rather than one message per child.

Evidence: The first post-merge HTTP/Playwright run failed only at this false one-to-one assumption; all six task-owned browser cases passed after counting notice entries in the persisted reminder.

Impact: Test-only correction; no production UI behavior change.

Refs: U3 r0.4 Task 6 review closure
2026-10-14 04:06:22 +00:00
weichen 139f2ce654 Merge current Frontend main into U3 browser checks
Why: Keep the approved RDM export controls and the U3 material browser coverage in one component candidate.

Evidence: Changes in store.ts touch separate state fields and behavior; full Frontend tests/build and task-owned browser cases will be rerun after this merge.

Impact: Includes upstream RDM export UI without changing U3 source or notice contracts.

Refs: Dev U3 r0.4 Task 6 final review synchronization
2026-10-14 04:04:05 +00:00
weichen 01ec12e77c Wait for settled material notices in browser checks
Why: A failed task card can appear before its notification turn, and the second finding must be observed while work is still running.

Evidence: The strengthened browser checks first exposed missing fixture notice text and a zero-width second-stage window; after fixture correction all six task-owned HTTP/Playwright scenarios passed. Frontend 265 unit tests and build passed.

Impact: Test coverage only; no production UI change.

Refs: U3 r0.4 independent review Important 5
2026-10-14 03:57:38 +00:00
weichen 34950997f8 Cover progressive material outcomes in browser tests
Why: The r0.4 acceptance must show running findings, zero and partial failure, and a source-bound choice in the real UI.

Evidence: Six task-owned HTTP/Playwright cases passed; npm test (265 tests) and npm run build passed.

Impact: Browser test coverage only; no production UI change.

Refs: U3 r0.4 Task 6
2026-10-14 03:34:23 +00:00
weichen 006c815675 Check pending membership in the real HTTP work view
Why: The U3 browser gate must show a host-generated pending question for underdetermined source counts.

Evidence: The new assertion failed against the old mechanism fixture while existing source-navigation and focus-flow tests remained observable; Backend fixture now emits structured premises.

Impact: Browser test inputs and assertion only; no production UI change.

Refs: Backend 756679d; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 17:02:03 +00:00
weichen 9d7681cec4 Keep generated evidence helpers module-local
Why: The Core mirror must satisfy the Frontend unused-export guard without changing the candidate shape.

Evidence: Dev make contracts regenerated the file; Frontend knip 51 <= 52 and build passed.

Impact: Type-only export visibility change.

Refs: Core b474394; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 16:49:56 +00:00
weichen 5bc2e12c01 Sync U3 evidence candidate types from Core
Why: Frontend generated types must follow the single Core checkpoint contract.

Evidence: Dev make contracts generated the mirror; npm test and npm run build passed.

Impact: Type-only mirror update; persisted task cards and reading behavior stay unchanged.

Refs: Core c722c16; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 16:34:07 +00:00
weichen 64ba640b77 Merge remote-tracking branch 'origin/main' into feature/fast-slow-u2-u3 2026-10-13 15:49:29 +00:00
weichen 329bdf53d6 Merge remote-tracking branch 'origin/main' into feature/fast-slow-u2-u3 2026-10-13 10:19:21 +00:00
weichen 0bbccbe39e Merge current login recovery browser coverage
Why: Preserve current main before publishing the U2/U3 candidate.

Evidence: Exact main object fetched; browser-only upstream changes.

Impact: Retain both sets of browser coverage.

Refs: Agent-System-Dev U2/U3 delivery
2026-10-13 05:28:02 +00:00
weichen 3087b0621c Assert literal source records in work checkpoint browser flows
Why: Material records now display validated source quotations rather than unverified model paraphrases.

Evidence: Existing browser coverage retains refresh, changed-version, preview and focus checks; the updated assertions target actual source text.

Impact: Browser-only expectations change; product bundle remains the independently reviewed version.

Refs: Approved R05 and host material-record guard
2026-10-13 04:31:50 +00:00
weichen 4d1d64a83e Merge commit '9fc7da2ace8578d87d2601adcbf831e0b955aa68' into feature/fast-slow-u2-u3 2026-10-13 04:06:22 +00:00
weichen 861c45edb5 Keep delayed source responses behind the current preview intent
Why: Late source requests must not replace a newer preview or survive conversation navigation.

Evidence: Frontend typecheck, 221 unit tests, knip ratchet and build passed; delayed-response regressions reproduced the old failure.

Impact: The existing store owns source requests and invalidates stale responses on preview and context changes.

Refs: Agent-System-Dev feature-fast-slow-u2-u3-plan; independent review finding 4
2026-10-13 03:55:04 +00:00
weichen c63f6c7470 Gate checkpoint browser tests on an explicit owned instance
Why: Unrelated browser suites should skip this opt-in runtime acceptance instead of importing an absent manifest.

Evidence: Playwright discovers the two tests without an instance; explicit owned-instance replay passed previously.

Impact: Preserves strict manifest verification when invoked.

Refs: fast-slow-u2-u3 Task 7
2026-10-13 03:16:25 +00:00
weichen 75a420210d Exercise task recovery and source reading through real HTTP
Why: DOM fixtures alone cannot verify persisted checkpoints and source version handling.

Evidence: SELF source navigation and changed-version checks pass; reading replay is being repeated after the deferred runtime port fix.

Impact: Adds an explicit task-owned instance browser suite without paid model calls.

Refs: fast-slow-u2-u3 Task 7
2026-10-13 03:08:33 +00:00
weichen 175210f4bb Open processed source versions through the existing preview
Why: Source context must remain attached to the bytes being read.

Evidence: SELF frontend tests and production build pass.

Impact: Carries an optional expected hash and preserves one preview location.

Refs: fast-slow-u2-u3 Task 5 source-version acceptance
2026-10-13 02:51:07 +00:00
weichen b00fa4433b Merge commit 'dcd2699933eff51c06193cac7beb15c3d20a01ff' into feature/fast-slow-u2-u3 2026-10-13 02:44:19 +00:00
weichen 8be95c8807 Keep critical coverage and correction notices in conversation work
Why: A folded auxiliary panel must not hide material limits or withdrawn conclusions.

Evidence: SELF 213 frontend tests, type checks, knip ratchet, and bundle build pass.

Impact: Prioritizes existing task cards without opening files or changing focus.

Refs: fast-slow-u2-u3 Task 5
2026-10-13 02:36:57 +00:00
weichen 54fe339e65 Recover task projections without regressing work or reading state
Why: Replayed events, snapshot races, and source navigation must preserve authoritative work and user focus.

Evidence: SELF make evidence records 213 frontend tests, type checks, knip ratchet, and production build.

Impact: Uses the existing store for task revisions and gap replay; opens sources in the existing preview with version context.

Refs: fast-slow-u2-u3 Task 5; generated public contract types account for the measured knip baseline increase
2026-10-13 01:59:40 +00:00
weichen 82c08bd983 Consume persistent work contracts
Why: keep the workbench wire types aligned with Core.

Evidence: TypeScript check passed on the synchronized contract.

Impact: generated types only.

Refs: U2/U3 Task1
2026-10-13 01:09:31 +00:00
111 changed files with 14828 additions and 705 deletions
+68
View File
@@ -0,0 +1,68 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Check out fixed Frontend and UI commits
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }}
UI_SHA: fadabceadeec210d52cda7e9cec71e988d2e4482
run: |
set -euo pipefail
printf 'context server=%s repository=%s sha=%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}"
[[ "${GITHUB_SERVER_URL}" =~ ^https?://[^/]+/?$ ]]
test "${GITHUB_REPOSITORY}" = "weichen/Agent-System-Frontend"
[[ "${GITHUB_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${UI_SHA}" =~ ^[0-9a-f]{40}$ ]]
test -n "${TOKEN}"
if [ -z "${UI_DEPLOY_KEY}" ]; then
echo "UI_DEPLOY_KEY is required for the private UI dependency" >&2
exit 1
fi
export GIT_TERMINAL_PROMPT=0
mkdir -p "${RUNNER_TEMP}/iam-frontend-ci/frontend" "${RUNNER_TEMP}/iam-frontend-ci/ui"
cd "${RUNNER_TEMP}/iam-frontend-ci/frontend"
git init -q
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "${GITHUB_SHA}"
umask 077
trap 'rm -f "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"' EXIT
printf '%s' "${UI_DEPLOY_KEY}" > "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key"
printf '%s\n' '[git-ssh.u1680832.nyat.app]:31411 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK55QSxDqMMZ1HIWHSPkQ7fyJkDKbdg98TeIv6SrR3qx' > "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"
export GIT_SSH_COMMAND="ssh -F /dev/null -i ${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts -p 31411"
cd "${RUNNER_TEMP}/iam-frontend-ci/ui"
git init -q
git remote add origin "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git"
git fetch --depth=1 origin "${UI_SHA}"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "${UI_SHA}"
git -C "${RUNNER_TEMP}/iam-frontend-ci/frontend" status --short
git -C "${RUNNER_TEMP}/iam-frontend-ci/ui" status --short
- name: Install Node 22
env:
NODE_ARCHIVE_SHA256: df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz
printf '%s %s\n' "${NODE_ARCHIVE_SHA256}" "$RUNNER_TEMP/node.tar.xz" | sha256sum -c -
tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz"
- name: Install dependencies
run: |
set -euo pipefail
npm ci --prefix "${RUNNER_TEMP}/iam-frontend-ci/ui"
npm ci --prefix "${RUNNER_TEMP}/iam-frontend-ci/frontend"
- name: Test and build
run: |
set -euo pipefail
cd "${RUNNER_TEMP}/iam-frontend-ci/frontend"
npm test
npm run build
+150
View File
@@ -0,0 +1,150 @@
name: governance
on:
pull_request:
push:
branches: [main]
jobs:
governance:
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
# 只物化两个治理 gitlink(Guidelines 与 deps/development-manager),非递归;
# 其它 gitlink 不物化、不取回。凭据只以 URL 限定的 extraheader 存在于本进程树内。
python3 - <<'PY_CHECKOUT'
import os
from pathlib import Path
import re
import subprocess
import sys
from urllib.parse import urlsplit
# 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines",
"deps/development-manager": "Development-Manager",
}
TARGET = "weichen/Agent-System-Frontend"
root_token = os.environ.pop("TOKEN", "")
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
if not root_token or not component_token:
sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN")
if os.environ.get("GITHUB_REPOSITORY") != TARGET:
sys.exit("Checkout repository is outside the reviewed allowlist")
sha = os.environ.get("GITHUB_SHA", "")
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
sys.exit("Checkout requires a fixed commit SHA")
# 丢弃继承的 Git 追踪/配置/SSH 钩子;凭据只存在于单个 Git 进程树的精确 URL 头中。
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null",
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0")
config = {
"core.hooksPath": "/dev/null", "core.fsmonitor": "false",
"init.templateDir": "", "credential.helper": "",
"fetch.recurseSubmodules": "false", "submodule.recurse": "false",
"http.followRedirects": "false", "protocol.allow": "never",
"protocol." + urlsplit(SERVER).scheme + ".allow": "always",
}
def git(repo, *args, extra=None):
values = config | (extra or {})
env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))}
for index, (key, value) in enumerate(values.items()):
env[f"GIT_CONFIG_KEY_{index}"] = key
env[f"GIT_CONFIG_VALUE_{index}"] = value
return subprocess.run(["git", "-C", str(repo), *args], env=env,
check=True, stdout=subprocess.PIPE).stdout
def materialize_governance(root):
# -r 列出任意深度的 gitlink,但不进入 submodule 内部;其它 gitlink 只记录、从不取回。
links = {}
for entry in git(root, "ls-tree", "-rz", "HEAD").split(b"\0"):
if not entry:
continue
metadata, path = entry.split(b"\t", 1)
mode, kind, commit = metadata.decode().split()
if mode == "160000":
links[path.decode()] = commit
for name in REPOSITORIES:
if name not in links:
raise ValueError("Governance gitlink is missing from the fixed commit")
raw = git(root, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
entries = {}
for item in raw.split(b"\0"):
if not item:
continue
key, value = item.decode().split("\n", 1)
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
if not match:
continue
name, field = match.groups()
if name not in REPOSITORIES:
continue
if field in entries.setdefault(name, {}):
raise ValueError("Duplicate candidate .gitmodules setting")
entries[name][field] = value
# 先整体校验两个治理条目,再进行任何带凭据的取回。
for name, repository in REPOSITORIES.items():
fields = entries.get(name, {})
if fields.get("path") != name:
raise ValueError("Governance submodule path is outside the reviewed allowlist")
trusted_url = f"{SERVER}/weichen/{repository}.git"
if fields.get("url") not in {SSH + repository + ".git", trusted_url}:
raise ValueError("Governance submodule URL is outside the reviewed allowlist")
for name, repository in REPOSITORIES.items():
url = f"{SERVER}/weichen/{repository}.git"
# 仅持久化审查过的公开 URL,使 Git 在进程级配置消失后仍识别该 submodule。
git(root, "config", f"submodule.{name}.url", url)
git(root, "submodule", "update", "--init", "--checkout", "--", name,
extra={f"submodule.{name}.url": url,
f"submodule.{name}.update": "checkout",
f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token})
child = root / name
if git(child, "rev-parse", "HEAD").decode().strip() != links[name]:
raise ValueError("Materialized governance gitlink does not match the fixed SHA")
try:
root = Path.cwd()
# 必须是干净的临时工作区;不复用既有 Git 状态。
if (root / ".git").exists():
raise ValueError("Checkout requires a fresh workspace")
git(root, "init", "-q")
url = f"{SERVER}/{TARGET}.git"
git(root, "remote", "add", "origin", url)
git(root, "fetch", "--depth=1", "origin", sha,
extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token})
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
materialize_governance(root)
except (ValueError, OSError, subprocess.CalledProcessError) as error:
sys.exit(f"Checkout refused: {error}")
print("Checkout complete; credentials were not persisted in Git configuration")
PY_CHECKOUT
unset TOKEN SUBMODULE_TOKEN
- name: Install Go 1.27.1
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
- name: Validate agent execution configuration
run: scripts/agent-exec validate --repo .
- name: Governance check
run: scripts/dm governance check --repo .
+6
View File
@@ -0,0 +1,6 @@
[submodule "deps/development-guidelines"]
path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
[submodule "deps/development-manager"]
path = deps/development-manager
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
@@ -0,0 +1,47 @@
# Agent-System-Frontend 采纳 development-governance-v2 治理套件 任务卡
任务主记录:本文件随 PR 描述维护(PR 链接见下方"结果与验证")。
状态:进行中;owner:weichen。
## 基线与授权
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
- branch_kind:initiative;initiative_id:governance-adoption-Agent-System-Frontend
- R0(initial_main_sha):8b59998d3c5b0545f3bb0b3e670b22aa7fe2179b;工作区:/home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption(worktree);分支:feature/governance-adoption
- 父任务信息(仅子任务填写):无
- accepting_requirements:false(单仓采纳 initiative,范围固定为下列十步,不接收新需求)
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=development-governance-v2 Phase 6 每个正式仓库独立 initiative;triage 收据=无(本仓采纳前尚无 triage 入口)
- 授权与交付终点:owner 已授权 development-governance-v2 全部实施(Agent-System-Dev 主记录 docs/superpowers/plans/2026-10-16-development-governance-v2-master.md);终点为本 PR 合入 main 且采纳状态 active。分支保护与 tag 由用户执行。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标(Phase 6 十步):固定 Guidelines SHA(deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb);增加 AGENT-EXECUTION.md;增加共享执行器薄入口(deps/development-manager @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c + scripts/dm、scripts/agent-exec、scripts/task);四条路由(implementation/repair/test/review);任务卡模板与分支元数据;CI 中启用 schema 校验(.gitea/workflows/governance.yml:agent-exec validate + governance check);一次正例运行;负例(缺配置、坏收据、越界路径、未知副作用);独立 Review + CI;采纳状态 active。
非目标:不改业务代码与既有测试;不改分支保护(由用户执行);不发布 tag。
引导说明:AGENT-EXECUTION.md 由 host 以固定版本治理套件(dm-server governance init @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)生成并作为引导配置提交(执行器运行前必须存在有效配置;沿用 DM 采纳先例);其余文件由实施单元生成。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| governance-adoption-Agent-System-Frontend-r1 | 13 个允许路径(治理文件 + 两个 gitlink) | 5670c68e | implementation | cursor→antigravity→pi | run e3fd063d(dm-server pinned,attempts=1) | cursor / not-observable | success | d64dbc3a15d2(apply 快进) | 否 | /tmp/gov-receipt-Agent-System-Frontend-r1.json |
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | 8b59998d3c5b0545f3bb0b3e670b22aa7fe2179b | 8b59998d3c5b0545f3bb0b3e670b22aa7fe2179b | 2026-10-17T06:53:57Z | 一致 |
## 结果与验证(完成后填写)
- 结果:NOT_RUN
- 验证:见下表(命令输出由 /tmp/gov-neg/ev.sh 写入 .task/evidence/governance-adoption/,SELF 除非另注)。
- 影响与剩余项:NOT_RUN
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| 20261017T071544Z | `scripts/dm governance check --repo .` | d64dbc3a15d2 | 0 | SELF | ok fixtures;[输出](evidence/governance-adoption/00-20261017T071544Z.log) sha256:b4f09e161961151b |
| 20261017T071544Z | `scripts/agent-exec validate --repo .` | d64dbc3a15d2 | 0 | SELF | validate: ok;[输出](evidence/governance-adoption/01-20261017T071544Z.log) sha256:06f07c50e89b4bbf |
| 20261017T071544Z | `python3 /tmp/gov-neg/adopt_cases.py .` | d64dbc3a15d2 | 0 | SELF | [verdict] ALL PASS cases=5;[输出](evidence/governance-adoption/02-20261017T071544Z.log) sha256:f74ae904b59d73e4 |
@@ -0,0 +1,45 @@
# IAM U3 账号管理与本人恢复前端 任务卡
任务主记录:本文件随 PR 描述维护。
状态:已完成(合入前闭合);owner:weichen。PR:[Frontend PR #86](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/86)
## 基线与授权
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
- branch_kind:standalone;initiative_id:无(跨仓任务 Agent-System-Dev `feature/iam-u3-account-directory-recovery` 的前端子交付)
- R0:710ef836(分支创建时的 origin/main;分支早于本仓治理接入,原无任务卡,本卡在收口时补建);工作区:/home/weichen/projects/Agent-System-IAM-U3-Frontend-Combo/frontend(worktree);分支:feature/iam-u3-account-ui
- 父任务信息(仅子任务填写):无
- accepting_requirements:false
- 需求分流:由 Dev U3 主任务承接
- 授权与交付终点:用户已批准 U3 阶段 4–6 与 Frontend 组件阶段 7(HEAD `93ee723`);U4 合入后用户选择 merge_then_accept:同步 main、重跑证据、独立 Review、当前 HEAD CI 通过后合入,用户在部署上验收,问题另立修复。终点为本 PR 合入 main,再由 RDM/Dev 组合固定。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标:把 U3 账号界面移植到含 U4 职位权限 UI、账号链路修复(#94/#95)、密码登录与管理员临时重置(#107)、RDM 成员检索(#108)的 main 上,每项能力只保留一套实现(以 main 为基础补入 U3 缺少的行为),并按 U3 RDM 目录消费的命令合同补回成员/职责候选选择器。
非目标:不恢复 U3 登录 Key 标签页(main 已改为纯密码登录);不恢复 main 已删除的 rdm.duty.get 编辑器;不改 Backend/RDM/UI。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| iam-u3-frontend-onto-main-r2 | .gitea/workflows/ci.yml、src/、test/ | implementation | cursor→antigravity→pi | 1(e80375762b4e) | cursor / not-observable(新进程,fresh_session=true) | success | d47c493(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-onto-main-run2.json |
| iam-u3-frontend-rdm-selectors-r1 | src/、test/ | implementation | cursor→antigravity→pi | 1(16e6bae28677) | cursor / not-observable(新进程,fresh_session=true) | success | 72c941c(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-rdm-selectors-run.json |
| iam-u3-frontend-ci-ui-sha-r1 | .gitea/workflows/ci.yml | repair | cursor→antigravity→pi | 1(3f39344cbd2e) | cursor / not-observable(新进程,fresh_session=true) | success | afd9e75(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-ci-ui-sha-run.json |
首个单元 r1 因 U3 分支缺 AGENT-EXECUTION.md 返回 invalid-config(未启动 worker);改为在 main 上建同步分支移植。`e4041b3` 以 `-s ours` 记录原分支 `bc9b17a` 历史,树等于 `afd9e75`。
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|---|---|---|---|
| U4 后再同步 | 1650b4fff0163e48a752ecbd86a3c4b18771e287 | e4041b36b97c1d3ac335584644d906b0e8adf55c | 2026-10-18 |
| 合入前 pr-check | 1650b4fff0163e48a752ecbd86a3c4b18771e287 | 080c63081642e5670c8c7ff2646905f5b398c807 | 2026-10-19(PR_CHECK_OK,落后 main 0) |
## 结果与验证
- 结果:每个冲突文件的取舍(worker 记录):api.ts 以 main 为基础,删除 U3 重复方法,redeem 改走 `/api/auth/recovery-grants/redeem`;AccountUsersPanel 保留 main 组件并补入 U3 的写后重读、刷新失败提示、游标重置、409/启停提示与凭据状态列;adminSections 补入 U3 恢复提案幂等重试、创建后重读与终态提示、迟到响应丢弃;LoginPage/AdminZone/MyAccountSettings/SettingsModal 用 main;RDM 选择器按 RDM U3 合同(mode/searchFields/docId/cursor,成员添加带 userId,职责指派/预览带 accountIds)补回。
- 验证:主机在 `72c941c`(配对 UI `5c2b103`)上 `npx tsc -b`、`npx vitest run`(48 文件 565 测试)、`npm run build`、`node scripts/check-knip.mjs` 通过,日志 `/tmp/cleanup-20261018/render/u3-fe-72c941c-verify.log`(SELF)。当前 HEAD `e4041b3` 的 CI `ci / test-and-build` 与 `governance / governance` 通过(GATE)。
- Review:全新只读子代理会话审阅 `1650b4f..72c941c`,阻塞项为 ci.yml 的 UI_SHA 仍是旧 `7c35a78`(复现 tsc 失败),已由 `afd9e75` 改为 `5c2b103` 并经 CI 证明;非阻塞记录:`accountCandidatePager` 的 `issuedFor` 共享闭包在迟到响应下可能丢游标;adminSections 任何 409 都回落为临时重置(main 既有行为)。
- 验收:按 merge_then_accept,用户在部署上验收,问题另立修复。
@@ -0,0 +1,41 @@
# 法规结构化引用前端呈现(U7) 任务卡
任务主记录:本文件随 PR 描述维护(PR 链接见下方“结果与验证”)。
状态:已完成(合入前闭合);owner:weichen。PR:[Frontend PR #109](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/109)
## 基线与授权
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
- branch_kind:standalone;initiative_id:无(跨仓大任务 Agent-System-Dev `feature/regulations-kb` 的 U7 前端子交付,Dev PR #614 登记)
- R0(initial_main_sha):0459567(origin/main);工作区:/home/weichen/projects/Agent-System-Frontend-wt-feature-regulations-kb-citations(worktree);分支:feature/regulations-kb-citations
- 父任务信息(仅子任务填写):无
- accepting_requirements:false
- 需求分流:verdict=JOIN(由 Dev 大任务承接);selected_initiative=Agent-System-Dev feature/regulations-kb;理由=计划 U7 前端范围
- 授权与交付终点:owner 以 /goal 授权按法规知识库计划全部实施;终点为本 PR 合入 main,再由 RDM/Dev 组合钉住。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标:按 Dev 规格 `docs/superpowers/specs/2026-10-18-regulations-citation-contract.md` 第 5、6 节,在审计工作区新增“法规核验”树(`rdm.inspect.tree kind=regs`)与批/引用详情;驾驶舱阅读器把正文中的 `[cit-…]` 渲染为编号引用标记并可展开元数据与原文片段;未核验、无效与待复核状态清晰区分。
非目标:不改 RDM 写路径与命令;不改设计令牌;不做部署。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| frontend-u7-regulation-citations | src/、test/ | implementation | cursor→antigravity→pi | 1(35d799203497) | cursor / not-observable(新进程,fresh_session=true) | success | 6a3f244(apply 快进,run 4cf12d61) | 否 | /tmp/regkb-u7fe-receipt.json |
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | 0459567 | 0459567 | 2026-10-18 | 一致 |
| 验收前 | 0459567(origin/main) | 6a3f244 | 2026-10-18 | 一致,无需同步 |
## 结果与验证(完成后填写)
- 结果:新增 `src/regCitations.ts`(契约类型、`[cit-…]` 标记解析与编号、状态/过期/省略原因文案、样式档、审计树请求参数)与 `src/components/RegCitation.tsx`(编号标记、引用卡片、引用清单,折叠用 @ui Collapsible,正文与片段仅作文本渲染);审计工作区新增“法规核验”树(证据批→引用)与批/引用详情(状态、原因、腿状态、封存、引用元数据、受披露约束的片段);驾驶舱阅读器正文渲染引用标记与引用清单。只有 verified 且未过期显示为有效依据,其余状态与无效引用使用明确的未核验/无效样式。样式只用令牌(`src/reg-citations.css`)。
- 验证:host 在临时 Dev 组合布局(modules/ui c918747)重跑 `bash /tmp/regkb-fe-verify.sh all`(SELF):vitest 37 文件 345 用例通过(含 design-guard 与新增 reg-citations 测试),knip 未用导出 50 ≤ 51,`tsc -b` 与 `vite build` 通过,日志 /tmp/regkb-u7fe-verify.log;`git diff --check` 通过。权威结论以 PR HEAD CI 与 Dev 组合 `make test` 为准。
- Review:host 独立审阅实施 diff(未复用实施会话):未使用 dangerouslySetInnerHTML 与 `<details>`,外链仅 http(s),字段名与 RDM U6 JSON 一致;无阻塞项。
- 影响与剩余项:数据由 RDM U7(`rdm.inspect.tree kind=regs`、`rdm.inspect.object`、`rdm.cockpit.node` citations)提供;RDM 未提供时界面为空态,不影响既有标签页。Dev 组合 pin 在 U8 推进。
@@ -0,0 +1,19 @@
command: scripts/dm governance check --repo .
cwd: /home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption
head: d64dbc3a15d29520402f0c97199cb88b3e4aa9af
exit: 0
utc: 20261017T071544Z
---
ok gitlink deps/development-guidelines
ok gitlink deps/development-manager
ok agent-execution
ok file AGENTS.md
ok file PROJECT.md
ok file policy/guidelines-adoption.md
ok file .task/templates/task-card.md
ok executable scripts/agent-exec
ok executable scripts/hooks/pre-commit
ok executable scripts/dm
ok executable scripts/task
ok adoption status
ok fixtures
@@ -0,0 +1,7 @@
command: scripts/agent-exec validate --repo .
cwd: /home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption
head: d64dbc3a15d29520402f0c97199cb88b3e4aa9af
exit: 0
utc: 20261017T071544Z
---
validate: ok
@@ -0,0 +1,13 @@
command: python3 /tmp/gov-neg/adopt_cases.py .
cwd: /home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption
head: d64dbc3a15d29520402f0c97199cb88b3e4aa9af
exit: 0
utc: 20261017T071544Z
---
[object] Agent-System-Frontend-wt-feature-governance-adoption HEAD=d64dbc3a15d29520402f0c97199cb88b3e4aa9af
PASS positive-run-apply: run exit=0 terminal=success apply exit=0 head=6fa967fff457
PASS missing-config: run exit=2 terminal=invalid-config validate exit=2
PASS bad-receipt: apply exit=4 head_unchanged=True msg=apply: no local integrity record: changed paths do not match the transport diff
PASS out-of-scope-path: run exit=5 terminal=scope-expansion transport=False
PASS unknown-side-effect: run exit=4 terminal=failed-unknown transport=False
[verdict] ALL PASS cases=5
+66
View File
@@ -0,0 +1,66 @@
# <任务标题> 任务卡
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
状态:<进行中 / 待审核 / 已合入 / 已放弃>;owner:<填写>。
## 基线与授权
- 权威仓:<仓 URL 或逻辑 ID>;root_target:main;immediate_target:<main 或 feature/initiative>
- branch_kind:<initiative | child | standalone>;initiative_id:<稳定 ID,普通任务为空>
- R0(initial_main_sha):<完整 SHA>;工作区:<路径>(worktree / clone);分支:<feature|fix|hotfix/...>
- 父任务信息(仅子任务填写):parent_branch=<父分支>;parent_head_sha=<SHA>;parent_pr=<父 PR>
- accepting_requirements:<true | false>(仅 initiative 可为 true;冻结后为 false)
- 需求分流:verdict=<JOIN | NEW>;selected_initiative=<分支或无>;候选=<分支@HEAD 列表或无>;理由=<不适配/适配依据>;triage 收据=<源码外路径与摘要>
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
## Agent 实施收据
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
## 复用材料(task-start 自动检索结果摘录)
- 相关近期提交:<git log -- <paths> 命中;无则写无>。
- 相关经验条目:<检索命中;无则写无>。
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | <R0> | <HEAD> | <日期时间> | <一致/已同步> |
| 验收前 | <填写> | <填写> | <填写> | <填写> |
字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。
## 交付调度(有多个就绪任务时)
- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。
- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。
- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。
- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。
定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
## 结果与验证(完成后填写)
- 结果:<交付了什么;未发生写无>。
- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。
- 影响与剩余项:<影响面、遗留风险、后续任务>。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
## 回收(task-finish)
- 合入核对:<分支已包含于目标分支的核对输出>。
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
+67
View File
@@ -0,0 +1,67 @@
# Project Agent Execution Configuration
# 项目工程 Agent 执行路由配置
适用范围:定义本项目中由 AI / Agent 主导的代码实施、修复、测试与审阅所调用的 Agent CLI 路由。
解析器识别正文中唯一一个 ```agent-execution 代码块。正文外的说明文字可根据项目需要调整。
通用硬规则见 Development-Guidelines 的 [Agent 执行与协作契约](deps/development-guidelines/standards/agent-execution.md)。
本配置为项目本地路由权威,声明具体的候选 profile、尝试次数上限、超时、集成入口与豁免路径;
代码块须通过 `deps/development-guidelines/templates/schemas/agent-execution.schema.json` 校验后才能登记为 active。
`fallback_to_host` 只能为 `never`;旧值 `all-unavailable` 仅供迁移工具读取,不能通过准入。
下例的 CLI 名称、命令与路径均为占位,按项目实际执行器替换。
```agent-execution
schema: agent-execution/v1
execution:
fresh_process_per_unit: true
permission: run-everything
fallback_to_host: never
max_parallel: 3
timeout_seconds: 7200
idle_timeout_seconds: 600
summary_bytes: 16384
raw_output_bytes: 10485760
max_attempts_per_profile: 3
emergency_profile: codex
roles:
implementation: [cursor, antigravity, pi]
repair: [cursor, antigravity, pi]
test: [pi, antigravity, cursor]
review: [pi, antigravity, cursor, codex]
profiles:
cursor:
adapter: cursor
executable: "cursor-agent-stable"
model: auto
enabled: true
antigravity:
adapter: antigravity
executable: agy
model: auto
enabled: true
pi:
adapter: pi
executable: pi
model: auto
enabled: true
codex:
adapter: codex
executable: codex
model: auto
enabled: true
integration:
exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"]
transport:
command: "scripts/agent-exec apply"
admission:
command: "scripts/agent-exec admit"
cleanup:
command: "scripts/agent-exec cleanup"
```
+18
View File
@@ -35,3 +35,21 @@
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
## Agent 执行与新项目准入(强制)
1. 缺 Guidelines 固定版本(`deps/development-guidelines` gitlink 未就绪或 HEAD 不符)时,Agent 只能只读分析,不得实施。
2. 缺有效 `AGENT-EXECUTION.md`(`scripts/dm governance check` 未通过)时禁止 AI 实施。
3. 缺共享执行器(`scripts/agent-exec` 或 `deps/development-manager` 不可用)时,状态为 `blocked/unavailable`,不得改由 host 替代。
4. 不允许 host 代写实施单元;实施、修复与测试必须经 `AGENT-EXECUTION.md` 登记的候选执行。
5. 人类直接开发按其自身授权与项目规则进行,不受本条款约束。
6. 分支保护与规范 tag 由用户负责设置与维护,Agent 不得代为设置。
入口:
- 需求分流:`scripts/task triage discover|decide|verify`
- 任务启动:`scripts/task start …`
- PR 检查:`scripts/task pr-check …`
- Agent 执行:`scripts/agent-exec run|apply|admit|validate`
AI 实施单元仅通过 `scripts/agent-exec run` 运行(依据 AGENT-EXECUTION.md),并经 `scripts/agent-exec apply` 集成。
+1
View File
@@ -10,4 +10,5 @@
- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/(Dev Makefile 拷贝至 backend web/dist 内嵌)。
- `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。
- 开发:`npm run dev`(Vite 代理 /api → 127.0.0.1:8787)。
- 治理:`scripts/agent-exec validate --repo .` — 校验 AGENT-EXECUTION.md 执行路由配置;`scripts/dm governance check --repo .` — 校验治理套件(Guidelines 固定版本、采纳记录与执行配置)。
- 依赖刻意最小:react + marked + dompurify(模型输出与文档预览先清洗再写入页面);无 UI 框架(设计令牌手写,见 styles.css)。
Vendored Submodule
+1
+81
View File
@@ -0,0 +1,81 @@
// 防再发守卫:管理员建号路径(UsersPanel)必须持续符合后端待初始化契约。
// 背景:backend 8dfc223 将 POST /api/admin/users 改为严格 {owner, profile} 解码,
// 前端未跟进导致部署后 UI 建号 400(json: unknown field "name")——单测与 mock
// 都没有覆盖这条组合路径,本场景以真实浏览器把「填用户名 → 创建账号 →
// 确认『待初始化』提示与用户表行」钉进组合验收线。
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
// 与 vite preview;种子账号 e2e-auditor 为基座管理员(bootstrap-admin)。
// 环境契约(缺省值与 e2e.sh 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过):
// PLAYWRIGHT_BASE_URL / E2E_OWNER / E2E_PASSWORD / E2E_PASSWORD2
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
async function login(page: Page) {
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
// 三种落点:直接进工作台(密码已生效)|强制改密门|初始密码已失效(种子二次运行)→ 改用新密码重登。
const entered = await page.locator('.tsb-user').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (!entered) {
const gated = await page.getByText('修改密码').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
} else {
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
}
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
}
test('管理员建号:UI 请求新契约并给出待初始化提示', async ({ page }) => {
await login(page)
// 账户菜单 → 系统管理…(全屏独立视图)→ 用户管理分区。
await page.locator('.tsb-user').click()
const adminEntry = page.getByText('系统管理…', { exact: true })
await expect(adminEntry).toBeVisible()
await adminEntry.click()
await expect(page.locator('.az')).toBeVisible()
const usersSection = page.getByText('用户管理', { exact: true }).first()
if (await usersSection.count()) await usersSection.click()
// 建号表单:用户名每次运行唯一(重复运行不因 409 假红),显示名可空。
const newOwner = `e2e-ui-${Date.now().toString(36)}`
const userInput = page.getByPlaceholder('用户名(登录名)')
await expect(userInput).toBeVisible()
await userInput.fill(newOwner)
await page.getByPlaceholder('显示名(可空)').fill('组合验收')
// 创建 → 契约断言一:请求体是严格 {owner, profile:{name}},无 password/isAdmin。
const createBody = page.waitForRequest(
req => req.url().endsWith('/api/admin/users') && req.method() === 'POST',
).then(req => req.postDataJSON())
await page.getByRole('button', { name: '创建账号', exact: true }).click()
const body = await createBody
expect(body).toEqual({ owner: newOwner, profile: { name: '组合验收' } })
// 契约断言二:页面创建后账号可直接使用,不再提示 CLI 初始化。
await expect(page.locator('.ad-notice')).toContainText(`已创建 ${newOwner}`)
// 契约断言三:用户表出现该账号,状态为未设密码(HTTP 建号不能设密码/授权限)。
const row = page.locator('.ad-table tbody tr', { hasText: newOwner })
await expect(row).toBeVisible()
await expect(row).toContainText('未设密码')
await expect(row).not.toContainText('ADMIN')
// 界面说明与后端流程一致(激活 Key 由服务器本地 CLI 发放)。
await expect(page.locator('.ad-create .ad-hint')).toContainText('初始密码')
await expect(page.locator('.ad-create .ad-hint')).not.toContainText('user initialize-account')
})
+775
View File
@@ -0,0 +1,775 @@
// 统一渲染 §11 浏览器验收:真实产品(RDM 二进制 auth on + dist preview)走通九项。
// 驱动方:/tmp/renderer-browser-acceptance/browser-acceptance.sh(种子写 $RBA_OUT/seed.env);
// 未提供 PLAYWRIGHT_BASE_URL 或 RBA_OUT 时整组跳过。
// 产品缺能力处用 expect.soft('GAP[仓] …') 记失败并继续取证;浏览器做不到、只能以本人会话
// 直调命令的步骤记为 API_ASSISTED。跨用例状态写 $RBA_OUT/state.json(失败会重启 worker)。
import { test, expect, type Browser, type Locator, type Page } from '@playwright/test'
import fs from 'node:fs'
import path from 'node:path'
const env = (k: string) => process.env[k] ?? ''
const OUT = env('RBA_OUT')
const EVID = OUT ? path.join(OUT, 'evidence') : ''
const RUN = path.basename(OUT || 'run')
const PW = env('RBA_PASSWORD')
const U = { admin: env('RBA_ADMIN'), author: env('RBA_AUTHOR'), reviewer: env('RBA_REVIEWER'), approver: env('RBA_APPROVER'), outsider: env('RBA_OUTSIDER') }
const A = { name: env('RBA_PROJECT_A'), pid: env('RBA_PID_A'), host: env('RBA_HOST_A'), doc: env('RBA_DOC_A'), title: env('RBA_DOC_A_TITLE') }
const B = { name: env('RBA_PROJECT_B'), pid: env('RBA_PID_B'), host: env('RBA_HOST_B'), doc: env('RBA_DOC_B') }
const UNCTRL = { doc: env('RBA_UNCTRL_DOC'), title: env('RBA_UNCTRL_TITLE') }
const GENERAL = { id: env('RBA_GENERAL_PROFILE'), version: env('RBA_GENERAL_VERSION') }
const TEMPLATE_PROFILE = `company.${env('RBA_TEMPLATE')}`
const ANNOTATION = `§11 批注 ${RUN}:请补充目标量化指标`
const RANGE_ANNOTATION = `§11 范围批注 ${RUN}:「目标」需可量化`
const REVISION_AFTER = `§11 修订 ${RUN}:目标改为三项可量化指标`
test.skip(!process.env.PLAYWRIGHT_BASE_URL || !OUT, 'set PLAYWRIGHT_BASE_URL and RBA_OUT (browser-acceptance.sh drives this suite)')
test.describe.configure({ mode: 'default', timeout: 240_000 })
// ---- 证据与跨用例状态 ----
const STATE = () => path.join(OUT, 'state.json')
const readState = (): Record<string, any> => { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')) } catch { return {} } }
const writeState = (patch: Record<string, unknown>) => fs.writeFileSync(STATE(), JSON.stringify({ ...readState(), ...patch }, null, 2))
let evidence: string[] = []
let notes: string[] = []
let openSessions = new Set<() => Promise<void>>()
const note = (s: string) => { notes.push(s) }
test.beforeEach(() => { evidence = []; notes = []; openSessions = new Set() })
test.afterEach(async ({}, info) => {
for (const close of [...openSessions]) await close().catch(() => {})
const items = info.annotations.filter(a => a.type === 'item').map(a => a.description)
const dir = path.join(EVID, 'results')
fs.mkdirSync(dir, { recursive: true })
const errors = info.errors.map(e => (e.message ?? String(e)).replace(/\u001b\[[0-9;]*m/g, '').slice(0, 1500))
fs.writeFileSync(path.join(dir, `${info.testId}.json`), JSON.stringify({
title: info.title, items, status: info.status, errors, evidence, notes,
apiAssisted: info.annotations.filter(a => a.type === 'API_ASSISTED').map(a => a.description),
durationMs: info.duration,
}, null, 2))
})
const items = (...ids: number[]) => { for (const id of ids) test.info().annotations.push({ type: 'item', description: String(id) }) }
const apiAssisted = (what: string) => test.info().annotations.push({ type: 'API_ASSISTED', description: what })
async function shot(page: Page, name: string, target?: Locator) {
const file = path.join(EVID, 'screens', `${name}.png`)
fs.mkdirSync(path.dirname(file), { recursive: true })
if (target) await target.screenshot({ path: file })
else await page.screenshot({ path: file, fullPage: false })
evidence.push(file)
}
interface NetEntry { url: string; method: string; status: number; request: string; body: string }
/** 每个会话一个上下文:主题经真实持久化入口 localStorage('as.ui') 注入;记录全部 /api 响应体。 */
async function session(browser: Browser, owner: string, tag: string, opts: { theme?: 'light' | 'dark'; width?: number; height?: number } = {}) {
const ctx = await browser.newContext({ viewport: { width: opts.width ?? 1440, height: opts.height ?? 900 }, acceptDownloads: true, locale: 'zh-CN' })
if (opts.theme) {
await ctx.addInitScript(theme => {
const prev = (() => { try { return JSON.parse(localStorage.getItem('as.ui') || '{}') } catch { return {} } })()
localStorage.setItem('as.ui', JSON.stringify({ ...prev, theme }))
}, opts.theme)
}
const page = await ctx.newPage()
const net: NetEntry[] = []
const pending: Promise<void>[] = []
page.on('response', res => {
const url = res.url()
if (!url.includes('/api/') || (res.headers()['content-type'] ?? '').includes('event-stream')) return
pending.push(Promise.race([res.text(), new Promise<string>(r => setTimeout(() => r('<<body timeout>>'), 5_000))])
.catch(() => '<<no body>>')
.then(body => { net.push({ url, method: res.request().method(), status: res.status(), request: res.request().postData() ?? '', body }) }))
})
const close = async () => {
openSessions.delete(close)
await Promise.all(pending)
const file = path.join(EVID, 'network', `${tag}.jsonl`)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, net.map(n => JSON.stringify(n)).join('\n') + '\n')
evidence.push(file)
await ctx.close()
}
openSessions.add(close)
await login(page, owner)
return { page, net, pending, close }
}
async function login(page: Page, owner: string) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(owner)
await page.getByPlaceholder('初始密码或常用密码').fill(PW)
await page.getByRole('button', { name: '登录', exact: true }).click()
await page.locator('.tsb-tool', { hasText: '追溯审计' }).waitFor({ state: 'attached', timeout: 20_000 })
}
/** 窄屏下侧栏收进「上下文」面板:先经工作台导航打开,再点审计入口。 */
async function clickAuditEntry(page: Page) {
const entry = page.locator('.tsb-tool', { hasText: '追溯审计' })
if (!(await entry.isVisible())) await page.getByRole('button', { name: '打开上下文' }).click()
await entry.click()
}
async function openAudit(page: Page, projectName: string) {
await clickAuditEntry(page)
await expect(page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 })
await page.locator('.rdm-audit-project').selectOption({ label: projectName })
}
async function openDoc(page: Page, title: string) {
await page.getByRole('tab', { name: '资料与文档' }).click()
const open = page.getByRole('button', { name: `阅读与交付:${title}`, exact: true })
for (let i = 0; i < 5 && !(await open.isVisible().catch(() => false)); i++) {
const more = page.locator('.rdm-audit-more')
if (await more.isVisible().catch(() => false)) await more.click()
else await page.waitForTimeout(500)
}
await open.click()
await expect(page.locator('.drw')).toBeVisible()
await expect(page.locator('.drw-canvas-node').first()).toBeVisible({ timeout: 20_000 })
}
const panel = (page: Page, label: string) =>
page.locator('.drw-panel').filter({ has: page.locator('.ui-seclabel', { hasText: new RegExp(`^${label}$`) }) })
const deliveryOf = (page: Page, mode: '预览' | '审阅' | '批准') =>
page.locator('.drw-delivery').filter({ has: page.locator('.ui-chip', { hasText: new RegExp(`^${mode}$`) }) }).first()
/** 以本人浏览器会话直调命令(与前端同一 /api/commands 通道、同一 cookie)。 */
async function command(page: Page, id: string, params: Record<string, unknown>, source?: string) {
const [conversationId, anchorMessageId] = (source ?? '').split(' ')
const res = await page.request.post(`/api/commands/${id}`, {
data: { params, ...(source ? { entrance: 'api', conversationId, anchorMessageId } : {}) },
})
const text = await res.text()
let body: any = null
try { body = JSON.parse(text) } catch { /* 非 JSON 原样保留 */ }
const file = path.join(EVID, 'api', `${test.info().testId}-${id}.json`)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, JSON.stringify({ id, params, status: res.status(), body: body ?? text }, null, 2))
evidence.push(file)
return { status: res.status(), text, body }
}
async function download(page: Page, delivery: Locator, label: string) {
const button = delivery.getByRole('button', { name: label, exact: true })
await expect(button, `下载入口「${label}」`).toBeVisible()
const [dl] = await Promise.all([page.waitForEvent('download', { timeout: 30_000 }), button.click()])
const name = dl.suggestedFilename()
const file = path.join(EVID, 'downloads', name)
fs.mkdirSync(path.dirname(file), { recursive: true })
await dl.saveAs(file)
evidence.push(file)
return { name, file, size: fs.statSync(file).size }
}
async function expectReadableFailures(page: Page) {
for (const msg of await page.locator('.drw-failure-msg').allTextContents()) {
expect(msg.trim().startsWith('{'), `失败文案不得是原始 JSON:${msg}`).toBe(false)
}
}
const parseJson = (text: string): any => { try { return JSON.parse(text) } catch { return null } }
/**
* 工作区整文决定的取证:先有同会话的已提交来源消息(POST /messages 响应),命令再以它为顶层锚点分发,
* params 不含来源字段,入口为页面动作;回执必须 committed。
*/
async function expectCommittedDecision(s: { net: NetEntry[] }, command: string, label: string) {
const cmdUrl = `/api/commands/${command}`
await expect.poll(() => s.net.some(n => n.url.includes(cmdUrl)), { message: `应分发 ${command}`, timeout: 15_000 }).toBe(true)
const dispatch = s.net.filter(n => n.url.includes(cmdUrl)).at(-1)!
const body = parseJson(dispatch.request)
expect(body.params, '命令参数不携带来源字段').not.toHaveProperty('anchorMessageId')
expect(body.entrance, '工作区动作入口').toBe('button')
const findSource = () => s.net.find(n => n.method === 'POST'
&& n.url.includes(`/api/conversations/${body.conversationId}/messages`)
&& parseJson(n.body)?.message?.id === body.anchorMessageId)
await expect.poll(() => findSource() !== undefined, { message: '锚点必须是同会话已提交的来源消息', timeout: 15_000 }).toBe(true)
expect(parseJson(findSource()!.request)?.text, '来源消息写明中文审签决定').toContain(`审签决定:${label}`)
expect(parseJson(dispatch.body)?.receipt?.state, `${command} 回执 committed`).toBe('committed')
}
// ---- §11 ----
test('§11-1 文档树 → 结构画布与阅读视图(共用选中与过滤)', async ({ browser }) => {
items(1)
const s = await session(browser, U.author, 'item1-author')
const { page } = s
await openAudit(page, A.name)
await page.getByRole('tab', { name: '资料与文档' }).click()
const docRow = page.locator('.rdm-audit-tree-row').filter({ hasText: A.title })
await expect(docRow).toBeVisible({ timeout: 15_000 })
await docRow.locator('.rdm-audit-tree-node').click()
await expect(page.locator('.rdm-audit-tree-children').first()).toBeVisible({ timeout: 15_000 })
await shot(page, 'item1-doc-tree')
await openDoc(page, A.title)
const nodes = page.locator('.drw-canvas-node')
const total = await nodes.count()
note(`结构画布节点数 ${total}`)
expect(total, '受控文档应展开为多节点结构').toBeGreaterThan(10)
const blank = (await page.locator('.drw-canvas-title').allTextContents()).filter(t => !t.trim()).length
expect(blank, '画布不得出现无标题的空白节点').toBe(0)
await shot(page, 'item1-canvas')
const target = page.locator('.drw-canvas-node[title="项目内容、目标及意义"]').first()
await target.click()
await expect(target).toHaveAttribute('aria-selected', 'true')
await page.getByLabel('过滤节点').fill('目标')
await page.getByRole('tab', { name: '阅读视图' }).click()
await expect(page.locator('.drw-reader')).toBeVisible()
await expect(page.locator('.drw-reader-title[aria-current="true"]')).toHaveText('项目内容、目标及意义')
await expect(page.getByLabel('过滤节点')).toHaveValue('目标')
await shot(page, 'item1-reader-filtered')
await page.getByLabel('过滤节点').fill('')
await page.getByRole('tab', { name: '结构画布' }).click()
await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toHaveAttribute('title', '项目内容、目标及意义')
// 键盘导航
await page.locator('.drw-canvas-node[aria-selected="true"]').focus()
await page.keyboard.press('ArrowDown')
await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toBeFocused()
await s.close()
})
test('§11-2b 规则绑定:项目设置权限可绑定与解绑(非受控文档)', async ({ browser }) => {
items(2)
const s = await session(browser, U.admin, 'item2b-admin')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, UNCTRL.title)
const binding = panel(page, '规则绑定')
await expect(binding, '具备项目设置权限的用户可见规则绑定区').toBeVisible({ timeout: 20_000 })
await page.locator('.drw-bind-rule', { hasText: `${GENERAL.id}@${GENERAL.version}` }).locator('input').check()
await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '绑定所选规则', exact: true }).click()
await expect(panel(page, '规则绑定').getByText('绑定规则已完成')).toBeVisible({ timeout: 30_000 })
await expectReadableFailures(page)
await expect(panel(page, '渲染规则').locator('.drw-profile-source', { hasText: '文档规则' }), '绑定后规则列表刷新出文档规则').toBeVisible({ timeout: 15_000 })
await shot(page, 'item2b-bound', binding)
await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '解绑', exact: true }).click()
await expect(panel(page, '规则绑定').getByText('解绑规则已完成')).toBeVisible({ timeout: 30_000 })
await expectReadableFailures(page)
await s.close()
})
test('§11-3b 受控文档拒绝绑定通用规则,失败以可读文案显示', async ({ browser }) => {
items(3)
const s = await session(browser, U.admin, 'item3b-admin')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const binding = panel(page, '规则绑定')
await expect(binding, '具备项目设置权限的用户可见规则绑定区').toBeVisible({ timeout: 20_000 })
await page.locator('.drw-bind-rule', { hasText: `${GENERAL.id}@${GENERAL.version}` }).locator('input').check()
await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '绑定所选规则', exact: true }).click()
await expect(binding.locator('.drw-failure-msg')).toContainText('受控文档', { timeout: 30_000 })
await expect(binding.locator('.drw-failure-msg')).not.toContainText('controlled_requires_exact_profile')
await shot(page, 'item3b-controlled-refusal', binding)
await s.close()
})
test('§11-3 受控文档:规则来源、版本与不回退说明', async ({ browser }) => {
items(3)
const s = await session(browser, U.author, 'item3-author')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const rules = panel(page, '渲染规则')
const option = rules.locator('.drw-profile').first()
await expect(option).toBeVisible({ timeout: 15_000 })
await expect(option.locator('.drw-profile-source')).toHaveText('模板规则')
await expect(option.locator('.drw-profile-id')).toHaveText(TEMPLATE_PROFILE)
await expect(option.locator('.drw-profile-ver')).toHaveText(`v${env('RBA_TEMPLATE_VERSION')}`)
// 44 个 DHF 专用规则均已发布(state=published、publicationAllowed=true):状态为已发布,可正式交付,无未发布原因。
await expect(option.locator('.ui-chip').first()).toHaveText('已发布')
await expect(option.locator('.ui-chip', { hasText: '可正式交付' })).toBeVisible()
await expect(option.locator('input[type="radio"]')).toBeEnabled()
await expect(option.locator('.drw-profile-reason')).toHaveCount(0)
await expect(rules.locator('.drw-notice')).toContainText('不会回退到项目或公司默认规则')
await expect(rules.locator('.drw-failure-msg')).toContainText('不得回退到项目或公司默认规则')
await expectReadableFailures(page)
await shot(page, 'item3-rules-controlled', rules)
await s.close()
})
test('§11-2 专用规则与公司通用规则可选', async ({ browser }) => {
items(2)
const s = await session(browser, U.author, 'item2-author')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const rules = panel(page, '渲染规则')
await expect(rules.locator('.drw-profile').first()).toBeVisible({ timeout: 15_000 })
const sources = await rules.locator('.drw-profile-source').allTextContents()
note(`受控文档可选规则来源:${sources.join('、')}`)
expect(sources.some(x => x === '模板规则' || x === '文档类型规则' || x === '文档规则'), '应提供专用(模板/文档类型)规则').toBe(true)
await shot(page, 'item2-controlled-rules', rules)
await page.getByRole('button', { name: '← 返回文档树' }).click()
await openDoc(page, UNCTRL.title)
const rulesU = panel(page, '渲染规则')
await expect(rulesU.locator('.drw-profile').or(rulesU.locator('.drw-failure')).or(rulesU.getByText('没有可用的渲染规则')).first()).toBeVisible({ timeout: 15_000 })
const uSources = await rulesU.locator('.drw-profile-source').allTextContents()
note(`非受控文档可选规则来源:${uSources.join('、') || '(无)'};拒绝:${(await rulesU.locator('.drw-failure-msg').allTextContents()).join(' | ')}`)
await shot(page, 'item2-uncontrolled-rules', rulesU)
expect.soft(uSources.includes('公司默认规则'),
`GAP[RDM] 已发布的公司通用规则 ${GENERAL.id}@${GENERAL.version} 不出现在非受控文档的规则列表:rdm.render.profiles 只读 rdm_render_profile_bindings,而产品无任何写绑定命令(organization-default/project-default/document-type 绑定不可达)`).toBe(true)
expect.soft(sources.length + uSources.length >= 2 && uSources.length >= 1,
'GAP[RDM] 浏览器内无法在同一文档上于专用规则与公司通用规则之间选择(专用规则只到 validated;通用规则无绑定)').toBe(true)
await s.close()
})
test('§11-4 生成预览并把诊断定位到节点', async ({ browser }) => {
items(4)
const s = await session(browser, U.author, 'item4-author')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const generate = page.getByRole('button', { name: '生成预览', exact: true })
await expect(generate).toBeEnabled({ timeout: 15_000 })
await generate.click()
const preview = panel(page, '预览与提交')
await expect(preview.locator('.drw-receipt').or(preview.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 90_000 })
await expectReadableFailures(page)
await shot(page, 'item4-preview-receipt')
await expect(preview.locator('.drw-op .drw-failure'), '预览不应失败').toHaveCount(0)
const receipt = preview.locator('.drw-receipt')
await expect(receipt.locator('.ui-chip').first()).toHaveText('已生成', { timeout: 60_000 })
await expect(receipt.getByText('诊断读取中')).toHaveCount(0, { timeout: 60_000 })
await expect(receipt.locator('.drw-diags').or(receipt.getByText('没有诊断问题')).or(receipt.locator('.drw-failure')).first()).toBeVisible()
await expectReadableFailures(page)
const summary = await receipt.locator('.drw-muted').first().textContent()
const located = receipt.locator('button.drw-diag-node')
const locatedCount = await located.count()
const unlocated = await receipt.locator('.drw-diag-node.unlocated').count()
note(`诊断摘要:${summary};可定位 ${locatedCount} 条(首屏),未定位 ${unlocated} 条`)
await shot(page, 'item4-diagnostics', preview)
await expect(deliveryOf(page, '预览')).toBeVisible({ timeout: 15_000 })
expect.soft(locatedCount,
'GAP[Renderer] 渲染器生产代码从不产出 warning/info 诊断、也不填 Diagnostic.NodeID(仅失败时产出无节点的 error);GAP[RDM] 受控 DHF 实例 172 个节点全为空/未开始,预览 diagnostics 为 null——无可定位到节点的诊断').toBeGreaterThan(0)
if (!locatedCount) { await s.close(); return }
const first = located.first()
const title = (await first.getAttribute('title')) ?? ''
await first.click()
await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toHaveAttribute('title', title)
const counted = receipt.locator('.drw-diag.sev-error button.drw-diag-node, .drw-diag.sev-warning button.drw-diag-node')
if (await counted.count()) {
const t = (await counted.first().getAttribute('title')) ?? ''
await expect(page.locator(`.drw-canvas-node[title="${t.replace(/"/g, '\\"')}"] .drw-badge`).first()).toBeVisible()
}
await shot(page, 'item4-diagnostic-located-canvas')
await page.getByRole('tab', { name: '阅读视图' }).click()
const section = page.locator('section.drw-reader-node.sel')
await expect(section.locator('.drw-reader-title')).toHaveText(title)
await expect(section.locator('a.drw-anchor')).toHaveAttribute('href', `#${await section.getAttribute('id')}`)
await shot(page, 'item4-diagnostic-located-reader')
await expect(deliveryOf(page, '预览')).toBeVisible({ timeout: 15_000 })
await s.close()
})
test('§11-5a 编制人提交审阅', async ({ browser }) => {
items(5)
const s = await session(browser, U.author, 'item5a-author')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const submit = page.getByRole('button', { name: '提交审阅', exact: true })
await expect(submit).toBeVisible({ timeout: 15_000 })
await shot(page, 'item5a-submit-state', panel(page, '预览与提交'))
const uiSubmit = await submit.isEnabled()
note(`浏览器「提交审阅」可用:${uiSubmit};原因:${await submit.getAttribute('title')}`)
expect.soft(uiSubmit,
'GAP[RDM] 受控文档的专用模板规则只能经 rdm.render.validate 到 validated,产品无发布路径(company.* 为保留前缀,candidate.publish 不绑定作用域),rdm.render.submit 要求 published → 浏览器无法提交审阅').toBe(true)
let submissionId = ''
let round = 0
if (uiSubmit) {
await submit.click()
await expect(panel(page, '预览与提交').getByText('提交审阅已完成')).toBeVisible({ timeout: 60_000 })
} else {
apiAssisted(`rdm.render.submit 以编制人会话显式指定已发布的公司通用规则 ${GENERAL.id}@${GENERAL.version}`)
const r = await command(page, 'rdm.render.submit', {
projectId: A.pid, docId: A.doc, operationId: `rba-submit-${RUN}`, profileId: GENERAL.id, profileVersion: GENERAL.version,
})
note(`API 提交回执:${r.text.slice(0, 300)}`)
expect.soft(r.body?.receipt?.state,
`BLOCKER[RDM] rdm.render.submit 被拒(${r.body?.receipt?.reason ?? r.status}):trustedRenderActor → LookupAccountDirectoryOwner(purpose=document-duties) 需要 RDM 注册 AccountDirectoryScopeResolver,而 RDM d63f1db 从未调用 RegisterAccountDirectoryScopeResolver,Backend 恒返回 ErrAccountForbidden;submit/annotate/revise 在真实产品中全部不可用`).toBe('committed')
}
const status = await command(page, 'rdm.review.doc.status', { projectId: A.pid, docId: A.doc })
submissionId = status.body?.payload?.submission?.submissionId ?? ''
round = status.body?.payload?.submission?.round ?? 0
writeState({ submissionId, round })
expect(submissionId, '提交后 rdm.review.doc.status 返回提交编号').not.toBe('')
await page.reload()
await openAudit(page, A.name)
await openDoc(page, A.title)
await expect(panel(page, '审阅').locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 15_000 })
await shot(page, 'item5a-submitted')
await s.close()
})
test('§11-5b 审核人添加批注与修订', async ({ browser }) => {
items(5)
const { submissionId } = readState()
const s = await session(browser, U.reviewer, 'item5b-reviewer')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const review = panel(page, '审阅')
if (!submissionId) {
await expect(review.getByText('暂无审阅提交')).toBeVisible({ timeout: 20_000 })
await shot(page, 'item5b-reviewer-no-submission')
expect(submissionId, 'BLOCKER[RDM] 无渲染审阅提交(见 §11-5a),审核人无法添加批注与修订').not.toBe('')
}
await expect(review.locator('.drw-mono'), '审核人在新会话中看到编制人的提交').toHaveAttribute('title', submissionId, { timeout: 20_000 })
await page.locator('.drw-canvas-node[title="项目内容、目标及意义"]').first().click()
await expect(review.locator('.drw-composer-node')).toHaveText('项目内容、目标及意义')
await review.getByLabel('批注内容').fill(ANNOTATION)
await review.getByRole('button', { name: '添加批注' }).click()
await expect(review.locator('.drw-anno-text', { hasText: ANNOTATION })).toBeVisible({ timeout: 30_000 })
await review.getByLabel('审阅动作').selectOption('revise')
await review.getByLabel('修订类型').selectOption('replace')
await review.getByLabel('修订前').fill('项目目标')
await review.getByLabel('修订后').fill(REVISION_AFTER)
await review.getByRole('button', { name: '提交修订' }).click()
await expect(review.locator('.drw-diff-add', { hasText: REVISION_AFTER })).toBeVisible({ timeout: 30_000 })
await expect(review.locator('.drw-anno-group .drw-anno-node').first()).toHaveText('项目内容、目标及意义')
await expect(page.locator('.drw-canvas-node[title="项目内容、目标及意义"] .drw-badge.review').first()).toBeVisible()
// 范围批注(06 §2):阅读视图选中节点正文「目标」(码点 5..7),载荷带偏移与摘录,不带 textHash/range。
await page.getByRole('tab', { name: '阅读视图' }).click()
const nodeText = page.locator('section.drw-reader-node.sel [data-anchor-node]')
await expect(nodeText).toHaveText('项目内容、目标及意义')
await nodeText.evaluate(el => {
const range = document.createRange()
range.setStart(el.firstChild!, 5)
range.setEnd(el.firstChild!, 7)
const sel = window.getSelection()!
sel.removeAllRanges()
sel.addRange(range)
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
})
await expect(review.locator('.drw-composer-quote')).toHaveText('「目标」')
await review.getByLabel('批注内容').fill(RANGE_ANNOTATION)
await review.getByRole('button', { name: '添加批注' }).click()
const annotateCalls = () => s.net.filter(n => n.url.includes('/api/commands/rdm.render.review.annotate') && parseJson(n.request)?.params?.text === RANGE_ANNOTATION)
await expect.poll(() => annotateCalls().length, { message: '应分发范围批注', timeout: 30_000 }).toBeGreaterThan(0)
const rangeCall = annotateCalls().at(-1)!
const rangeParams = parseJson(rangeCall.request)?.params
expect(rangeParams, '范围批注载荷').toMatchObject({ textStart: 5, textEnd: 7, quote: '目标' })
expect(rangeParams).not.toHaveProperty('textHash')
expect(rangeParams).not.toHaveProperty('range')
note(`范围批注回执:${rangeCall.body.slice(0, 300)}`)
const rangeItem = review.locator('.drw-anno', { hasText: RANGE_ANNOTATION })
const anchored = await rangeItem.locator('.drw-anno-quote').waitFor({ timeout: 30_000 }).then(() => true, () => false)
if (anchored) await expect(rangeItem.locator('.drw-anno-quote')).toHaveText('目标')
expect.soft(anchored, `GAP[RDM] 范围批注未按所选文字保存(回执:${rangeCall.body.slice(0, 200)})`).toBe(true)
await page.getByRole('tab', { name: '结构画布' }).click()
await expectReadableFailures(page)
await shot(page, 'item5b-review-annotations')
await s.close()
})
test('§11-5c 审核通过与批准(签署)', async ({ browser }) => {
items(5)
const { submissionId } = readState()
const r = await session(browser, U.reviewer, 'item5c-reviewer')
await openAudit(r.page, A.name)
await openDoc(r.page, A.title)
const reviewPanel = panel(r.page, '审阅')
if (!submissionId) {
await expect(reviewPanel.getByText('暂无审阅提交')).toBeVisible({ timeout: 20_000 })
expect.soft(await r.page.getByRole('button', { name: /审核通过|驳回|批准|签署|定稿/ }).count(),
'GAP[Frontend] 文档工作区没有审核通过/驳回/批准入口(rdm.review.doc.approve/final 需会话来源锚点,只能经对话代理)').toBeGreaterThan(0)
await shot(r.page, 'item5c-reviewer-no-submission', reviewPanel)
expect(submissionId, 'BLOCKER[RDM] 无渲染审阅提交(见 §11-5a),审核与批准签署无从进行').not.toBe('')
}
await expect(reviewPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
// 整文审签只走工作区入口:审核人待审时必须出现「审核通过」;成功与可读失败都必须可见。
const decisionPanel = panel(r.page, '整文审签')
const uiApprove = decisionPanel.getByRole('button', { name: '审核通过', exact: true })
await expect(uiApprove, 'GAP[Frontend] 审核人在待审状态下工作区没有「审核通过」入口').toBeVisible({ timeout: 20_000 })
await uiApprove.click()
const done = decisionPanel.getByText('整文审核通过已完成')
const failed = decisionPanel.locator('.drw-failure-msg')
await expect(done.or(failed).first()).toBeVisible({ timeout: 60_000 })
expect.soft(await done.count(), `审核通过应成功:${(await failed.allTextContents()).join(' | ')}`).toBe(1)
await expectReadableFailures(r.page)
await expectCommittedDecision(r, 'rdm.review.doc.approve', '审核通过')
await shot(r.page, 'item5c-reviewer-approved', decisionPanel)
await r.close()
const a = await session(browser, U.approver, 'item5c-approver')
await openAudit(a.page, A.name)
await openDoc(a.page, A.title)
const approverPanel = panel(a.page, '审阅')
await expect(approverPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
// 终批入口只在批准人且全员已审核通过时出现;载荷绑定界面所示轮次。
const finalPanel = panel(a.page, '整文审签')
const uiFinal = finalPanel.getByRole('button', { name: '终批', exact: true })
await expect(uiFinal, 'GAP[Frontend] 批准人在全员审核通过后工作区没有「终批」入口').toBeVisible({ timeout: 20_000 })
await uiFinal.click()
const finalDone = finalPanel.getByText('整文终批已完成')
const finalFailed = finalPanel.locator('.drw-failure-msg')
await expect(finalDone.or(finalFailed).first()).toBeVisible({ timeout: 60_000 })
expect.soft(await finalDone.count(), `终批应成功:${(await finalFailed.allTextContents()).join(' | ')}`).toBe(1)
await expectReadableFailures(a.page)
await expectCommittedDecision(a, 'rdm.review.doc.final', '终批')
await a.page.reload()
await openAudit(a.page, A.name)
await openDoc(a.page, A.title)
const decisions = panel(a.page, '审阅').locator('.drw-decision')
await expect(decisions.first()).toBeVisible({ timeout: 20_000 })
const decisionTexts = await decisions.allTextContents()
note(`签署记录:${decisionTexts.join(' | ')}`)
expect(decisionTexts.length, '审阅面板显示审核与批准两条签署').toBeGreaterThanOrEqual(2)
await shot(a.page, 'item5c-decisions', panel(a.page, '审阅'))
await a.close()
})
test('§11-6 下载 Markdown、批准稿、审阅留痕稿与完整包', async ({ browser }) => {
items(6)
const { submissionId } = readState()
if (!submissionId) {
// 无渲染提交:只能取证编制人预览交付的 Markdown/DOCX/清单下载,批准稿与留痕稿记为阻断
const s = await session(browser, U.author, 'item6-author-preview')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const preview = deliveryOf(page, '预览')
await expect(preview).toBeVisible({ timeout: 20_000 })
const md = await download(page, preview, 'Markdown')
expect(md.name).toMatch(/\.md$/)
const docx = await download(page, preview, '预览 DOCX')
expect(fs.readFileSync(docx.file).subarray(0, 2).toString()).toBe('PK')
const manifest = await download(page, preview, '清单(manifest)')
note(`预览交付下载:${[md, docx, manifest].map(f => `${f.name}(${f.size} B)`).join(';')}`)
writeState({ downloads: [md, docx, manifest].map(f => f.name) })
await shot(page, 'item6-preview-downloads', panel(page, '交付记录'))
expect.soft(await deliveryOf(page, '批准').count(), 'BLOCKER[RDM] 无渲染提交 → 无批准交付,批准稿不可下载').toBeGreaterThan(0)
expect.soft(await page.getByRole('button', { name: '审阅留痕 DOCX', exact: true }).count(), 'BLOCKER[RDM] 无渲染提交 → 无审阅交付,审阅留痕稿不可下载').toBeGreaterThan(0)
const pkg = await download(page, preview, '完整交付包(ZIP)')
expect(pkg.name).toMatch(/\.zip$/)
expect(fs.readFileSync(pkg.file).subarray(0, 2).toString()).toBe('PK')
await s.close()
return
}
const s = await session(browser, U.approver, 'item6-approver')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const reviewPanel = panel(page, '审阅')
await expect(reviewPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
const ok = panel(page, '预览与提交')
await reviewPanel.getByRole('button', { name: '生成批准稿' }).click()
await expect(ok.getByText('生成批准稿已完成').or(ok.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 120_000 })
await expectReadableFailures(page)
await expect(ok.locator('.drw-op .drw-failure'), '生成批准稿不应失败').toHaveCount(0)
await reviewPanel.getByRole('button', { name: '生成留痕稿' }).click()
await expect(ok.getByText('生成留痕稿已完成').or(ok.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 120_000 })
await expect(ok.locator('.drw-op .drw-failure'), '生成留痕稿不应失败').toHaveCount(0)
const approved = deliveryOf(page, '批准')
const reviewed = deliveryOf(page, '审阅')
await expect(approved).toBeVisible({ timeout: 20_000 })
await expect(reviewed).toBeVisible({ timeout: 20_000 })
await shot(page, 'item6-deliveries', panel(page, '交付记录'))
const kinds = { approved: await approved.locator('.drw-download').allTextContents(), review: await reviewed.locator('.drw-download').allTextContents() }
note(`批准交付可下载:${kinds.approved.join('、')};审阅交付可下载:${kinds.review.join('、')}`)
writeState({ deliveryKinds: kinds })
const md = await download(page, approved, 'Markdown')
expect(md.name).toMatch(/\.md$/)
expect(fs.readFileSync(md.file, 'utf8').length).toBeGreaterThan(50)
const docx = await download(page, approved, '批准稿 DOCX')
expect(docx.name).toMatch(/\.docx$/)
expect(fs.readFileSync(docx.file).subarray(0, 2).toString()).toBe('PK')
const traceHolder = (await reviewed.getByRole('button', { name: '审阅留痕 DOCX', exact: true }).count()) ? reviewed : approved
const trace = await download(page, traceHolder, '审阅留痕 DOCX')
expect(trace.name).toMatch(/\.docx$/)
expect(fs.readFileSync(trace.file).subarray(0, 2).toString()).toBe('PK')
const manifest = await download(page, approved, '清单(manifest)')
note(`下载:${[md, docx, trace, manifest].map(f => `${f.name}(${f.size} B)`).join(';')}`)
// 长文件名:显示截断,title 保留完整文件名(§11-9 一并取证)
const lastName = panel(page, '交付记录').locator('.drw-filename').first()
await expect(lastName).toHaveAttribute('title', manifest.name)
const shown = (await lastName.textContent()) ?? ''
note(`文件名显示「${shown}」,完整「${manifest.name}」`)
if (manifest.name.length > 36) expect(shown).toContain('…')
await shot(page, 'item6-downloaded-longname', panel(page, '交付记录'))
writeState({ downloads: [md, docx, trace, manifest].map(f => f.name) })
// 完整包:rdm.render.download kind=package,服务端确定性 ZIP;与单件下载同一鉴权路径。
const pkg = await download(page, approved, '完整交付包(ZIP)')
expect(pkg.name).toMatch(/\.zip$/)
expect(fs.readFileSync(pkg.file).subarray(0, 2).toString()).toBe('PK')
note(`完整交付包:${pkg.name}(${pkg.size} B)`)
writeState({ downloads: [md, docx, trace, manifest, pkg].map(f => f.name) })
await s.close()
})
test('§11-7 追溯网显示来源、规则、修订、签署与交付物', async ({ browser }) => {
items(7)
const s = await session(browser, U.admin, 'item7-admin')
const { page } = s
await openAudit(page, A.name)
await page.getByRole('tab', { name: '关联追溯' }).click()
const pick = page.locator('.rdm-audit-pick button', { hasText: /^项目内容、目标及意义$/ }).first()
await expect(pick).toBeVisible({ timeout: 60_000 })
await pick.click()
await expect(page.locator('.rdm-audit-trace-head')).toBeVisible()
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 20_000 })
await shot(page, 'item7-trace-section')
await Promise.all(s.pending)
const relationBodies = s.net.filter(n => n.url.includes('rdm.inspect.relations')).map(n => n.body).join('\n')
const text = (await page.locator('.rdm-audit-body').textContent()) ?? ''
note(`关联追溯页面文本(截断):${text.slice(0, 300)}`)
const has = (re: RegExp) => re.test(text) || re.test(relationBodies)
expect.soft(has(/source|来源依据([1-9]/), 'GAP[RDM] 追溯网未给出该章节的来源依据(rdm.inspect.relations 只含通用网/合规边)').toBe(true)
expect.soft(has(/profile|渲染规则|org\.general|company\.dhf/), 'GAP[RDM] 追溯网不含渲染规则(profile)节点或边').toBe(true)
expect.soft(has(/revision|修订/), 'GAP[RDM] 追溯网不含审阅修订').toBe(true)
expect.soft(has(/decision|approve|final|签署|批准/), 'GAP[RDM] 追溯网不含审核/批准签署').toBe(true)
expect.soft(has(/delivery|交付|deliveryId/), 'GAP[RDM] 追溯网不含渲染交付物(preview/review/approved delivery)').toBe(true)
// 补充取证:操作记录是否留下渲染与签署事件(不计入追溯网判定)
await page.getByRole('button', { name: '← 返回' }).click()
await page.getByRole('tab', { name: '操作记录' }).click()
await page.waitForTimeout(2_000)
await shot(page, 'item7-timeline')
const timeline = (await page.locator('.rdm-audit-body').textContent()) ?? ''
note(`操作记录含 render:${/render/.test(timeline)};含 review:${/review/.test(timeline)}`)
await s.close()
})
test('§11-8 无权用户的页面与网络响应不含受限内容', async ({ browser }) => {
items(8)
const st = readState()
const s = await session(browser, U.outsider, 'item8-outsider')
const { page } = s
await clickAuditEntry(page)
await expect(page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 })
const options = await page.locator('.rdm-audit-project option').allTextContents()
note(`无权用户项目列表:${options.join('、')}`)
expect(options).not.toContain(A.name)
expect(options).toContain(B.name)
await page.getByRole('tab', { name: '资料与文档' }).click()
await expect(page.locator('.rdm-audit-tree-row').first()).toBeVisible({ timeout: 15_000 })
await page.getByRole('tab', { name: '关联追溯' }).click()
await page.waitForTimeout(1_500)
await page.getByRole('tab', { name: '操作记录' }).click()
await page.waitForTimeout(1_500)
await shot(page, 'item8-outsider-audit')
const pageText = (await page.locator('body').textContent()) ?? ''
for (const m of [A.name, A.title, UNCTRL.title, ANNOTATION, REVISION_AFTER]) expect(pageText, `页面不得出现受限内容「${m}」`).not.toContain(m)
const passiveCount = s.net.length
// 主动探测:以无权用户会话直调 A 的读取与下载命令(请求中自带的 id 不算泄露)
const probes: [string, Record<string, unknown>][] = [
['rdm.inspect.summary', { projectId: A.pid }],
['rdm.inspect.tree', { projectId: A.pid, kind: 'docs' }],
['rdm.inspect.tree', { projectId: A.pid, kind: 'docs', parent: A.doc }],
['rdm.inspect.relations', { projectId: A.pid, kind: 'doc', id: A.doc, depth: 1 }],
['rdm.render.profiles', { projectId: A.pid, docId: A.doc }],
['rdm.render.deliveries', { projectId: A.pid, docId: A.doc, limit: 20 }],
['rdm.review.doc.status', { projectId: A.pid, docId: A.doc }],
...(st.submissionId ? [['rdm.render.review.view', { projectId: A.pid, submissionId: st.submissionId }] as [string, Record<string, unknown>]] : []),
]
const probeResults: { id: string; status: number; state: string; hasPayload: boolean }[] = []
const probeBodies: { url: string; body: string }[] = []
for (const [id, params] of probes) {
const r = await command(page, id, params)
probeBodies.push({ url: `probe:${id}`, body: r.text })
const state = r.body?.receipt?.state ?? ''
const payload = r.body?.payload
const hasPayload = payload !== undefined && payload !== null && !(typeof payload === 'object' && Object.keys(payload).length === 0)
probeResults.push({ id, status: r.status, state, hasPayload })
expect.soft(r.status >= 400 || state === 'rejected', `${id} 对无权用户应被拒绝:HTTP ${r.status} ${r.text.slice(0, 200)}`).toBe(true)
expect.soft(hasPayload, `${id} 不得返回受限载荷`).toBe(false)
}
for (const kind of ['markdown', 'docx', 'manifest']) {
const r = await command(page, 'rdm.render.download', { projectId: A.pid, deliveryId: 'probe', kind })
probeBodies.push({ url: `probe:rdm.render.download:${kind}`, body: r.text })
probeResults.push({ id: `rdm.render.download:${kind}`, status: r.status, state: r.body?.receipt?.state ?? '', hasPayload: !!r.body?.payload })
expect.soft(!r.body?.payload?.dataBase64, 'rdm.render.download 不得返回文件内容').toBe(true)
}
const hostProjects = await page.request.get('/api/projects')
const hostText = await hostProjects.text()
expect.soft(hostText.includes(A.host), '宿主项目列表不得含项目 A').toBe(false)
const hostConv = await page.request.get(`/api/projects/${A.host}/conversations`)
const hostConvText = await hostConv.text()
probeBodies.push({ url: '/api/projects', body: hostText }, { url: `/api/projects/${A.host}/conversations`, body: hostConvText })
note(`宿主 A 会话列表 HTTP ${hostConv.status()}`)
expect.soft(hostConv.status() >= 400 || !hostConvText.includes('§11'), '宿主 A 会话不得可见').toBe(true)
note(`主动探测:${JSON.stringify(probeResults)}`)
await Promise.all(s.pending)
// 全部网络响应体扫描:内容标记任何响应都不得出现;A 的标识只在被动浏览响应中检查
const contentMarkers = [A.name, A.title, UNCTRL.title, ANNOTATION, REVISION_AFTER, ...(st.downloads ?? [])]
const idMarkers = [A.pid, A.host, st.submissionId].filter(Boolean)
const leaks: string[] = []
s.net.forEach((n, i) => {
for (const m of contentMarkers) if (m && n.body.includes(m)) leaks.push(`${n.url} 含「${m}」`)
if (i < passiveCount) for (const m of idMarkers) if (n.body.includes(m)) leaks.push(`${n.url} 含标识 ${m}`)
})
for (const p of probeBodies) for (const m of contentMarkers) if (m && p.body.includes(m)) leaks.push(`${p.url} 含「${m}」`)
const scanned = path.join(EVID, 'network', 'item8-scan.json')
fs.mkdirSync(path.dirname(scanned), { recursive: true })
fs.writeFileSync(scanned, JSON.stringify({ pageResponses: s.net.length, passive: passiveCount, probeResponses: probeBodies.length, contentMarkers, idMarkers, leaks, probeResults }, null, 2))
evidence.push(scanned)
expect(leaks, `受限内容泄露:${leaks.join(';')}`).toEqual([])
await s.close()
// 反向:A 的审核人不是 B 成员,看不到 B
const r = await session(browser, U.reviewer, 'item8-reviewer-vs-B')
await clickAuditEntry(r.page)
await expect(r.page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 })
expect(await r.page.locator('.rdm-audit-project option').allTextContents()).not.toContain(B.name)
const bProbe = await command(r.page, 'rdm.render.profiles', { projectId: B.pid, docId: B.doc })
expect.soft(bProbe.status >= 400 || bProbe.body?.receipt?.state === 'rejected', `审核人读 B 应被拒绝:${bProbe.text.slice(0, 200)}`).toBe(true)
await r.close()
})
test('§11-9 浅色/深色/窄屏/长文档/长文件名可用', async ({ browser }) => {
items(9)
for (const theme of ['light', 'dark'] as const) {
for (const [w, h, tag] of [[1440, 900, 'wide'], [390, 844, 'narrow']] as const) {
const s = await session(browser, U.author, `item9-${theme}-${tag}`, { theme, width: w, height: h })
const { page } = s
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe(theme)
await openAudit(page, A.name)
await openDoc(page, A.title)
await expect(panel(page, '渲染规则').locator('.drw-profile').first()).toBeVisible({ timeout: 15_000 })
const overflow = await page.locator('.drw').evaluate(el => el.scrollWidth - el.clientWidth)
note(`${theme}/${tag}:.drw 横向溢出 ${overflow}px`)
expect.soft(overflow, `${theme}/${tag} 工作区不得横向溢出`).toBeLessThanOrEqual(1)
const title = page.locator('.drw-title')
await expect(title).toHaveAttribute('title', A.title)
const clipped = await title.evaluate(el => el.scrollWidth > el.clientWidth)
note(`${theme}/${tag}:${A.title.length} 字长标题截断显示 ${clipped}`)
expect(clipped, '长标题在页头截断显示(完整标题保留在 title)').toBe(true)
expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis')
const bodyOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect.soft(bodyOverflow, `${theme}/${tag} 页面不得横向滚动`).toBeLessThanOrEqual(1)
await shot(page, `item9-${theme}-${tag}-canvas`)
if (tag === 'wide') {
const delivery = page.locator('.drw-delivery').first()
await expect(delivery, '长文件名取证需要至少一条交付记录').toBeVisible({ timeout: 15_000 })
const file = await download(page, delivery, 'Markdown')
const name = panel(page, '交付记录').locator('.drw-filename').first()
await expect(name).toHaveAttribute('title', file.name)
const shown = (await name.textContent()) ?? ''
note(`${theme}:下载文件名「${file.name}」(${file.name.length} 字)显示为「${shown}」——RDM 交付文件名恒为 <docId>.<ext>,产品不产生长文件名;长名称以 ${A.title.length} 字文档标题检验`)
expect(shown.length).toBeLessThanOrEqual(36)
await shot(page, `item9-${theme}-filename`, panel(page, '交付记录'))
}
await page.getByRole('tab', { name: '阅读视图' }).click()
const sections = await page.locator('section.drw-reader-node').count()
expect(sections, '长文档阅读视图完整列出节点').toBeGreaterThan(10)
await page.locator('section.drw-reader-node').last().scrollIntoViewIfNeeded()
await expect(page.locator('section.drw-reader-node').last()).toBeInViewport()
await shot(page, `item9-${theme}-${tag}-reader-end`)
await page.locator('.drw-side').scrollIntoViewIfNeeded()
await shot(page, `item9-${theme}-${tag}-side`, page.locator('.drw-side'))
await page.getByRole('button', { name: '← 返回文档树' }).click()
await expect(page.getByRole('button', { name: `阅读与交付:${A.title}`, exact: true })).toBeVisible()
await s.close()
}
}
})
+623
View File
@@ -0,0 +1,623 @@
// IAM U4 阶段 7 确定性浏览器切片(DR6–DR10):真实 React/Picker 交互 +
// 完全受控的 HTTP 应答,不依赖任何账号或数据库。活体隔离产品层由
// Agent-System-RDM scripts/iam-u4-role-acceptance.sh 以真实 Backend/RDM
// 授权路径复用同一场景(live 模式不拦截路由)。
import { test, expect, type Page, type Route } 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 }
}
interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; systemActions?: string[] }
interface Perm { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
interface RolePerm { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
interface Role { roleId: string; name: string; description: string; enabled: boolean; roleVersion: number; bindingCount: number; permissions: RolePerm[] }
interface Account {
userId: string; owner: string; profile: { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
profileVersion: number; enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
initializationState: string; initializationGeneration: number
}
const FULL_ACTIONS = ['system.users.view', 'system.users.create', 'system.users.edit', 'system.users.disable',
'system.roles.view', 'system.roles.manage', 'system.roles.assign', 'system.permissions.view', 'system.permissions.manage']
function perm(actionId: string, label: string, group: string, scope: 'system' | 'project', extra: Partial<Perm> = {}): Perm {
return { actionId, label, description: `${label}说明`, group, scope, protected: false, ...extra }
}
const CATALOG: Perm[] = [
perm('project.create', '创建项目', '项目入口', 'system'),
perm('project.member.manage', '管理项目成员', '项目入口', 'project'),
perm('project.override.set', '设置项目覆盖', '项目入口', 'project'),
perm('document.read', '读取文档', '文档动作', 'project'),
perm('document.author', '编制文档', '文档动作', 'project'),
perm('document.approve', '批准文档', '文档动作', 'project'),
perm('export.create', '创建导出', '平台动作', 'project'),
perm('audit.read', '读取审计', '平台动作', 'project'),
perm('system.users.manage', '管理用户账号', '系统动作', 'system', { protected: true }),
]
function emptyProfile(name = '') {
return { name, phone: '', email: '', staffNo: '', department: '', notes: '' }
}
function account(userId: string, owner: string, profile = emptyProfile(), overrides: Partial<Account> = {}): Account {
return { userId, owner, profile, profileVersion: 1, enabled: true, stateVersion: 3, isAdmin: false, passwordSet: true, initializationState: 'done', initializationGeneration: 0, ...overrides }
}
function rp(actionId: string, allowed = false, capAllowed = false): RolePerm {
return { actionId, allowed, capAllowed, permissionVersion: 1 }
}
/** 可变角色/绑定状态:一个 fixture 贯穿用例内全部请求,支持版本化 409。 */
function world(me: Me) {
const roles: Role[] = [
{ roleId: 'role_eng', name: '软件工程师', description: '研发职位', enabled: true, roleVersion: 3, bindingCount: 2, permissions: [rp('project.create', true, true), rp('document.read', true, true)] },
{ roleId: 'role_ops', name: '运维工程师', description: '运维职位', enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] },
{ roleId: 'role_review', name: '审核工程师', description: '质量职位', enabled: false, roleVersion: 2, bindingCount: 1, permissions: [rp('document.read', true, true), rp('document.approve', false, true)] },
]
const bindings: Record<string, string> = { u1: 'role_eng', u3: 'role_eng' }
const users: Account[] = [
account('u1', 'alice', { ...emptyProfile('张三'), phone: '13800000001', staffNo: 'A001' }),
account('u2', 'bob'),
account('u3', 'carol', { ...emptyProfile('李四'), email: 'carol@example.com' }),
]
const grants: Record<string, { actions: string[]; authorityEpoch: number }> = {}
const calls: { method: string; path: string; query: Record<string, string>; body?: Record<string, unknown> }[] = []
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
const failNext = new Map<string, number>()
let grantEpoch = 5
const PAGE = 2
const visible = (q: URLSearchParams): Role[] => roles.filter(role => {
const status = q.get('status') ?? 'all'
if (status === 'enabled' && !role.enabled) return false
if (status === 'disabled' && role.enabled) return false
const action = q.get('action') ?? ''
if (action && !role.permissions.some(item => item.actionId === action && item.allowed)) return false
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
if (!query) return true
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
return (q.get('mode') ?? 'fuzzy') === 'exact' ? fields.some(value => value === query) : fields.some(value => value.includes(query))
})
const recount = () => {
for (const role of roles) role.bindingCount = Object.values(bindings).filter(roleId => roleId === role.roleId).length
}
const notFound = () => ({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'not found' }) })
const deny = (route: Route, status: number) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ error: `code:${status}` }) })
const ok = (route: Route, payload: unknown) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(payload) })
return {
roles, bindings, users, grants, calls,
hold(key: string) {
const pending = { started: deferred(), release: deferred() }
holds.set(key, pending)
return pending
},
failNextTimes(path: string, times: number) { failNext.set(path, times) },
async route(route: Route) {
const request = route.request()
const url = new URL(request.url())
const path = decodeURIComponent(url.pathname)
const method = request.method()
const raw = request.postData()
calls.push({ method, path, query: Object.fromEntries(url.searchParams), body: raw ? JSON.parse(raw) as Record<string, unknown> : undefined })
const hold = holds.get(`${method} ${path}`)
if (hold) { holds.delete(`${method} ${path}`); hold.started.resolve(); await hold.release.promise }
const fails = failNext.get(path) ?? 0
if (fails > 0) { failNext.set(path, fails - 1); return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'boom' }) }) }
if (path === '/api/health') return ok(route, { ok: true })
if (path === '/api/me') return ok(route, me)
if (path === '/api/projects') return ok(route, [])
if (path === '/api/settings') return ok(route, { schema: [], defaults: {}, user: {}, project: {} })
if (path === '/api/commands') return ok(route, [])
if (path === '/api/admin-zone/sections') return ok(route, [])
if (path.endsWith('/cards')) return ok(route, { cards: [] })
if (path === '/api/admin/permissions/catalog') return ok(route, { permissions: CATALOG })
if (path === '/api/admin/roles' && method === 'GET') {
const list = visible(url.searchParams)
const start = Number(url.searchParams.get('cursor') ?? 0) || 0
const page = list.slice(start, start + PAGE)
return ok(route, { roles: page, nextCursor: start + PAGE < list.length ? String(start + PAGE) : '' })
}
if (path === '/api/admin/roles' && method === 'POST') {
const body = request.postDataJSON() as { name: string; description: string }
const role: Role = { roleId: `role_${body.name}`, name: body.name, description: body.description, enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] }
roles.unshift(role)
return ok(route, role)
}
const roleMatch = path.match(/^\/api\/admin\/roles\/([^/]+)(\/permissions)?$/)
if (roleMatch) {
const role = roles.find(item => item.roleId === roleMatch[1])
if (!role) return route.fulfill(notFound())
if (!roleMatch[2]) {
if (method === 'GET') return ok(route, role)
const body = request.postDataJSON() as { expectedRoleVersion: number; name?: string; description?: string; enabled?: boolean }
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
if (body.name !== undefined) role.name = body.name
if (body.description !== undefined) role.description = body.description
if (body.enabled !== undefined) role.enabled = body.enabled
role.roleVersion += 1
return ok(route, role)
}
if (method === 'PUT') {
const body = request.postDataJSON() as { expectedRoleVersion: number; changes: { actionId: string; allowed: boolean; capAllowed: boolean }[] }
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
role.permissions = body.changes.map(change => ({ ...change, permissionVersion: 1 }))
role.roleVersion += 1
return ok(route, { receiptId: 'r1', roleVersion: role.roleVersion, authorityEpoch: 6 })
}
}
if (path === '/api/admin/users/search' && method === 'GET') {
const q = url.searchParams
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
const fields = (q.get('fields') ?? 'owner,name,userId').split(',')
const status = q.get('status') ?? 'all'
const list = users.filter(item => {
if (status === 'enabled' && !item.enabled) return false
if (status === 'disabled' && item.enabled) return false
if (!query) return true
const values = (fields.includes('default') ? ['owner', 'name', 'userId'] : fields)
.map(field => field === 'userId' ? item.userId : field === 'owner' ? item.owner : item.profile[field as 'name'] ?? '')
return (q.get('mode') ?? 'fuzzy') === 'exact' ? values.some(value => value.toLowerCase() === query) : values.some(value => value.toLowerCase().includes(query))
})
return ok(route, { accounts: list, nextCursor: '', directoryVersion: 'd1' })
}
if (path === '/api/admin/users' && method === 'POST') {
const body = request.postDataJSON() as { owner: string; profile: Account['profile'] }
if (users.some(item => item.owner === body.owner)) return deny(route, 409)
const created = account(`u-${body.owner}`, body.owner, { ...emptyProfile(), ...body.profile })
users.push(created)
return ok(route, created)
}
const userMatch = path.match(/^\/api\/admin\/users\/([^/]+)(\/role|\/profile|\/state|\/system-grants)?$/)
if (userMatch) {
const user = users.find(item => item.userId === userMatch[1])
if (!user) return route.fulfill(notFound())
const sub = userMatch[2] ?? ''
if (sub === '' && method === 'GET') return ok(route, user)
if (sub === '/profile' && method === 'PATCH') {
const body = request.postDataJSON() as { expectedProfileVersion: number; patch: Record<string, string> }
if (body.expectedProfileVersion !== user.profileVersion) return deny(route, 409)
user.profile = { ...user.profile, ...body.patch }
user.profileVersion += 1
return ok(route, user)
}
if (sub === '/state' && method === 'PUT') {
const body = request.postDataJSON() as { expectedStateVersion: number; enabled: boolean }
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
user.enabled = body.enabled
user.stateVersion += 1
return ok(route, user)
}
if (sub === '/role' && method === 'GET') {
const role = roles.find(item => item.roleId === bindings[user.userId])
return ok(route, role ? {
binding: { bindingId: `b-${user.userId}`, userId: user.userId, roleId: role.roleId, active: true, bindingVersion: 1, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch },
role,
} : {})
}
if (sub === '/role' && method === 'PUT') {
const body = request.postDataJSON() as { roleId: string; expectedStateVersion: number }
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
bindings[user.userId] = body.roleId
recount()
return ok(route, { bindingId: `b-${user.userId}`, userId: user.userId, roleId: body.roleId, active: true, bindingVersion: 2, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch })
}
if (sub === '/system-grants' && method === 'GET') {
return ok(route, grants[user.userId] ?? { actions: [], authorityEpoch: grantEpoch })
}
if (sub === '/system-grants' && method === 'PUT') {
const body = request.postDataJSON() as { actions: string[]; expectedAuthorityEpoch: number }
if (body.expectedAuthorityEpoch !== (grants[user.userId]?.authorityEpoch ?? grantEpoch)) return deny(route, 409)
const hadManage = (grants[user.userId]?.actions ?? []).includes('system.permissions.manage')
if (hadManage && !body.actions.includes('system.permissions.manage')) {
return route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: 'cannot remove the final permission manager' }) })
}
grants[user.userId] = { actions: body.actions, authorityEpoch: ++grantEpoch }
return ok(route, { receiptId: 'g1', authorityEpoch: grantEpoch })
}
}
// 未点名端点(profiles/plugins/conversations/workspaces 等启动探测)按空列表兜底。
return route.fulfill({ contentType: 'application/json', body: '[]' })
},
}
}
type World = ReturnType<typeof world>
async function openAdmin(page: Page, me: Me): Promise<World> {
const w = world(me)
await page.route('**/api/**', route => { void w.route(route) })
await page.goto('/')
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
return w
}
const openAdminAsAdmin = (page: Page) => openAdmin(page, { owner: 'root', name: '管理员', auth: true, isAdmin: true, systemActions: [...FULL_ACTIONS] })
async function openRolesSection(page: Page) {
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
// 面板加载带 180ms 防抖,等待首笔角色清单返回后再交互。
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '搜索与过滤' }).click()
}
async function openUsersSection(page: Page) {
await page.getByRole('button', { name: '用户管理', exact: true }).click()
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
}
const engRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '软件工程师' })
const reviewRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '审核工程师' })
test('DR10 细粒度管理员:仅用户查看权时看不到角色分区,也不发出角色请求', async ({ page }) => {
const w = await openAdmin(page, { owner: 'mgr', name: '用户管理员', auth: true, isAdmin: false, systemActions: ['system.users.view'] })
await expect(page.getByRole('button', { name: '用户管理', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '角色与权限', exact: true })).toHaveCount(0)
await openUsersSection(page)
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByText(/当前角色 无角色查看权限/)).toBeVisible()
await expect(page.getByLabel('用户生效角色')).toHaveCount(0)
expect(w.calls.filter(call => call.path.endsWith('/role'))).toEqual([])
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
})
test('DR10 权限管理者无查看权:出现守护提示且不拉取目录', async ({ page }) => {
const w = await openAdmin(page, { owner: 'mgr', name: '权限管理者', auth: true, isAdmin: false, systemActions: ['system.users.view', 'system.permissions.manage'] })
await openUsersSection(page)
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByText('需要同时具备系统权限查看权,才能安全编辑现有授权')).toBeVisible()
await expect(page.getByRole('button', { name: '明确系统权限' })).toHaveCount(0)
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
})
test('DR6 创建带描述的角色,后续修改保持稳定 roleId', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await page.getByLabel('新角色名称').fill('数据工程师')
await page.getByLabel('新角色描述').fill('数据管道职位')
await page.getByRole('button', { name: '创建角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师')
await page.getByLabel('角色描述', { exact: true }).fill('数据管道与质量职位')
await page.getByRole('button', { name: '保存资料', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色资料已保存')
expect(w.calls.filter(call => call.method === 'POST' && call.path === '/api/admin/roles')).toHaveLength(1)
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path.startsWith('/api/admin/roles/'))
expect(patches).toHaveLength(1)
expect(patches[0].path).toBe('/api/admin/roles/role_数据工程师')
})
test('DR6 停用角色需显式确认;取消不发出任何请求', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByRole('button', { name: '停用角色', exact: true }).click()
const dialog = page.getByRole('alertdialog', { name: '确认停用角色 软件工程师' })
await expect(dialog).toContainText('软件工程师')
await expect(dialog).toContainText('2 个用户绑定')
await expect(dialog).toContainText('不再授予权限')
await dialog.getByRole('button', { name: '取消', exact: true }).click()
await expect(dialog).toHaveCount(0)
expect(w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')).toEqual([])
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限')
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
expect(patches).toHaveLength(1)
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3, enabled: false })
})
test('DR6 停用版本冲突:刷新最新状态并保留重试上下文', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
// 并发方先推进版本,首次确认停用必然 409。
w.roles.find(role => role.roleId === 'role_eng')!.roleVersion = 4
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('角色状态版本冲突')
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用')
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
expect(patches).toHaveLength(2)
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3 })
expect(patches[1].body).toMatchObject({ expectedRoleVersion: 4 })
expect(w.calls.some(call => call.method === 'GET' && call.path === '/api/admin/roles/role_eng')).toBe(true)
})
test('DR9 过滤、翻页与视图切换按 roleId 保留草稿且永不串角色', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
await expect(page.getByText('变更预览(1)')).toBeVisible()
// 切到第二页的审核工程师:不带走软件工程师的草稿。
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await expect(page.getByText('变更预览(0)')).toBeVisible()
await expect(page.getByLabel('编制文档 默认允许')).not.toBeChecked()
// 切回软件工程师:草稿原样保留。
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
await engRow(page).click()
await expect(page.getByText('变更预览(1)')).toBeVisible()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
// 状态过滤触发重载:草稿仍在。
await page.getByLabel('角色状态').selectOption('disabled')
await expect(reviewRow(page)).toBeVisible()
await page.getByLabel('角色状态').selectOption('all')
await engRow(page).click()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
// 单角色 ↔ 矩阵切换保留;矩阵中当前角色的勾选状态一致。
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
await expect(page.getByLabel('软件工程师 编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
expect(w.calls.filter(call => call.path === '/api/admin/roles/role_ops')).toEqual([])
})
test('DR9 离开管理区须显式决定;确认离开后草稿被放弃', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
const dialogs: string[] = []
let leave = false
page.on('dialog', async dialog => {
dialogs.push(dialog.message())
if (dialog.type() === 'confirm') leave ? await dialog.accept() : await dialog.dismiss()
else await dialog.accept()
})
await page.getByRole('button', { name: '返回工作台' }).click()
expect(dialogs).toHaveLength(1)
expect(dialogs[0]).toContain('软件工程师')
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
leave = true
await page.getByRole('button', { name: '返回工作台' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toHaveCount(0)
// 重新进入后草稿不复存在(离开即放弃)。
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await openRolesSection(page)
await engRow(page).click()
await expect(page.getByText('变更预览(0)')).toBeVisible()
})
test('DR9 409 只重放同一角色草稿;另一角色草稿不受影响', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await page.getByLabel('批准文档 默认允许').check()
// 切回软件工程师再保存:并发方已推进版本并改变服务端基线,保存触发 409 rebase。
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
await engRow(page).click()
const eng = w.roles.find(role => role.roleId === 'role_eng')!
eng.roleVersion = 4
eng.permissions = [rp('project.create', false, false), rp('document.read', true, true)]
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('权限版本冲突;已基于最新版本重放本地草稿')
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/roles/role_review')).toEqual([])
// 重放后:本地对编制文档的修改保留;再次保存基于最新版本成功。
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色权限已保存')
const puts = w.calls.filter(call => call.method === 'PUT' && call.path === '/api/admin/roles/role_eng/permissions')
expect(puts).toHaveLength(2)
expect(puts[0].body).toMatchObject({ expectedRoleVersion: 3 })
expect(puts[1].body).toMatchObject({ expectedRoleVersion: 4 })
expect((puts[1].body!.changes as { actionId: string; allowed: boolean }[]).find(change => change.actionId === 'document.author')).toMatchObject({ allowed: true, capAllowed: true })
// 审核工程师的草稿未被动过:切回后仍在。
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await expect(page.getByLabel('批准文档 默认允许')).toBeChecked()
})
test('DR9 矩阵批量编辑只作用于当前可见页', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
await page.locator('.rp-matrix button', { hasText: '软件工程师' }).click()
await page.getByRole('button', { name: '当前页默认全选', exact: true }).click()
for (const label of ['创建项目', '管理项目成员', '设置项目覆盖', '读取文档', '编制文档', '批准文档']) {
await expect(page.getByLabel(`软件工程师 ${label} 默认允许`)).toBeChecked()
}
await page.getByRole('button', { name: '下一组', exact: true }).click()
await expect(page.getByLabel('软件工程师 创建导出 默认允许')).not.toBeChecked()
await page.getByRole('button', { name: '上一组', exact: true }).click()
await page.getByRole('button', { name: '当前页上限关闭', exact: true }).click()
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).not.toBeChecked()
// 变更预览在单角色视图:批量编辑按 roleId 保留,切回可见 2 项相对基线变化。
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
await expect(page.getByText('变更预览(2)')).toBeVisible()
})
test('DR10 角色清单失败不拖垮目录加载,可经检索重试恢复', async ({ page }) => {
const w = await openAdminAsAdmin(page)
w.failNextTimes('/api/admin/roles', 1)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('500')
// 目录请求独立发出并成功(互不拖垮)。
expect(w.calls.some(call => call.path === '/api/admin/permissions/catalog')).toBe(true)
await page.getByRole('button', { name: '搜索与过滤' }).click()
await page.getByLabel('搜索角色').fill('软件')
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('alert')).toHaveCount(0)
})
test('DR10 权限目录失败不影响角色列表呈现', async ({ page }) => {
const w = await openAdminAsAdmin(page)
w.failNextTimes('/api/admin/permissions/catalog', 1)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('权限目录加载失败')
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible()
expect(w.calls.some(call => call.path === '/api/admin/roles')).toBe(true)
})
test('DR10 键盘完成核心流程,保存后焦点稳定且状态可被播报', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).focus()
await page.keyboard.press('Enter')
await expect(page.getByLabel('角色名称', { exact: true })).toHaveValue('软件工程师')
await page.getByLabel('编制文档 默认允许').focus()
await page.keyboard.press('Space')
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '保存权限', exact: true }).focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('status')).toContainText('角色权限已保存')
const focused = await page.evaluate(() => (document.activeElement as HTMLElement | null)?.textContent ?? '')
expect(focused).toContain('保存权限')
})
test('DR10 窄屏矩阵横向可导航且可操作', async ({ page }) => {
await openAdminAsAdmin(page)
await page.setViewportSize({ width: 480, height: 800 })
await openRolesSection(page)
// 窄屏下视图切换按钮存在持续的布局微抖动,Playwright 稳定性检查无法通过;
// 用 DOM 事件切换视图,随后的横向滚动与勾选仍是真实交互。
await page.getByRole('button', { name: '角色矩阵', exact: true }).dispatchEvent('click')
const wrap = page.locator('.rp-matrix-wrap')
await expect(wrap).toBeVisible()
const scroll = await wrap.evaluate(el => ({ sw: el.scrollWidth, cw: el.clientWidth }))
expect(scroll.sw).toBeGreaterThan(scroll.cw)
await page.getByLabel('软件工程师 创建项目 默认允许').check()
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).toBeChecked()
})
test('DR7 两人共享同一角色;换绑后仅一个生效角色且计数随之变化', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openUsersSection(page)
const openUser = async (owner: string) => {
await page.locator('tr', { hasText: owner }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByRole('button', { name: '返回列表' })).toBeVisible()
}
await openUser('bob')
await page.getByLabel('用户生效角色').selectOption({ label: '软件工程师' })
await page.getByRole('button', { name: '替换角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('用户角色已替换')
expect(w.bindings.u2).toBe('role_eng')
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await openUser('alice')
await expect(page.getByText(/当前角色 软件工程师/)).toBeVisible()
await page.getByLabel('用户生效角色').selectOption({ label: '运维工程师' })
await page.getByRole('button', { name: '替换角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('用户角色已替换')
expect(w.bindings.u1).toBe('role_ops')
expect(w.roles.find(role => role.roleId === 'role_eng')!.bindingCount).toBe(2) // bob + carol
// 用户角色视图始终单角色:GET /role 只返回一个 binding。
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/u1/role').length).toBeGreaterThan(0)
})
test('DR8 资料可全空创建,并支持按电话等字段检索', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openUsersSection(page)
await page.getByLabel('新用户登录名').fill('dave')
await page.getByRole('button', { name: '创建用户', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建 dave')
const created = w.calls.find(call => call.method === 'POST' && call.path === '/api/admin/users')
expect(created!.body!.profile).toEqual(emptyProfile())
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await page.getByLabel('用户搜索字段').selectOption({ label: '电话' })
await page.getByLabel('搜索用户').fill('13800000001')
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
await expect(page.locator('tr', { hasText: 'carol' })).toHaveCount(0)
const searches = w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/search')
expect(searches.at(-1)!.query.fields).toContain('phone')
})
test('DR10 加载与空态:加载期间无结果无报错,过滤为空给出选择提示', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '搜索与过滤' }).click()
const pending = w.hold('GET /api/admin/roles')
await page.getByLabel('搜索角色').fill('不存在的角色')
await pending.started.promise
await expect(page.locator('.rp-role-list button').first()).toBeDisabled()
await expect(page.getByRole('alert')).toHaveCount(0)
pending.release.resolve()
await expect(page.locator('.rp-role-list button')).toHaveCount(0)
await expect(page.getByText('请选择角色')).toBeVisible()
})
// ── 活体层(IAM_E2E_LIVE=1):同一文件在真实 Backend/RDM 组合上运行真实授权路径。
// 由 Agent-System-RDM scripts/iam-u4-role-acceptance.sh 物化冻结候选源码、
// 构建内嵌产品、全新 SQLite 数据根与随机回环端口后驱动;不拦截任何路由。
const LIVE = process.env.IAM_E2E_LIVE === '1'
async function liveLogin(page: Page) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(process.env.IAM_E2E_OWNER ?? '')
await page.getByPlaceholder('初始密码或常用密码').fill(process.env.IAM_E2E_PASSWORD ?? '')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
}
async function openAdminZoneLive(page: Page) {
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
}
test('LIVE 真实登录加载真实权限目录,创建、配置并停用角色', async ({ page }) => {
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
test.setTimeout(90_000)
await liveLogin(page)
await openAdminZoneLive(page)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await page.getByRole('button', { name: '搜索与过滤' }).click()
await expect(page.getByLabel('新角色名称')).toBeVisible({ timeout: 15_000 })
// 真实空库无角色:先经真实 Backend 创建,选中后权限表才渲染真实目录。
await page.getByLabel('新角色名称').fill('数据工程师')
await page.getByLabel('新角色描述').fill('活体验收职位')
await page.getByRole('button', { name: '创建角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师', { timeout: 15_000 })
// 真实目录的首页可能全是受保护系统授权(复选框禁用):逐组翻到可编辑动作。
const firstDefault = page.locator('input[aria-label$="默认允许"]:not([disabled])').first()
for (let pageIdx = 0; (await firstDefault.count()) === 0 && pageIdx < 15; pageIdx++) {
await page.getByRole('button', { name: '下一组', exact: true }).click()
await page.waitForTimeout(150)
}
await expect(firstDefault).toBeVisible({ timeout: 15_000 })
await firstDefault.check()
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色权限已保存', { timeout: 15_000 })
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await expect(page.getByRole('alertdialog')).toContainText('数据工程师')
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限', { timeout: 15_000 })
})
test('LIVE DR13 非成员账号被拒读取项目业务内容;创建者放行', async ({ request }) => {
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
const base = process.env.PLAYWRIGHT_BASE_URL!
const projectId = process.env.IAM_E2E_PROJECT_ID
test.expect(projectId).toBeTruthy()
const memberList = async (owner: string, password: string) => {
await request.post(`${base}/api/auth/login`, { data: { owner, password } })
const res = await request.post(`${base}/api/commands/rdm.project.member.list`, {
data: { params: { projectId } },
})
const body = await res.json().catch(() => null)
return { ok: res.ok(), body }
}
const observer = await memberList(process.env.IAM_E2E_OBSERVER_OWNER ?? '', process.env.IAM_E2E_OBSERVER_PASSWORD ?? '')
const denied = !observer.ok || observer.body?.receipt?.state === 'rejected'
expect(denied, `observer 应被拒绝:${JSON.stringify(observer.body).slice(0, 240)}`).toBe(true)
const creator = await memberList(process.env.IAM_E2E_OWNER ?? '', process.env.IAM_E2E_PASSWORD ?? '')
const allowed = creator.ok && creator.body?.receipt?.state !== 'rejected'
expect(allowed, `创建者应放行:${JSON.stringify(creator.body).slice(0, 240)}`).toBe(true)
})
+21 -30
View File
@@ -1,7 +1,5 @@
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
// 新密码登录进工作台;重复兑换同一 Key 被拒。
// 管理员发放一次性登录密码与登录入口回归:管理员生成一次性密码
// → 用户密码登录 → 强制改密;登录页不再提供一次性 Key 登录 tab。
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
@@ -53,23 +51,21 @@ async function adminLogin(page: Page) {
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
}
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
test('管理员生成一次性登录密码:用户登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
test.setTimeout(120_000)
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
// 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。
await adminLogin(page)
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
const zone = page.getByRole('dialog', { name: '系统管理' })
await expect(zone).toBeVisible()
// 每个用户行都有同款按钮:限定目标用户所在行。
await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click()
await zone.getByRole('button', { name: '生成 Key', exact: true }).click()
const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]')
await expect(keyInput).toBeVisible({ timeout: 10_000 })
const key = await keyInput.inputValue()
expect(key).toMatch(/^alk_/)
await expect(zone.getByText(/单次有效/)).toBeVisible()
const row = zone.locator('tr', { hasText: USER })
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
const temporaryPassword = zone.locator('input[aria-label="一次性密码"]')
await expect(temporaryPassword).toBeVisible({ timeout: 10_000 })
const password = await temporaryPassword.inputValue()
expect(password.length).toBeGreaterThan(0)
// 2) 管理员退出系统管理与登录态,回到登录页。
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
@@ -78,35 +74,30 @@ test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
// 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
await page.getByPlaceholder('alk_…').fill(key)
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
// 4) 受限设密门:服务端确认用户名;无当前密码输入框。
// 3) 用户使用临时密码登录后进入强制改密门。
await passwordLogin(page, USER, password)
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)
await expect(gate.getByText(`账号 ${USER}`)).toBeVisible({ timeout: 10_000 })
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(1)
const pw = gate.locator('input[type="password"]')
await pw.nth(0).fill(USER_PW)
await pw.nth(0).fill(password)
await pw.nth(1).fill(USER_PW)
await pw.nth(2).fill(USER_PW)
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
// 4) 成功页:提示用新密码登录,唯一出口回登录页。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
// 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。
// 5) 新密码登录进工作台。
await passwordLogin(page, USER, USER_PW)
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
// 6) 登录页不再提供一次性 Key 登录入口。
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
await page.getByPlaceholder('alk_…').fill(key)
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('tab', { name: '一次性登录 Key' })).toHaveCount(0)
await expect(page.getByPlaceholder('alk_…')).toHaveCount(0)
})
+1 -1
View File
@@ -71,7 +71,7 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
await page.getByRole('tab', { name: '资料与文档' }).click()
await page.locator('.rdm-audit-tree-node').first().click()
const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node')
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: 'section' }) }).first()
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: '章节' }) }).first()
await expect(sectionNode).toBeVisible()
await sectionNode.click()
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
+182
View File
@@ -0,0 +1,182 @@
// I4 组合验收(Playwright):文档驾驶舱七场景(rollout §3.I4 最小场景集)。
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制(含 I1/I3 命令面)+ frontend dist,
// 种子:审计演示项目(rdm-self + cockpit-e2e 结构装载;两个章节经草稿流落正文;
// 一个章节留空;第二个账号 e2e-reader 不入项目做权限负例)。
// 环境契约与 rdm-audit.playwright.ts 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过。
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
const READER = process.env.E2E_READER || 'e2e-reader'
const READER_PASSWORD = process.env.E2E_READER_PASSWORD || 'E2eReader#2026'
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
async function login(page: Page, user: string, password: string) {
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
await page.getByPlaceholder('由管理员分配').fill(user)
await page.getByPlaceholder('初始密码或常用密码').fill(password)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
// 侧栏入口两种形态:常规侧栏按钮(.tsb-tool);窄宽紧凑布局换图标栏
// (.icon-only 内 title 以「文档驾驶舱」开头的按钮)——入口都可达。
function cockpitEntry(page: Page) {
return page.locator('.tsb-tool', { hasText: '文档驾驶舱' })
.or(page.locator('.icon-only button[title^="文档驾驶舱"]')).first()
}
async function loginOwner(page: Page) {
await login(page, OWNER, PASSWORD)
// 登录后身份纪元与能力探针仍可能各跳一次;应用在身份变更时会关闭已开
// 工作区——等落定再点入口,避免 overlay 被身份刷新关掉(竞态实测)。
await page.waitForLoadState('networkidle').catch(() => {})
await page.waitForTimeout(1_500)
}
async function openCockpit(page: Page) {
// 窄宽是单窗格布局:登录后默认「对话」窗格,先切到「上下文」窗格。
if (!(await cockpitEntry(page).isVisible().catch(() => false))) {
await page.getByRole('button', { name: '打开上下文' }).click()
}
await cockpitEntry(page).click()
await expect(page.locator('.cockpit-project')).toBeVisible({ timeout: 15_000 })
// 默认选中首个项目(种子=审计演示项目)。
await expect(page.locator('.cockpit-stats')).toBeVisible({ timeout: 15_000 })
}
async function openReaderOnDoc(page: Page, title: string) {
await page.locator('.cockpit-tree-title', { hasText: title }).first().click()
await page.getByRole('button', { name: '在阅读器中打开' }).click()
await expect(page.locator('.cockpit-reader')).toBeVisible()
}
test.describe.serial('I4 文档驾驶舱组合验收', () => {
// 场景 1:首次进入项目——统计带、结构树、全景可见(DV-R01/R02)。
test('1 首屏:统计、树、全景可见且统计可判读', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
// placeholder
await expect(page.locator('.cockpit-stat', { hasText: '完成度' })).toBeVisible()
await expect(page.locator('.cockpit-stat', { hasText: '文档' })).toBeVisible()
// 空 vs 不可算不得互相冒充:有数据项目的空缺计数显示数字。
await expect(page.locator('.cockpit-stat', { hasText: '空缺节点' }).locator('.cockpit-stat-num')).toHaveText(/^\d+$/)
await expect(page.locator('.cockpit-tree-title', { hasText: '验证主体报告' }).first()).toBeVisible()
await expect(page.getByText('文档概览')).toBeVisible()
await expect(page.locator('.cockpit-snap')).toContainText(/快照 wm:\d+/)
})
// 场景 2:文档→章节,全景与阅读器双向定位(DV-R03)。
test('2 定位:树选章节→阅读器正文;返回全景仍同一节点', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
// 展开目标文档(树按标题排序,branch 作用域防跨文档串扰),点「概述」。
const branch = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
await branch.locator('.cockpit-tree-caret').click()
const sec = branch.locator('.cockpit-tree-title', { hasText: '概述' }).first()
await expect(sec).toBeVisible()
sec.click()
await expect(page.locator('.cockpit-nodehead')).toBeVisible()
// 全景→阅读器:同一 nodeRef(阅读器显示该节标题与正文)。
await page.getByRole('button', { name: '在阅读器中打开' }).click()
await expect(page.locator('.cockpit-reader')).toBeVisible()
await expect(page.locator('.cockpit-nodetitle', { hasText: '概述' })).toBeVisible()
const body = page.locator('.cockpit-reader-para', { hasText: '本报告' }).first()
await expect(body).toBeVisible()
// 阅读器→全景:返回后仍是选中节点(节点头仍在)。
await page.getByRole('button', { name: '← 返回全景' }).click()
await expect(page.locator('.cockpit-nodehead')).toBeVisible()
})
// 场景 3:四面板展开并正确显示计数;受限与空态诚实(DV-R04)。
test('3 面板:来源/历史/影响/冲突计数与空态、截断旗标', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
const branch = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
await branch.locator('.cockpit-tree-caret').click()
await branch.locator('.cockpit-tree-title', { hasText: '概述' }).first().click()
const panel = page.locator('.cockpit-panel')
await expect(panel).toBeVisible()
// 来源:草稿落版本挂了依据切片(种子经 ingest 提供)。
await panel.getByRole('tab', { name: '内容来源' }).click()
await expect(panel.locator('.cockpit-panel-count')).toContainText(/来源 \d+ 条/)
// 历史:已提交事件(structure + draft 链)。
await panel.getByRole('tab', { name: '修改历史' }).click()
await expect(panel.locator('.cockpit-panel-count')).toContainText('已提交事件')
await expect(panel.locator('.cockpit-panel-row').first()).toBeVisible()
// 影响:一跳;空/计数二态都诚实。
await panel.getByRole('tab', { name: '影响对象' }).click()
await expect(panel.locator('.cockpit-panel-count')).toContainText(/影响 \d+ 项/)
// 冲突:种子无开放碰撞 → 明确「无开放冲突」而非空白。
await panel.getByRole('tab', { name: '冲突与风险' }).click()
await expect(panel.getByText('无开放冲突')).toBeVisible()
// 版本块(I3):当前版本+差异折叠入口可见。
await expect(page.locator('.cockpit-versions-toggle')).toBeVisible()
})
// 场景 4:后台变更后所有投影一致(DV-R05)——测试经 API 采纳新版本,
// 驾驶舱刷新后统计水位/树时间/详情版本块同步前进。
test('4 一致性:API 采纳新版本后,刷新入口与各投影同步', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
const branch4 = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
await branch4.locator('.cockpit-tree-caret').click()
await branch4.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
const snapBefore = await page.locator('.cockpit-snap').innerText()
// 通过应用自身 API 落一个已提交变更(需求账本新增条目,唯一键防幂等跳过;
// 采纳走高危确认卡、服务端不允许客户端伪造 Confirmed,故用低危提交面驱动)。
await page.request.post('/api/commands/rdm.reqs.import', {
data: { params: { projectId: await page.locator('.cockpit-project').inputValue(),
items: [{ kind: 'UR', title: `UR-PROBE-${Date.now()} 一致性探针`, statement: '刷新一致性驱动条目', source: 'e2e-probe' }] } },
failOnStatusCode: false,
})
await page.getByRole('button', { name: '刷新' }).click()
await expect(page.locator('.cockpit-snap')).not.toHaveText(snapBefore)
// 版本块前进:出现新版本条目。
await expect(page.locator('.cockpit-versions-toggle')).toContainText(/版本 v\d+/)
})
// 场景 5:断线/失败保留旧快照并给出重试(DV-R08)——page.route 掐断命令面。
test('5 失败:命令面断连时保留旧快照并出现重试入口', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
await expect(page.locator('.cockpit-stats')).toBeVisible()
// 掐断后续命令面:切换项目触发重取 → 失败横幅 + 旧内容保留。
await page.route('**/api/commands/rdm.cockpit.**', route => route.abort())
await page.locator('.cockpit-refresh').click()
await expect(page.locator('.cockpit-banner-err')).toBeVisible()
await expect(page.locator('.cockpit-stats')).toBeVisible() // 旧快照未被清空
await page.unroute('**/api/commands/rdm.cockpit.**')
await page.locator('.cockpit-refresh').click()
await expect(page.locator('.cockpit-banner-err')).toBeHidden()
})
// 场景 6:权限负例——非成员账号看不到项目(无项目诚实空态;不泄露存在性)。
test('6 权限:非成员账号无项目可见', async ({ page }) => {
await login(page, READER, READER_PASSWORD)
await page.locator('.tsb-tool', { hasText: '文档驾驶舱' }).click()
await expect(page.getByText('暂无可见研发项目')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('.cockpit-tree-title', { hasText: '验证主体报告' })).toHaveCount(0)
})
// 场景 7:窄屏、键盘、减少动效(DV-R07)。
test('7 可用性:390 宽无横向溢出、键盘可达、减少动效可用', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.setViewportSize({ width: 390, height: 844 })
await loginOwner(page)
await openCockpit(page)
// 无横向溢出。
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
// 键盘:统计卡/树行可聚焦(tab 序含驾驶舱交互件)。
await page.locator('.cockpit-tree-title').first().focus()
await expect(page.locator('.cockpit-tree-title').first()).toBeFocused()
// 18px 大字(html font-size 注入)不破坏统计带布局。
await page.addStyleTag({ content: 'html{font-size:18px !important}' })
await expect(page.locator('.cockpit-stats')).toBeVisible()
const overflowBig = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflowBig).toBeLessThanOrEqual(8)
})
})
+202
View File
@@ -0,0 +1,202 @@
import { test, expect } from '@playwright/test'
import { readFileSync, readlinkSync } from 'node:fs'
import { createHash } from 'node:crypto'
const manifestPath=process.env.WORK_CHECKPOINT_HTTP_MANIFEST
test.skip(!manifestPath,'Run explicitly with a task-owned mechanism instance manifest')
const manifest=manifestPath?JSON.parse(readFileSync(manifestPath,'utf8')):null
if(manifest&&(manifest.mode!=='mechanism'||!manifest.dataDir.includes('fast-slow-u2-u3-')||readlinkSync(`/proc/${manifest.pid}/exe`)!==manifest.binary||createHash('sha256').update(readFileSync(manifest.binary)).digest('hex')!==manifest.binarySHA256))throw new Error('Owned mechanism instance verification failed')
type Scenario='normal'|'fast-ack'|'queue-hold'|'slow-stages'|'zero-failure'|'partial-failure'|'choice'
async function setup(request:any,scenario:Scenario='normal',awaitTask=true,options:{text?:string,small?:boolean,startFailure?:boolean}={}){
const project=await(await request.post(manifest.base+'/api/projects',{data:{name:'browser-'+Date.now()}})).json()
const conv=await(await request.post(manifest.base+`/api/projects/${project.id}/conversations`,{data:{title:'材料整理',profileId:'checkpoint-eval'}})).json()
const ids:string[]=[]
for(let i=0;i<(options.small?1:3);i++){
const marker=i===0&&scenario!=='normal'?`[fixture:${scenario}]\n`:''
const res=await request.post(manifest.base+`/api/projects/${project.id}/uploads`,{multipart:{file:{name:`来源${i+1}.txt`,mimeType:'text/plain',buffer:Buffer.from(marker+'上线六月。验收条件需要补齐,预算50万元尚未审批。交付包含12项需求,其中3项依赖外部接口。第一期先完成8项核心需求。\n\n'+(options.small?'':('整理记录说明:保留日期、金额、限制和待确认事项。\n\n').repeat(options.text?250:100)))}}})
expect(res.ok()).toBeTruthy();ids.push((await res.json()).artifactId)
}
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:true}})).status()).toBe(204)
let sendBody:any
try{
const sent=await request.post(manifest.base+`/api/conversations/${conv.id}/messages`,{data:{text:options.text??'',attachmentIds:ids,requestId:'browser-'+Date.now()}})
expect(sent.ok()).toBeTruthy()
sendBody=await sent.json()
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${sendBody.run.id}`)).json()).run.status).toBe('completed')
}finally{
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:false}})).status()).toBe(204)
}
if(awaitTask)await expect.poll(async()=>{
const out=await(await request.get(manifest.base+`/api/conversations/${conv.id}/cards`)).json()
return out.cards.find((c:any)=>c.task)?.task.executionState
}).toBe(scenario.endsWith('failure')?'failed':'completed')
return {project,conv,ids,sendRunId:sendBody.run.id}
}
async function waitForFailedWorkCard(request:any,seed:any){
await expect.poll(async()=>{
const out=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()
const card=out.cards.find((c:any)=>c.task)
return card?.task.executionState==='failed'&&card.status==='failed'&&Boolean(card.work)
}).toBe(true)
}
async function expectOnlySendRun(request:any,seed:any){
const current=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
expect(current.runs.filter((r:any)=>r.kind==='conversation').map((r:any)=>r.id)).toEqual([seed.sendRunId])
}
async function open(page:any,seed:any){
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
await page.goto('/')
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
}
test('attachment-only intake gives a safe first reply before sourced findings',async({page,request})=>{
const seed=await setup(request,'fast-ack',false)
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
expect(cards.find((c:any)=>c.task).task.checkpointSeq??0).toBe(0)
await open(page,seed)
const reply=page.getByText(/已收到 3 份材料.*待确认/)
await expect(reply).toBeVisible()
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
await page.setViewportSize({width:390,height:844})
await expect(reply).toBeVisible()
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT.replace(/\.png$/, '-mobile.png'),fullPage:true})
await page.reload()
await expect(reply).toBeVisible()
})
test('text with long material keeps the user focus in the host reply',async({page,request})=>{
const seed=await setup(request,'fast-ack',false,{text:'重点核对预算审批'})
await open(page,seed)
await expect(page.getByText(/你提出的工作重点已带入整理任务.*待确认/)).toBeVisible()
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
})
test('running and completed resends reuse one material card',async({page,request})=>{
const seed=await setup(request,'slow-stages',false)
const resend=async(key:string)=>{
const response=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:seed.ids,requestId:key}})
expect(response.ok()).toBeTruthy()
const body=await response.json()
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${body.run.id}`)).json()).run.status).toBe('completed')
}
await resend('active-resend-'+Date.now())
await open(page,seed)
await expect(page.getByText(/这批材料仍在整理.*待确认/)).toBeVisible()
await expect.poll(async()=>{
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
return cards.find((c:any)=>c.task)?.task.executionState
}).toBe('completed')
await resend('completed-resend-'+Date.now())
await expect(page.getByText(/这批材料已整理完成.*待确认/)).toBeVisible()
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
})
test('text with a small attachment retains the ordinary reply path',async({page,request})=>{
const seed=await setup(request,'normal',true,{text:'请先解释材料要点',small:true})
await open(page,seed)
await expect(page.getByText('已收到材料,正在后台整理;请结合阶段发现继续工作。').first()).toBeVisible()
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
})
test('real HTTP task recovery, source navigation and changed-version limitation',async({page,request})=>{
const seed=await setup(request);const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message));await open(page,seed)
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
await expect(page.locator('[data-conversation-work]')).toContainText('材料未说明具体归属')
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
await page.getByRole('button',{name:'查看依据',exact:true}).first().click()
await page.getByRole('button',{name:/查看来源/}).first().click()
await expect(page.locator('.preview-pane')).toBeVisible();await expect(page.getByText('任务处理时引用的来源版本;下方是本项发现引用的片段')).toBeVisible()
const card=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.find((c:any)=>c.task)
const source=card.task.findings[0].sources[0]
const update=await request.put(manifest.base+`/api/artifacts/${source.artifactId}/content`,{data:'上线七月。文件内容已经更新。',headers:{'Content-Type':'text/plain'}});expect(update.ok()).toBeTruthy()
const old=await request.get(manifest.base+`/api/artifacts/${source.artifactId}/content?expectedHash=${source.contentHash}`);expect(old.status()).toBe(409)
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('依据文件已更新')
await expect(page.locator('[data-conversation-work] .acard-findings').first()).not.toContainText('上线六月')
expect(errors).toEqual([])
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT,fullPage:true})
})
test('background replay does not steal the current file, focus or viewport',async({page,request})=>{
const seed=await setup(request);await open(page,seed)
await page.getByRole('button',{name:'查看依据',exact:true}).first().click();await page.getByRole('button',{name:/查看来源/}).first().click();await expect(page.locator('.preview-pane')).toBeVisible()
const tabs=await page.locator('.tab-strip').textContent()
const composer=page.locator('textarea').last();await composer.fill('正在记录自己的想法');await composer.focus()
const focus=await composer.evaluate((el:any)=>document.activeElement===el);expect(focus).toBe(true)
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'重点核对预算',attachmentIds:seed.ids,requestId:'another-'+Date.now()}});expect(sent.ok()).toBeTruthy()
await expect.poll(async()=>{
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
return cards.filter((c:any)=>c.task?.executionState==='completed').length
}).toBe(2)
await expect(composer).toBeFocused();await expect(composer).toHaveValue('正在记录自己的想法')
expect(await page.locator('.tab-strip').textContent()).toBe(tabs)
})
test('two sourced stages appear while the material task is still running',async({page,request})=>{
const seed=await setup(request,'slow-stages',false)
await open(page,seed)
const cards=async()=>(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('running')
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState==='running'&&card.task.checkpointSeq>=2&&card.task.findings?.length>1}).toBe(true)
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('completed')
})
for(const scenario of ['zero-failure','partial-failure'] as const){
test(`${scenario} shows the saved scope without a deliverable choice`,async({page,request})=>{
const seed=await setup(request,scenario)
await waitForFailedWorkCard(request,seed)
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
await page.goto('/')
const work=page.locator('[data-conversation-work]')
await expect(work).toContainText('执行失败')
if(scenario==='zero-failure')await expect(work).toContainText('暂无阶段结论记录')
else await expect(work).toContainText('上线六月')
await expect(page.locator('.work-choice')).toHaveCount(0)
await expect(page.locator('.hitl-choice')).toHaveCount(0)
await expectOnlySendRun(request,seed)
})
}
test('completed sourced work offers only its grounded suggestions',async({page,request})=>{
const seed=await setup(request,'choice')
await open(page,seed)
const work=page.locator('[data-conversation-work]')
await expect(work.locator('.work-choice')).toHaveCount(3)
for(const label of ['会议摘要','验收清单','联调计划'])await expect(work.locator('.work-choice').filter({hasText:label})).toHaveCount(1)
await expect(work.locator('.work-decision legend')).toContainText('接下来需要做什么?')
await page.reload()
await expect(work.locator('.work-choice')).toHaveCount(3)
})
test('failed material registration reports no started task or unsupported finding',async({page,request})=>{
const seed=await setup(request,'normal',false,{startFailure:true})
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
await page.goto('/')
await expect(page.getByText(/后台整理未能启动/).first()).toBeVisible()
const conversation=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
expect(conversation.messages.some((m:any)=>m.role==='assistant'&&(m.parts??[]).some((p:any)=>p.type==='text'&&p.text?.includes('未能启动')))).toBe(true)
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(0)
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
})
test('saturated material slots show queued status without claiming execution',async({page,request})=>{
const seed=await setup(request,'queue-hold',false)
const sendAnother=async(label:string,hold:boolean)=>{
const uploaded=await request.post(manifest.base+`/api/projects/${seed.project.id}/uploads`,{
multipart:{file:{name:`${label}.txt`,mimeType:'text/plain',buffer:Buffer.from((hold?'[fixture:queue-hold]\n':'')+'上线六月。验收条件需要补齐。'+label)}}
})
expect(uploaded.ok()).toBeTruthy()
const artifactId=(await uploaded.json()).artifactId
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:[artifactId],requestId:'queue-'+label}})
expect(sent.ok()).toBeTruthy()
const runId=(await sent.json()).run.id
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${runId}`)).json()).run.status).toBe('completed')
}
await sendAnother('占用二',true)
await sendAnother('占用三',true)
await sendAnother('等待四',false)
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
await page.goto('/')
await expect(page.locator('[data-conversation-work]')).toContainText('排队中')
await expect(page.getByText(/整理任务正在排队.*待确认/).first()).toBeVisible()
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.filter((c:any)=>c.task)
expect(cards).toHaveLength(4)
expect(cards.filter((c:any)=>c.task.executionState==='queued')).toHaveLength(1)
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
})
+1 -1
View File
@@ -1,3 +1,3 @@
{
"exports": 43
"exports": 58
}
+40
View File
@@ -0,0 +1,40 @@
# 通用规范采纳记录
- 项目/负责人:Agent-System-Frontend / weichen
- 状态:active
- 规范位置:`deps/development-guidelines`
- 规范性文档目录:`policy/`(项目规则与制度放此处;入口为 `policy/README.md`)
- 过程文档位置:任务卡在 `.task/`(如 `.task/feature-governance-adoption.md`);任务主记录为对应 PR 描述
- 版本权威:本仓 gitlink `deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`;可读标签:无(候选 `0.1.0-draft.12`,尚未发布)
- 批准/生效依据:`feature/governance-adoption PR(Agent-System-Frontend)`
- 替代范围和迁移说明:无替代既有采纳记录。遗留:`policy/README.md` 与 `PROJECT.md` 的采纳基线行仍写有旧路径 `development/guidelines`(指向 Dev 仓),本次未改,后续单独修正为本仓 gitlink。
| 规范/工作流 | 适用条件与范围 | 采纳或不适用理由 | 执行方式/证据 |
|---|---|---|---|
| core/baseline.md | 日常工作 | 采纳:所有任务的硬边界、授权与完成声明基线 | manual |
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 采纳:本仓日常交付、Bug 排障与会话交接均按此执行,任务卡与 PR 为记录载体 | manual |
| standards/collaboration.md | 并行共同起点、两级分支、节点同步与陈旧候选拒绝 | 采纳:存在 feature 分支并行开发,合入前需同步最新目标分支 | manual;有真实平台负例再登记 enforced |
| standards/merge-queue.md | 多个就绪任务的最终同步、CI、Review 与合入调度 | 不适用:单 owner、单仓串行合入,无共享 CI 预算或多任务合入窗口 | 不适用 |
| standards/agent-execution.md | 工程 Agent CLI 执行路由、会话隔离与受控集成 | 采纳:允许 AI 协助实施,配置见 AGENT-EXECUTION.md | AGENT-EXECUTION.md + scripts/agent-exec(validate / admit);证据:governance check + 正负例(`.task/feature-governance-adoption.md`) |
| standards/verification.md | 验证与结论 | 采纳:功能与修复的验收须附实际命令输出 | manual;本地命令见 PROJECT.md「验证」节(CI 仅运行治理检查) |
| standards/module-contracts.md | 模块边界、公开接口与契约测试 | 采纳:`src/` 模块与 Core 契约形状测试(vitest)遵循该契约 | manual |
| workflows/design-verification.md | 功能增量的设计与验收对应 | 采纳:新功能与跨模块变更须写明验收与验证范围 | manual |
| standards/testability.md | 模块测试、mock 边界与可独立验证的设计 | 采纳:单元与契约测试依赖保持最小,真实适配器单独验证 | manual |
| standards/failure-containment.md | 故障影响控制、运行配额与故障注入 | 不适用:本仓为前端 SPA(构建产物由后端内嵌),无自有服务进程、队列或持久化,故障影响控制由后端仓承担 | 不适用 |
## 项目差异
| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 |
|---|---|---|---|
| 无 | 无 | 无 | 无 |
默认做法差异说明理由;硬边界冲突须有明确有效决策,不能用此表自授权。建议不采用无需登记。
## 本次升级或首次接入验证
- 入口:`AGENTS.md`(治理入口,合并 Guidelines 短入口与本仓「Agent 执行与新项目准入」)、`AGENT-EXECUTION.md`、`PROJECT.md`、本文件;相对链接指向 `deps/development-guidelines/...`,目标文件已在固定 SHA 中核对存在。
- 占位符:本文件与 `AGENTS.md` 中的模板占位符已全部替换。
- 版本获取:`governance init --adopt`(dm-server bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)以固定 SHA 检出 `deps/development-guidelines`(2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb)与 `deps/development-manager`(bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)。
- 冲突检查:既有 `AGENTS.md`、`PROJECT.md`、`AGENT-EXECUTION.md` 原样保留;本仓无其他采纳记录(如 `policy/adoption.md`),无需引用。
- 正负例与 Review/CI 证据:见 `.task/feature-governance-adoption.md`「结果与验证」节。
- 回退:删除 `.gitmodules` 中两个治理条目及 `deps/` gitlink,并移除本次生成的治理脚本与 `.gitea/workflows/governance.yml`;`AGENTS.md`、`PROJECT.md` 恢复到采纳前版本。
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# 共享 Agent 执行器薄入口:转调 scripts/dm 的 agent-exec 子命令(validate / run / apply / admit / cleanup)。
set -euo pipefail
exec "$(dirname "$0")/dm" agent-exec "$@"
Executable
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# 共享 dm-server 薄入口:读取 deps/development-manager 的 gitlink 钉住提交,
# 按该提交构建并缓存 dm-server,再 exec 其子命令(agent-exec、task、governance)。执行语义与退出码以该二进制为准。
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
dm="$root/deps/development-manager"
die() { echo "dm: $*" >&2; exit 2; }
# git 钩子会导出仓库本地变量(GIT_DIR、GIT_INDEX_FILE 等),它们会让 `git -C "$dm"` 解析到外层仓库。
# 所有针对 DM 检出的 git 与 go 调用都在清除这些变量后执行;读取外层 gitlink 与最终 exec 仍保留继承环境。
dm_local_env="GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_GRAFT_FILE GIT_NAMESPACE GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT GIT_SHALLOW_FILE GIT_IMPLICIT_WORK_TREE $(git rev-parse --local-env-vars 2>/dev/null || true)"
dm_clean_env() { for v in $dm_local_env; do unset "$v"; done; }
dmgit() ( dm_clean_env; git -C "$dm" "$@" )
# 1. 钉住版本只来自索引中的 gitlink(mode 160000),不读取工作区文件。
entry=$(git -C "$root" ls-files -s -- deps/development-manager 2>/dev/null || true)
[ -n "$entry" ] || die "索引中缺少 deps/development-manager 的 gitlink,无法确定钉住的 DM 版本"
read -r mode pin _stage _path <<<"$entry"
[ "$mode" = 160000 ] || die "deps/development-manager 不是 gitlink(mode=$mode)"
[[ "$pin" =~ ^[0-9a-f]{40}$ ]] || die "gitlink 提交格式无效:$pin"
# 2. 检出必须存在且确为 DM 独立仓库(避免 git -C 误解析到外层仓库),HEAD 必须等于钉住提交。
[ -e "$dm/.git" ] || die "deps/development-manager 未检出;请按钉住提交 $pin 检出该 gitlink"
top=$(dmgit rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$top" ] && [ "$top" = "$(cd "$dm" && pwd -P)" ] \
|| die "deps/development-manager 不是独立检出;请按钉住提交 $pin 检出该 gitlink"
head=$(dmgit rev-parse HEAD 2>/dev/null || true)
[ "$head" = "$pin" ] \
|| die "deps/development-manager HEAD(${head:-未知})与钉住提交 $pin 不一致;请按钉住提交检出"
# 3. 检出工作树必须干净(忽略嵌套 submodule 状态)。
dirty=$(dmgit status --porcelain --ignore-submodules=all) \
|| die "无法读取 deps/development-manager 的工作树状态"
[ -z "$dirty" ] || die "deps/development-manager 存在未提交改动,拒绝运行(请先清理该检出)"
# 4. 二进制按钉住提交缓存;缺失时在缓存目录内临时构建,完成后原子改名,避免留下半成品。
cache="${DM_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/development-manager/dm-server}"
bin="$cache/dm-server-$pin"
if [ ! -x "$bin" ]; then
command -v go >/dev/null 2>&1 || die "缺少 go,无法构建 dm-server($pin)"
mkdir -p "$cache"
cache=$(cd "$cache" && pwd -P)
bin="$cache/dm-server-$pin"
work=$(mktemp -d "$cache/build-$pin.XXXXXX")
if ! (dm_clean_env; cd "$dm" && GOWORK=off go build -trimpath -o "$work/dm-server" ./cmd/dm-server); then
rm -rf "$work"
die "构建 dm-server 失败($pin)"
fi
mv -f "$work/dm-server" "$bin"
rm -rf "$work"
fi
exec "$bin" "$@"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。
branch=$(git rev-parse --abbrev-ref HEAD)
case "$branch" in
main|master)
echo "pre-commit: 拒绝——集成分支 ${branch} 不得直接提交,走 feature 分支 + PR(collaboration.md)" >&2
exit 1 ;;
esac
echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/<slug>,当前:${branch}" >&2
exit 1
}
root=$(git rev-parse --show-toplevel)
# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子,
# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。
"$root/scripts/agent-exec" admit --repo "$root"
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# 通用任务入口:转调 scripts/dm 的 task 子命令(triage / start / pr-check)。
set -euo pipefail
exec "$(dirname "$0")/dm" task "$@"
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { loadSignedInWorkspace } from './App'
import { store } from './store'
afterEach(() => { vi.restoreAllMocks() })
function stubLoad() {
const order: string[] = []
vi.spyOn(store, 'refreshMe').mockImplementation(async () => { order.push('me') })
vi.spyOn(store, 'refreshProfiles').mockImplementation(async () => {})
vi.spyOn(store, 'refreshPlugins').mockImplementation(async () => {})
vi.spyOn(store, 'refreshProjects').mockImplementation(async () => { order.push('projects') })
vi.spyOn(store, 'startProjectSync').mockImplementation(() => { order.push('sync') })
return order
}
describe('AutoDiscover ownership after sign-in', () => {
it('checks projects immediately on sign-in, then hands re-checks to the single sync owner', async () => {
const order = stubLoad()
await loadSignedInWorkspace({ current: true })
expect(order).toEqual(['me', 'projects', 'sync'])
expect(store.startProjectSync).toHaveBeenCalledTimes(1)
})
it('does not start a sync owner for a page that was torn down during loading', async () => {
const order = stubLoad()
const active = { current: true }
vi.mocked(store.refreshProjects).mockImplementation(async () => { order.push('projects'); active.current = false })
await loadSignedInWorkspace(active)
expect(store.startProjectSync).not.toHaveBeenCalled()
})
})
+13 -8
View File
@@ -25,6 +25,16 @@ const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
const RIGHT_MIN = 280, RIGHT_MAX = 560
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
/** 放行后的首轮装载:首次项目列表就是登录即时核对;其后由唯一的自动发现所有者接管重查。 */
export async function loadSignedInWorkspace(active: { current: boolean }) {
await store.refreshMe()
if (!active.current) return
void store.refreshProfiles()
void store.refreshPlugins()
await store.refreshProjects()
if (active.current) store.startProjectSync()
}
export function App() {
const ui = useStore(s => s.ui)
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
@@ -114,14 +124,9 @@ export function App() {
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
let active = true
store.refreshMe().then(() => {
if (!active) return
void store.refreshProjects()
void store.refreshProfiles()
void store.refreshPlugins()
})
return () => { active = false }
const active = { current: true }
void loadSignedInWorkspace(active)
return () => { active.current = false; store.stopProjectSync() }
}, [authState])
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
+38
View File
@@ -0,0 +1,38 @@
// 账号管理动作呈现门控(超级管理员 R3/R4/R8):只决定 UI 暴露哪些入口,
// 服务端授权仍是唯一权威——隐藏的入口经直连 API 调用同样会被拒绝。
interface AccountIdentity { isAdmin?: boolean; isSuperAdmin?: boolean }
type AccountIdentityKind = 'super' | 'admin' | 'user'
export const accountIdentityKind = (account: AccountIdentity): AccountIdentityKind =>
account.isSuperAdmin ? 'super' : account.isAdmin ? 'admin' : 'user'
/** 创建管理员账号与任命/撤销管理员仅对超级管理员开放。 */
export const canAppointAdmins = (me: AccountIdentity | null | undefined) => !!me?.isSuperAdmin
interface AccountActionPolicy {
/** 资料、角色与系统权限维护。 */
maintain: boolean
toggleEnabled: boolean
/** 管理员重置临时密码与恢复提案。 */
resetPassword: boolean
adminChange: 'promote' | 'demote' | null
protectedReason: string
}
export function accountActionPolicy(me: AccountIdentity | null | undefined, target: AccountIdentity): AccountActionPolicy {
if (target.isSuperAdmin) {
const self = !!me?.isSuperAdmin
return {
maintain: self, toggleEnabled: false, resetPassword: false, adminChange: null,
protectedReason: self
? '超级管理员账号不能停用、降级或由管理员重置密码;本人密码请在「设置 → 账号」修改'
: '超级管理员账号受保护:普通管理员不能编辑、停用、降级或重置其密码',
}
}
return {
maintain: true, toggleEnabled: true, resetPassword: true,
adminChange: canAppointAdmins(me) ? (target.isAdmin ? 'demote' : 'promote') : null,
protectedReason: '',
}
}
+33
View File
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from './api'
afterEach(() => { vi.unstubAllGlobals() })
describe('AutoDiscover list request', () => {
it('passes the abort signal so an over-budget project list request is cancelled', async () => {
const seen: (RequestInit | undefined)[] = []
vi.stubGlobal('fetch', async (_path: string, init?: RequestInit) => {
seen.push(init)
return new Response('[]', { status: 200 })
})
const controller = new AbortController()
await api.listProjects(controller.signal)
await api.listProjects()
expect(seen[0]?.signal).toBe(controller.signal)
expect(seen[1]).toBeUndefined()
})
it('reads the linked creation capability from the identity response', async () => {
vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ owner: 'bob', name: 'Bob', auth: true,
linkedCapabilities: { projectCreate: true, source: 'system-grant', linkedActions: ['project.members.manage'], authorityEpoch: 4 } }), { status: 200 }))
const me = await api.me()
expect(me.linkedCapabilities).toMatchObject({ projectCreate: true, source: 'system-grant' })
})
it('returns the linked expansion from a role permission write receipt', async () => {
vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ receiptId: 'r1', roleVersion: 3, authorityEpoch: 9,
linkedExpansion: ['project.members.manage'] }), { status: 200 }))
const receipt = await api.replaceRolePermissions('role_pm', 2, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key')
expect(receipt.linkedExpansion).toEqual(['project.members.manage'])
})
})
+107 -21
View File
@@ -1,7 +1,7 @@
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
import { auth } from './auth'
import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement'
import { commandRequestBody, rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
@@ -14,6 +14,51 @@ const BASE = ''
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
export interface RolePermissionRecord { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
export interface RoleRecord {
roleId: string; name: string; description: string; enabled: boolean; roleVersion: number
bindingCount: number; permissions: RolePermissionRecord[]
}
interface RoleListPage { roles: RoleRecord[]; nextCursor?: string }
export interface UserRoleBinding {
bindingId: string; userId: string; roleId: string; active: boolean; bindingVersion: number
assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number
}
/** getUserRole 返回的角色摘要:实测不含 permissions,权限补齐由角色列表条目提供。 */
export interface UserRoleSummary extends Omit<RoleRecord, 'permissions'> { permissions?: RolePermissionRecord[] }
export interface UserRoleView { binding?: UserRoleBinding; role?: UserRoleSummary }
/** Backend 计算的本人 project.create 联动(/api/me linkedCapabilities)。 */
export interface LinkedCapabilities {
projectCreate: boolean; source?: string; linkedActions: string[]; conflicts?: string[]; authorityEpoch: number
}
/** 角色/系统授权写入回执:linkedExpansion 为服务端为 project.create 联动补齐的动作。 */
interface PolicyWriteReceipt { receiptId: string; authorityEpoch: number; linkedExpansion?: string[]; migrationNote?: string }
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
* current_required 必须校验当前密码。 */
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
export interface AccountRecord {
userId: string; owner: string; profile: AccountProfile; profileVersion: number
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; isSuperAdmin?: boolean; passwordSet?: boolean
initializationState?: string; initializationGeneration?: number; createdAt?: string
}
/** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */
export interface ManagedAccountRecord extends AccountRecord {
enabled: boolean; stateVersion: number; isAdmin: boolean; isSuperAdmin: boolean; passwordSet: boolean
initializationState: string; initializationGeneration: number
}
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
export interface AdminIssuedOneTimePassword {
owner: string
id: string
oneTimePassword: string
mustChangePassword: boolean
}
export interface ExecEnvView {
configured: boolean
@@ -39,8 +84,40 @@ async function json<T>(res: Response): Promise<T> {
}
export const api = {
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
permissionCatalog: () => auth.fetch(`${BASE}/api/admin/permissions/catalog`).then(r => json<{ permissions: PermissionDescriptor[] }>(r)).then(d => d.permissions ?? []),
listRoles: (request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; action?: string; cursor?: string; limit?: number } = {}) => {
const params = new URLSearchParams()
if (request.query) params.set('query', request.query)
if (request.mode) params.set('mode', request.mode)
if (request.status) params.set('status', request.status)
if (request.action) params.set('action', request.action)
if (request.cursor) params.set('cursor', request.cursor)
if (request.limit) params.set('limit', String(request.limit))
const query = params.toString()
return auth.fetch(`${BASE}/api/admin/roles${query ? `?${query}` : ''}`).then(r => json<RoleListPage>(r))
},
getRole: (roleId: string) => auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`).then(r => json<RoleRecord>(r)),
createRole: (name: string, description: string) => auth.fetch(`${BASE}/api/admin/roles`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description }),
}).then(r => json<RoleRecord>(r)),
patchRole: (roleId: string, expectedRoleVersion: number, patch: { name?: string; description?: string; enabled?: boolean }) =>
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(r)),
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<PolicyWriteReceipt & { roleVersion: number }>(r)),
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roleId, expectedStateVersion, idempotencyKey }) }).then(r => json<UserRoleBinding>(r)),
getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)),
replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<PolicyWriteReceipt>(r)),
explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)),
listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).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)),
createScratchSession: (title?: string) => auth.fetch(`${BASE}/api/scratch-sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(title ? { title } : {}) }).then(r => json<{ project: Project; conversation: Conversation }>(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' }).then(checked),
@@ -58,19 +135,11 @@ export const api = {
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }>(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()}`)
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
auth.beginSession()
return me
}),
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
auth.beginSession()
return me
}),
@@ -81,14 +150,29 @@ export const api = {
},
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; reloginRequired: boolean }>
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
}),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
// ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)----
adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)),
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
// isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。
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, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
// 服务端严格解码 JSON 对象体:无 Content-Type 与请求体的 POST 会被拒为 400 EOF。
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<AdminIssuedOneTimePassword>(r)),
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
if (request.cursor) params.set('cursor', request.cursor)
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
},
createManagedUser: (owner: string, profile: Partial<AccountProfile>, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile, ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<ManagedAccountRecord>(r)),
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<ManagedAccountRecord>(r)),
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<ManagedAccountRecord>(r)),
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
// 任命/撤销管理员:仅超级管理员(R3);超级管理员本身不可降级(R5)。
adminSetUserAdmin: (userId: string, expectedStateVersion: number, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, isAdmin }) }).then(r => json<ManagedAccountRecord>(r)),
// ---- 本人:资料(须完整密码会话)----
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
@@ -125,6 +209,8 @@ export const api = {
auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) })
.then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)),
taskSource: (cardId:string,sourceId:string) => auth.fetch(`${BASE}/api/cards/${encodeURIComponent(cardId)}/sources/${encodeURIComponent(sourceId)}`).then(r=>json<{source:import('./contracts').TaskSourceRef;versionState:string;artifact?:Artifact}>(r)),
listCardsSnapshot: (conversationId: string): Promise<{cards: ActivityCard[]; replaySeq?: number}> => auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{cards: ActivityCard[]; replaySeq?: number}>(r)),
listCards: (conversationId: string) =>
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) =>
@@ -199,10 +285,10 @@ export const api = {
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> =>
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}, source?: CommandSource): Promise<T> =>
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params }),
body: JSON.stringify(commandRequestBody(params, source)),
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
.then(out => {
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import type { PermissionDescriptor } from '../api'
import { GrantChangePreview, GrantGroupEditor, ProjectCreateSourceNote, toggleGrantGroup } from './AccountUsersPanel'
import { buildCapabilityGroups } from '../rolePermissions'
const descriptor = (actionId: string, label: string, group: string, scope: 'system' | 'project' = 'system'): PermissionDescriptor =>
({ actionId, label, description: '', group, scope, protected: scope === 'system' && actionId !== 'project.create' })
const catalog = [
descriptor('system.users.view', '查看用户', '用户管理'),
descriptor('system.users.create', '创建用户', '用户管理'),
descriptor('system.roles.view', '查看角色', '角色管理'),
descriptor('project.create', '创建项目', '项目入口'),
descriptor('project.members.manage', '管理项目成员', '项目管理', 'project'),
]
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
describe('CapabilityGroup explicit grants', () => {
it('grants system capabilities by group instead of one checkbox per action', () => {
const html = renderToStaticMarkup(<GrantGroupEditor catalog={catalog} grants={['system.users.view']} disabled={false} onChange={vi.fn()} />)
expect(html.match(/type="checkbox"/g)).toHaveLength(3)
expect(html).toContain('授予 用户管理')
expect(html).toContain('用户管理(部分)')
expect(html).toContain('联动本人创建项目的设置、成员、项目权限与文档职责管理')
expect(html).not.toContain('管理项目成员')
})
it('toggles every action of a group together', () => {
const users = buildCapabilityGroups(catalog.filter(item => item.scope === 'system')).find(group => group.id === 'catalog:用户管理')!
expect(toggleGrantGroup(['system.roles.view'], users, true)).toEqual(['system.roles.view', 'system.users.create', 'system.users.view'])
expect(toggleGrantGroup(['system.users.view', 'system.roles.view'], users, false)).toEqual(['system.roles.view'])
})
it('previews the grant difference with its source and the single affected user', () => {
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['project.create']} labels={labels} owner="bob" />)
expect(html).toContain('影响来源:明确系统授权 · 影响 1 名用户(bob)')
expect(html).toContain('新增:创建项目(联动本人创建项目的管理资格)')
expect(html).toContain('撤销:查看用户')
const revoke = renderToStaticMarkup(<GrantChangePreview baseline={['project.create']} grants={[]} labels={labels} owner="bob" />)
expect(revoke).toContain('已创建项目的管理资格不被反向删除')
})
})
describe('CreatorOwnerMemberDuties capability source', () => {
it('shows where an account gets project creation and what it links to', () => {
const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={role} grants={['project.create']} />))
.toContain('创建项目来源:职位角色「项目经理」 + 明确系统授权')
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={[]} />))
.toContain('被加入项目后由成员关系提供基础协作')
})
it('lists each project creation source separately when only one is present', () => {
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={{ name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }} grants={[]} />))
.toContain('创建项目来源:职位角色「项目经理」')
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={['project.create']} />))
.toContain('创建项目来源:明确系统授权')
})
})
describe('CapabilityGroup empty preview', () => {
it('renders a no-change preview when baseline equals grants', () => {
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['system.users.view']} labels={labels} owner="bob" />)
expect(html).toContain('系统授权没有变化')
})
it('still names the affected user when nothing visible changes', () => {
const html = renderToStaticMarkup(<GrantChangePreview baseline={[]} grants={[]} labels={labels} owner="bob" />)
expect(html).toContain('系统授权没有变化')
expect(html).not.toContain('影响来源')
})
})
+363
View File
@@ -0,0 +1,363 @@
import { useEffect, useRef, useState } from 'react'
import {
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView,
} from '../api'
import { useStore } from '../store'
import { Collapsible, Icon } from '@ui'
import { hasSystemAction } from './RolePermissionsPanel'
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
import {
PROJECT_CREATE_ACTION, buildCapabilityGroups, projectCreateSources, rebaseGrantDraft, type CapabilityGroup, type GroupLevel,
} from '../rolePermissions'
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
]
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
account.passwordSet ? '已设置密码' : '待设置密码'
function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) {
return <div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`${prefix}-${field.key}`}>{field.label}(可空)</label>
<input id={`${prefix}-${field.key}`} className="lg-input" value={profile[field.key]}
onChange={event => onChange({ ...profile, [field.key]: event.target.value })} />
</div>)}</div>
}
export function IdentityChip({ account }: { account: IdentityFlags }) {
const kind = accountIdentityKind(account)
if (kind === 'super') return <span className="ad-chip ad-chip-super">超级管理员</span>
if (kind === 'admin') return <span className="ad-chip ad-chip-admin">管理员</span>
return <span className="ad-chip">普通用户</span>
}
export function CreateAdminOption({ me, checked, disabled, onChange }: { me: IdentityFlags | null; checked: boolean; disabled: boolean; onChange: (value: boolean) => void }) {
if (!canAppointAdmins(me)) return <p className="ad-hint">仅超级管理员可创建或任命管理员;此处创建普通账号</p>
return <label className="ad-check"><input type="checkbox" checked={checked} disabled={disabled}
onChange={event => onChange(event.target.checked)} />创建为管理员账号</label>
}
export function AdminAppointmentButton({ me, account, busy, onChange }: { me: IdentityFlags | null; account: IdentityFlags; busy: boolean; onChange: (isAdmin: boolean) => void }) {
const change = accountActionPolicy(me, account).adminChange
if (!change) return null
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
}
function grantLevel(grants: string[], group: CapabilityGroup): GroupLevel {
const held = group.actions.filter(id => grants.includes(id)).length
return held === 0 ? 'off' : held === group.actions.length ? 'on' : 'mixed'
}
export function toggleGrantGroup(grants: string[], group: CapabilityGroup, checked: boolean): string[] {
return checked ? [...new Set([...grants, ...group.actions])].sort() : grants.filter(id => !group.actions.includes(id))
}
/** 明确系统授权按业务组授予;创建项目组说明联动形成的本人项目管理资格。 */
export function GrantGroupEditor({ catalog, grants, disabled, onChange }: {
catalog: PermissionDescriptor[]
grants: string[]
disabled: boolean
onChange: (grants: string[]) => void
}) {
const groups = buildCapabilityGroups(catalog.filter(item => item.scope === 'system'))
return <div className="rp-grants">{groups.map(group => {
const state = grantLevel(grants, group)
return <label className="ad-check" key={group.id} title={`内部动作 ${group.actions.join('、')}`}>
<input type="checkbox" aria-label={`授予 ${group.label}`} checked={state === 'on'} disabled={disabled}
onChange={event => onChange(toggleGrantGroup(grants, group, event.target.checked))} />
{group.label}{state === 'mixed' ? '(部分)' : ''}
{group.actions.includes(PROJECT_CREATE_ACTION) && <span className="ad-dim">:联动本人创建项目的设置、成员、项目权限与文档职责管理,不涉及他人项目</span>}
</label>
})}</div>
}
/** 授权差异预览:受影响来源为明确系统授权,影响人数为当前账号。 */
export function GrantChangePreview({ baseline, grants, labels, owner }: {
baseline: string[]; grants: string[]; labels: Record<string, string>; owner: string
}) {
const added = grants.filter(id => !baseline.includes(id))
const removed = baseline.filter(id => !grants.includes(id))
if (added.length === 0 && removed.length === 0) return <p className="ad-hint">系统授权没有变化</p>
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
return <div>
<p className="ad-hint" role="note">影响来源:明确系统授权 · 影响 1 名用户({owner})</p>
<ul className="rp-diff">
{added.length > 0 && <li>新增:{names(added)}{added.includes(PROJECT_CREATE_ACTION) ? '(联动本人创建项目的管理资格)' : ''}</li>}
{removed.length > 0 && <li>撤销:{names(removed)}{removed.includes(PROJECT_CREATE_ACTION) ? '(仅停止未来创建,已创建项目的管理资格不被反向删除)' : ''}</li>}
</ul>
</div>
}
/** 账号的创建能力来源与联动说明(角色默认与明确授权可并存)。 */
export function ProjectCreateSourceNote({ role, grants }: {
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[]
}) {
const sources = projectCreateSources(role, grants)
if (sources.length === 0) return <p className="ad-hint">未获得创建项目能力:被加入项目后由成员关系提供基础协作</p>
return <p className="ad-hint">创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格</p>
}
/** getUserRole 的角色摘要不带 permissions:用已加载角色列表里同一 roleId 的条目补齐,
* 列表里也没有时按无角色默认权限处理(不新增请求,也不改写已有权限)。 */
export function roleWithPermissions(role: UserRoleView['role'], roles: RoleRecord[]): UserRoleView['role'] {
if (!role || role.permissions) return role
return roles.find(item => item.roleId === role.roleId) ?? role
}
export function AccountUsersPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
const canViewRoles = allowed('system.roles.view')
const canAssignRoles = canViewRoles && allowed('system.roles.assign')
const canViewGrants = allowed('system.permissions.view')
const canManageGrants = canViewGrants && allowed('system.permissions.manage')
const [accounts, setAccounts] = useState<ManagedAccountRecord[] | null>(null)
const [selected, setSelected] = useState<ManagedAccountRecord | null>(null)
const [roleView, setRoleView] = useState<UserRoleView | null>(null)
const [roles, setRoles] = useState<RoleRecord[]>([])
const [roleQuery, setRoleQuery] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [roleLoading, setRoleLoading] = useState(false)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [grants, setGrants] = useState<string[]>([])
const [grantBaseline, setGrantBaseline] = useState<string[]>([])
const [authorityEpoch, setAuthorityEpoch] = useState(0)
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
const [newOwner, setNewOwner] = useState('')
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
const [newIsAdmin, setNewIsAdmin] = useState(false)
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [field, setField] = useState('default')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [cursor, setCursor] = useState('')
const [trail, setTrail] = useState<string[]>([])
const [nextCursor, setNextCursor] = useState('')
const [selectedRoleId, setSelectedRoleId] = useState('')
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const sequence = useRef(0)
const roleSequence = useRef(0)
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
const search = async (pageCursor = cursor, afterWrite = false) => {
const request = ++sequence.current
try {
const page = await api.searchAdminUsers({ query: query.trim(), mode, status,
fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 })
if (request !== sequence.current) return
setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '')
} catch (error) {
if (request === sequence.current) setErr(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error))
}
}
const resetPaging = () => { setCursor(''); setTrail([]) }
useEffect(() => {
const timer = window.setTimeout(() => { void search() }, 180)
return () => { window.clearTimeout(timer); sequence.current++ }
}, [query, mode, field, status, cursor])
const open = async (account: ManagedAccountRecord) => {
const request = ++sequence.current
roleSequence.current++
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(false)
setSelected(null); setRoleView(null); setErr(''); setNotice('')
const detailResult = await Promise.allSettled([api.adminGetUser(account.userId)])
if (request !== sequence.current) return
if (detailResult[0].status === 'rejected') { setErr(String(detailResult[0].reason)); return }
const detail = detailResult[0].value
setSelected(detail); setDraft(detail.profile)
const [roleResult, catalogResult, grantsResult] = await Promise.allSettled([
canViewRoles ? api.getUserRole(account.userId) : Promise.resolve(null),
canViewGrants ? api.permissionCatalog() : Promise.resolve(null),
canViewGrants ? api.getSystemGrants(account.userId) : Promise.resolve(null),
])
if (request !== sequence.current) return
if (roleResult.status === 'fulfilled' && roleResult.value) {
const currentRole = roleResult.value as UserRoleView
setRoleView(currentRole); setSelectedRoleId(currentRole.role?.roleId ?? '')
} else { setRoleView(null); setSelectedRoleId('') }
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
else setCatalog([])
if (grantsResult.status === 'fulfilled' && grantsResult.value) {
const actions = [...(grantsResult.value.actions ?? [])].sort()
setGrants(actions); setGrantBaseline(actions); setAuthorityEpoch(grantsResult.value.authorityEpoch)
} else { setGrants([]); setGrantBaseline([]); setAuthorityEpoch(0) }
}
useEffect(() => {
if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
const request = ++roleSequence.current
setRoleLoading(true); setNextRoleCursor('')
const timer = window.setTimeout(() => {
void api.listRoles({ query: roleQuery.trim(), mode: 'fuzzy', status: 'enabled', cursor: roleCursor, limit: 50 })
.then(page => {
if (request !== roleSequence.current) return
setRoles(page.roles ?? []); setNextRoleCursor(page.nextCursor ?? ''); setRoleLoading(false)
})
.catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } })
}, 180)
return () => { window.clearTimeout(timer) }
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
// 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。
const refreshFirst = async () => {
resetPaging(); setNextCursor('')
await search('', true)
}
const create = async () => {
if (!newOwner.trim()) return
setBusy(true); setErr(''); setNotice('')
try {
const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin)
setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false)
await open(account)
setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`)
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
finally { setBusy(false) }
}
const saveProfile = async () => {
if (!selected) return
const patch: Partial<AccountProfile> = {}
for (const item of fields) if (draft[item.key] !== selected.profile[item.key]) patch[item.key] = draft[item.key]
if (!Object.keys(patch).length) { setNotice('资料没有变化'); return }
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminPatchProfile(selected.userId, selected.profileVersion, patch)
setSelected(updated); setDraft(updated.profile); await refreshFirst(); setNotice('用户资料已保存')
} catch (error) { setErr(String(error).includes('409') ? '资料版本冲突,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const assignRole = async () => {
if (!selected || !selectedRoleId) return
setBusy(true); setErr(''); setNotice('')
try {
await api.assignUserRole(selected.userId, selectedRoleId, selected.stateVersion, crypto.randomUUID())
await open(selected); await refreshFirst(); setNotice('用户角色已替换')
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const saveGrants = async () => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
const userId = selected.userId
try {
const result = await api.replaceSystemGrants(userId, grants, authorityEpoch, crypto.randomUUID())
setAuthorityEpoch(result.authorityEpoch); setGrantBaseline(grants)
const expanded = result.linkedExpansion ?? []
setNotice(expanded.length ? `系统权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '系统权限已保存')
} catch (error) {
const raw = String(error)
if (raw.includes('final permission manager')) setErr('不能撤销最后一个有效权限管理者')
else if (raw.includes('409')) {
// 版本冲突:读取最新授权并重放本地增删,草稿不丢。
try {
const fresh = await api.getSystemGrants(userId)
const server = [...(fresh.actions ?? [])].sort()
setGrants(rebaseGrantDraft(grantBaseline, grants, server)); setGrantBaseline(server); setAuthorityEpoch(fresh.authorityEpoch)
setErr('系统权限版本冲突;已基于最新授权重放本地修改,请核对后再次保存')
} catch (refreshError) { setErr(`${String(refreshError)};本地草稿已保留`) }
} else setErr(`${raw.replace(/^Error:\s*/, '')};本地草稿已保留`)
}
finally { setBusy(false) }
}
const toggleState = async () => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminSetUserEnabled(selected.userId, selected.stateVersion, !selected.enabled)
setSelected(updated); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话、Key 与进行中的恢复提案已撤销')
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const setAdmin = async (isAdmin: boolean) => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminSetUserAdmin(selected.userId, selected.stateVersion, isAdmin)
setSelected(updated); await refreshFirst(); setNotice(isAdmin ? '已任命为管理员' : '已撤销管理员')
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const policy = selected ? accountActionPolicy(me, selected) : null
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
return <div>
{allowed('system.users.create') && <div className="ad-create">
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
<CreateAdminOption me={me} checked={newIsAdmin} disabled={busy} onChange={setNewIsAdmin} />
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
</Collapsible>
</div>}
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{selected && policy ? <div className="rp-editor">
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
</Collapsible>}
<div className="ad-row">
{canAssignRoles && policy.maintain && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
{allowed('system.users.disable') && policy.toggleEnabled && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
<AdminAppointmentButton me={me} account={selected} busy={busy} onChange={setAdmin} />
</div>
{canAssignRoles && policy.maintain && <div className="ad-row">
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
</div>}
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleWithPermissions(roleView?.role, roles)} grants={grantBaseline} />}
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
<GrantGroupEditor catalog={catalog} grants={grants} disabled={!canManageGrants || !policy.maintain || busy} onChange={setGrants} />
<GrantChangePreview baseline={grantBaseline} grants={grants} labels={labels} owner={selected.owner} />
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
</Collapsible>}
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
</div> : <>
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索用户" placeholder="搜索用户资料" value={query} onChange={event => { setQuery(event.target.value); resetPaging() }} />
<select className="lg-input" aria-label="用户匹配方式" value={mode} onChange={event => { setMode(event.target.value as typeof mode); resetPaging() }}><option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option></select>
<select className="lg-input" aria-label="用户搜索字段" value={field} onChange={event => { setField(event.target.value); resetPaging() }}>
<option value="default">账号/姓名/ID</option><option value="owner">登录名</option><option value="name">姓名</option><option value="userId">userId</option>
<option value="phone">电话</option><option value="email">邮箱</option><option value="staffNo">工号</option><option value="department">部门</option><option value="notes">备注</option>
</select>
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); resetPaging() }}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
</div>
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
<td>{account.profile.staffNo || '—'}</td><td><IdentityChip account={account} /></td><td>{account.enabled ? '有效' : '停用'}</td>
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
{accounts !== null && accounts.length === 0 && <tr><td colSpan={7} className="ad-dim">没有匹配用户</td></tr>}
{accounts === null && <tr><td colSpan={7} className="ad-dim">加载中…</td></tr>}</tbody></table>
<div className="ad-row"><button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页</button>
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页</button></div>
</>}
</div>
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon } from '@ui'
import { humanChangePasswordError } from './LoginPage'
import { humanChangePasswordError } from './loginErrors'
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
+36 -14
View File
@@ -10,7 +10,8 @@ import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
import { ExecEnvPanel } from './ExecEnvPanel'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { RdmRolesPanel } from './adminRdmSections'
import { AccountUsersPanel } from './AccountUsersPanel'
import { RolePermissionsPanel, roleDraftLeaveGuard, hasSystemAction } from './RolePermissionsPanel'
import type { AdminZoneSection } from '../api'
interface AdminSectionDef {
@@ -18,6 +19,8 @@ interface AdminSectionDef {
label: string
icon: IconName
desc: string
requiredAction?: string
rootOnly?: boolean
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
render: () => React.ReactNode
}
@@ -27,16 +30,30 @@ function ProfilesPanelWrapper() {
return <ProfilesSection profiles={profiles} />
}
/** 账号目录(按系统权限开放)+ 登录凭据管理(根管理员专属)。 */
function UsersSection() {
const isAdmin = useStore(s => !!s.me?.isAdmin)
return <>
<AccountUsersPanel />
{isAdmin && <>
<div className="az-section-head" style={{ marginTop: 18 }}>
<h3>登录凭据</h3>
<p>建号并发放初始密码、生成一次性登录密码(仅根管理员)</p>
</div>
<UsersPanel />
</>}
</>
}
const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员可发放登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
{ key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => <RolePermissionsPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => <ProfilesPanelWrapper /> },
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => <ExecEnvPanel /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', rootOnly: true, render: () => <SystemSettingsPanel /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', rootOnly: true, render: () => <PluginsSection /> },
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', rootOnly: true, render: () => <ExecEnvPanel /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', rootOnly: true, render: () => <PluginWorkspacesPanel /> },
]
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
@@ -64,14 +81,15 @@ function DeclaredSection({ section }: { section: AdminZoneSection }) {
export function AdminZone() {
const me = useStore(s => s.me)
const declared = useStore(s => s.adminSections)
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
const [sectionKey, setSectionKey] = useState('users')
const isAdmin = !!me?.isAdmin
const systemActions = me?.systemActions ?? []
const canOpenSystem = isAdmin || systemActions.length > 0
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
if (!isAdmin && declared.length === 0) return null
if (!canOpenSystem && declared.length === 0) return null
const sections: AdminSectionDef[] = [
...(isAdmin ? SECTIONS.filter(s => s.key !== 'rdm-roles' || rdmRolesAvailable) : []),
...SECTIONS.filter(section => section.rootOnly ? isAdmin : isAdmin || hasSystemAction(systemActions, section.requiredAction ?? '')),
...declared.map(s => ({
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
@@ -84,11 +102,15 @@ 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={() => {
// DR9:带未保存权限草稿离开须显式决定;守卫返回 false 表示用户取消。
if (roleDraftLeaveGuard.current && !roleDraftLeaveGuard.current()) return
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">{me?.isSuperAdmin ? '超级管理员' : isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me?.owner ?? ''}</span>
</div>
+269
View File
@@ -0,0 +1,269 @@
// 文档驾驶舱 I3:阅读器(连续正文 + 版本摘要/差异折叠)与右侧依据/影响面板。
// 消费 I1/I3 命令面 rdm.cockpit.node(+versions)/node.history(Dev 仓 docs/superpowers
// specs 2026-10-16 view-spec §5–§6)。不新增第四栏:右栏是选中节点的同一依据面,
// 全景与阅读器共享 selectedNodeRef;面板刷新原位进行,不抢占滚动位置(DV-R05/§5)。
// 折叠只用 @ui Collapsible;颜色/字号只消费 @ui/tokens.css。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Collapsible, EmptyState } from '@ui'
import {
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
PANEL_TABS, type CockpitContentBlock, type CockpitHistorySummary,
type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode,
type CockpitVersionSummary, type Density, type PanelTabId,
} from '../documentCockpit'
import type { CitationView } from '../regCitations'
import { CitedBody } from './RegCitation'
export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string }
interface NodeDetailShape {
meta: CockpitMeta
node: CockpitTreeNode
body?: CockpitContentBlock[]
/** U7:章节正文 [cit-…] 标记的引用视图(契约 §3)。 */
citations?: CitationView[] | null
children: CockpitTreeNode[]
sources: { items: any[] }
history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string }
impacts: { items: any[] }
conflicts: { items: any[] }
versions?: CockpitVersionSummary[]
}
function useNodeDetail(pid: string, sel?: CockpitNodeRef) {
const [detail, setDetail] = useState<NodeDetailShape | null>(null)
const [err, setErr] = useState('')
const gen = useRef(0)
useEffect(() => {
const g = ++gen.current
setDetail(null); setErr('')
if (!sel) return
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id })
.then(d => {
if (g !== gen.current) return
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
}
setDetail(d)
})
.catch(e => { if (g === gen.current) setErr(e.message) })
return () => { gen.current++ }
}, [pid, sel?.kind, sel?.id])
return { detail, err }
}
/** 版本块:当前版本 + 前一版本摘要;完整差异(行级标记)折叠。受限只给元数据。 */
export function VersionBlock({ versions, density }: { versions?: CockpitVersionSummary[]; density: Density }) {
if (!versions || versions.length === 0) return null
const { current, previous, redacted } = versionSummary(versions)
if (redacted || !current || current.content === undefined) {
return <div className="empty-note">版本受限(仅元数据可见)</div>
}
const prev = previous && previous.content !== undefined ? previous : undefined
return (
<Collapsible
id={`cockpit-versions-${current.version}`}
trigger={<span className="cockpit-versions-toggle">版本 {current.version}{previous ? `(前一版 ${previous.version})` : '(首版)'}与完整差异</span>}
>
<div className={`cockpit-versions cockpit-density-${density}`}>
{prev ? (
<>
<div className="cockpit-versions-head">当前 {current.version} · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
<pre className="cockpit-diff">
{diffLines(current.content || '', prev.content || '').map((l, i) => (
<div key={i} className={`cockpit-diff-${l.flag === '=' ? 'same' : l.flag === '+' ? 'add' : 'del'}`}>{l.flag} {l.text}</div>
))}
</pre>
<div className="cockpit-versions-head">前一版 {prev.version} · {prev.actor || '未知'} · {new Date(prev.at).toLocaleString('zh-CN')}</div>
</>
) : (
<div className="cockpit-versions-head">首版 · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
)}
</div>
</Collapsible>
)
}
/** 阅读器中栏:选中章节=该节连续正文+版本块;选中文档=全章节连续装配(每节详情按需拉取)。 */
export function CockpitReader({ pid, selection, sections, density, onBack }: {
pid: string
selection: ReaderSelection
sections: CockpitTreeNode[]
density: Density
onBack: () => void
}) {
const { detail, err } = useNodeDetail(pid, selection.ref)
const isSection = selection.ref.kind === 'section'
const [sectionDetails, setSectionDetails] = useState<Record<string, NodeDetailShape | undefined>>({})
const gen = useRef(0)
// 文档阅读器:按树顺序装配全部章节详情(上限 20;失败章节给受限占位不拖垮整篇)。
useEffect(() => {
if (isSection || selection.ref.kind !== 'document') return
const g = ++gen.current
const list = sections.slice(0, 20)
setSectionDetails({})
for (const sec of list) {
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: 'section', id: sec.ref.id })
.then(d => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: d })) })
.catch(() => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: undefined })) })
}
return () => { gen.current++ }
}, [pid, isSection, selection.ref.id, sections])
const anchorRef = useRef<HTMLDivElement | null>(null)
useEffect(() => { anchorRef.current?.scrollIntoView({ block: 'start' }) }, [selection.ref.id])
if (err) return <EmptyState icon="alert">阅读器加载失败:{err}</EmptyState>
return (
<div className={`cockpit-reader cockpit-density-${density}`}>
<div className="cockpit-nodehead" ref={anchorRef}>
<button type="button" className="cockpit-back" onClick={onBack}>← 返回全景</button>
<span className="cockpit-nodetitle">{detail?.node?.title || (isSection ? '章节' : '文档')}</span>
</div>
{isSection && detail && (
<>
{renderBody(detail.body, detail.meta, detail.citations, `sec-${selection.ref.id}`)}
<VersionBlock versions={detail.versions} density={density} />
</>
)}
{!isSection && (
<>
{sections.length === 0 && <div className="empty-note">该文档暂无章节</div>}
{assembleReader(sections, sectionDetails).map(sec => (
<section key={sec.ref.id} id={`sec-${sec.ref.id}`} className="cockpit-reader-section">
<div className="cockpit-reader-sechead">
<span className="cockpit-reader-sectitle">{sec.title}</span>
{sec.blocks[0]?.kind === 'restricted'
? <span className="cockpit-badge">内容受限({sec.blocks[0].reason || '权限'})</span>
: sec.contentState === 'empty' && <span className="cockpit-badge">空</span>}
</div>
{sec.blocks[0]?.kind === 'text' && (
<CitedBody texts={sec.blocks.filter(b => b.kind === 'text').map(b => b.text || '')}
citations={sectionDetails[sec.ref.id]?.citations} scope={`doc-${sec.ref.id}`} />
)}
{sec.blocks.map((b, i) => b.kind !== 'text' && (
<p key={i} className="empty-note">内容受限——标题与状态可见,正文不展示</p>
))}
<VersionBlock versions={sec.versions} density={density} />
</section>
))}
</>
)}
</div>
)
}
function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta, citations: CitationView[] | null | undefined, scope: string) {
if (!body || body.length === 0) return <div className="empty-note">暂无正文</div>
if (body[0].kind === 'restricted') {
return <div className="empty-note">内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
}
return (
<>
<CitedBody texts={body.map(b => b.text || '')} citations={citations} scope={scope} />
{meta.truncated && <div className="cockpit-truncated">详情已截断:{meta.truncationReasons?.join(';')}</div>}
</>
)
}
/** 右栏:依据/历史/影响/冲突四标签(同一选中节点;刷新原位,不抢占滚动)。 */
export function CockpitPanel({ pid, selection, tab, onTab, density }: {
pid: string
selection: ReaderSelection
tab: PanelTabId
onTab: (t: PanelTabId) => void
density: Density
}) {
const { detail, err } = useNodeDetail(pid, selection.ref)
const [historyItems, setHistoryItems] = useState<CockpitHistorySummary[]>([])
const [historyMore, setHistoryMore] = useState(false)
const [historyCursor, setHistoryCursor] = useState<string | undefined>(undefined)
useEffect(() => {
setHistoryItems([]); setHistoryMore(false); setHistoryCursor(undefined)
}, [selection.ref.kind, selection.ref.id])
useEffect(() => {
if (!detail?.history) return
// 严格合并:只接受 seq 更新的条目,乱序/重复丢弃;不重置已渲染内容。
setHistoryItems(prev => mergeHistoryStrict(prev, detail.history.items).items)
setHistoryMore(detail.history.hasMore)
setHistoryCursor(detail.history.nextCursor)
}, [detail])
const loadMoreHistory = useCallback(() => {
if (!historyCursor) return
api.rdmCommand<any>('rdm.cockpit.node.history', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id, cursor: historyCursor, limit: 20 })
.then(page => {
setHistoryItems(prev => mergeHistoryStrict(prev, page.items).items)
setHistoryMore(page.hasMore)
setHistoryCursor(page.nextCursor)
})
.catch(() => { /* 保留已渲染历史;可再次点击重试 */ })
}, [pid, selection.ref.kind, selection.ref.id, historyCursor])
const node = detail?.node
return (
<div className={`cockpit-panel cockpit-density-${density}`}>
<div className="cockpit-panel-tabs" role="tablist" aria-label="依据与影响">
{PANEL_TABS.map(t => (
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id}
className={`cockpit-panel-tab${tab === t.id ? ' cockpit-panel-tab-active' : ''}`}
onClick={() => onTab(t.id)}>
{t.label}
</button>
))}
</div>
{err && <div className="empty-note">依据面板加载失败:{err}</div>}
{!err && !detail && <div className="empty-note">加载依据…</div>}
{detail && tab === 'sources' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">来源 {node?.state.sourceCount ?? detail.sources.items.length} 条</div>
{detail.sources.items.length === 0 && <div className="empty-note">无来源——章节正文未挂依据切片</div>}
{detail.sources.items.map((s: any) => (
<div key={s.ref.id} className="cockpit-panel-row">
<span className="cockpit-panel-kind">{s.ref.kind}</span>
<span className="cockpit-panel-label">{s.label}</span>
{s.confidence && <span className="cockpit-badge">置信度 {s.confidence}</span>}
{s.redactedReason
? <span className="cockpit-badge">受限({s.redactedReason})</span>
: s.verification && s.verification !== 'not_applicable' && <span className="cockpit-badge">{s.verification}</span>}
</div>
))}
</div>
)}
{detail && tab === 'history' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">已提交事件(最新在前)</div>
{historyItems.length === 0 && <div className="empty-note">暂无历史</div>}
{historyItems.map(h => <div key={h.seq} className="cockpit-panel-row">{historyLine(h)}</div>)}
{historyMore && <button type="button" className="cockpit-tree-more" onClick={loadMoreHistory}>加载更早历史</button>}
</div>
)}
{detail && tab === 'impacts' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">影响 {node?.state.impactCount ?? detail.impacts.items.length} 项(一跳)</div>
{detail.impacts.items.length === 0 && <div className="empty-note">无一跳影响对象</div>}
{detail.impacts.items.map((r: any, i: number) => (
<div key={`${r.ref.kind}-${r.ref.id}-${i}`} className="cockpit-panel-row">
<span className="cockpit-panel-kind">{r.direction === 'incoming' ? '←' : '→'}</span>
<span className="cockpit-panel-label">{r.ref.kind}:{r.ref.id}</span>
<span className="cockpit-badge">{r.edgeType}</span>
<span className="cockpit-badge">{r.edgeState}</span>
</div>
))}
</div>
)}
{detail && tab === 'conflicts' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">冲突 {node?.state.conflictCount ?? 0} 项</div>
{detail.conflicts.items.length === 0 && <div className="empty-note">无开放冲突</div>}
{detail.conflicts.items.map(c => (
<div key={c.id} className="cockpit-panel-row cockpit-panel-conflict">
<span className="cockpit-badge">{c.state === 'open' ? '开放' : c.state}</span>
<div className="cockpit-conflict-side">甲方:{c.left.visibility === 'full' ? c.left.claim : `受限(${c.left.redactedReason || '权限'})`}</div>
<div className="cockpit-conflict-side">乙方:{c.right.visibility === 'full' ? c.right.claim : `受限(${c.right.redactedReason || '权限'})`}</div>
<div className="cockpit-changetime">{new Date(c.detectedAt).toLocaleString('zh-CN')}</div>
</div>
))}
</div>
)}
</div>
)
}
+2 -2
View File
@@ -637,14 +637,14 @@ function ConversationSession() {
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</label>
<div className="companion-setting"><span>读取同项目的其他对话</span><button className="btn ghost"
{!conversation?.scratch && <div className="companion-setting"><span>读取同项目的其他对话</span><button className="btn ghost"
title={conversation?.allowCrossRead === false
? '本会话不读取其他会话记录(点击开启)'
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
{conversation?.allowCrossRead === false ? '关闭' : '允许'}
</button>
<span className="companion-setting-note">实际读取范围还需项目设置允许</span></div>
<span className="companion-setting-note">实际读取范围还需项目设置允许</span></div>}
<label className="companion-setting"><span>这次工作的要求</span><SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} /></label>
</Collapsible>
<span title={connected ? '已连接' : '正在重新连接'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
+5 -2
View File
@@ -10,6 +10,7 @@ export function ConversationWork() {
const cards = useStore(s => s.cards)
const conversationId = useStore(s => s.currentConvId)
const projectId = useStore(s => s.currentProjectId)
const scratch = useStore(s => !!s.tasks.find(t => t.id === s.currentConvId)?.scratch)
const messages = useStore(s => s.messages)
const actionStates = useStore(s => s.cardActionStates)
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
@@ -29,7 +30,9 @@ export function ConversationWork() {
if (card.kind === 'confirm' && card.anchorMessageId
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
const summary = activitySummary(card)
if (card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
const taskNotice = card.task?.limitations?.length || card.task?.phase.id === 'correction'
|| card.task?.findings?.some(f => f.status === 'active' && (f.kind === 'needs_user' || f.corrects))
if (taskNotice || card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
else remaining.push(card)
}
// Preserve the host's newest-first order for completed results and history.
@@ -54,7 +57,7 @@ export function ConversationWork() {
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
onLocate: (card: ActivityCard) => { store.locateMessage(card.anchorMessageId) },
onArchive: (card: ActivityCard) => { void store.cardToProject(card) },
...(scratch ? {} : { onArchive: (card: ActivityCard) => { void store.cardToProject(card) } }),
}
const view = (card: ActivityCard) => <ActivityCardView key={card.id} card={card} callbacks={callbacks} docBridge={docBridge} />
+372
View File
@@ -0,0 +1,372 @@
// 文档驾驶舱 I2(rollout I2):顶部统计带 + 左侧结构树 + 中央结构全景。
// 消费 I1 只读命令面 rdm.cockpit.*(Dev 仓 docs/superpowers/specs 2026-10-16
// view-spec §1–§4;四项取舍冻结见 rollout §7——首屏只显示章节概览与统计,
// 段落/正文摘要在选中与阅读器内按权限展示,本增量不含阅读器(I3))。
// 异步所有者:generation + contextKey(项目+筛选);不匹配的旧响应直接丢弃。
// 令牌驱动(DESIGN.md):颜色/字号只消费 @ui/tokens.css;折叠用 Collapsible。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
import {
contextKey, loadPrefs, nodeSummaryLine, sameSnapshot, savePrefs,
selectionSurvives, statDisplay, STAT_FILTERS, toggleFilter, treeBadges,
type CockpitMeta, type CockpitNodeRef, type CockpitOverview,
type CockpitTreeNode, type CockpitTreePage,
type Density, type PanelTabId, type ViewMode,
} from '../documentCockpit'
import { CockpitPanel, CockpitReader, VersionBlock } from './CockpitReader'
interface ProjectView { projectId: string; name: string; canAudit?: boolean }
function CockpitErrorState({ raw }: { raw: string }) {
const c = classifyRdmError(raw)
return <EmptyState icon="alert">{c.message}</EmptyState>
}
interface Selection { ref: CockpitNodeRef; parentDocId?: string }
export function DocumentCockpit() {
const identity = useStore(() => store.getIdentityEpoch())
const [projects, setProjects] = useState<ProjectView[] | null>(null)
const [pid, setPid] = useState('')
const [filter, setFilter] = useState('')
const [overview, setOverview] = useState<CockpitOverview | null>(null)
const [treePages, setTreePages] = useState<Record<string, { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }>>({})
const [selected, setSelected] = useState<Selection | null>(null)
const [error, setError] = useState('')
const [loadErr, setLoadErr] = useState('')
const [viewMode, setViewMode] = useState<ViewMode>('panorama')
const [density, setDensity] = useState<Density>('compact')
const [panelTab, setPanelTab] = useState<PanelTabId>('sources')
const gen = useRef(0)
const reload = useCallback((projectId: string, f: string) => {
if (!projectId) return
const g = ++gen.current
const ctx = contextKey(projectId, f)
setLoadErr('')
api.rdmCommand<CockpitOverview>('rdm.cockpit.overview', { projectId })
.then(ov => {
if (g !== gen.current || ctx !== contextKey(pid, filter)) return // 旧响应:项目/筛选已切换,丢弃
setOverview(ov)
setTreePages({}) // 树页随新快照整体重建
setSelected(sel => {
const kept = selectionSurvives(sel?.ref || null, (ov.documents || []).map(d => d.ref.id), sel?.parentDocId)
return kept ? { ref: kept, parentDocId: sel?.parentDocId } : null
})
})
.catch(e => { if (g === gen.current && ctx === contextKey(pid, filter)) setLoadErr(e.message) })
}, [pid, filter])
// 项目清单(rdm.project.list:成员可见项目,canAudit 与否不影响文档读取入口)。
useEffect(() => {
let c = false
setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('')
api.rdmCommand<{ projects?: ProjectView[] } | ProjectView[]>('rdm.project.list')
.then(list => {
if (c) return
// rdm.project.list 返回 { projects: [...] };容忍裸数组形态。
const rows = Array.isArray(list) ? list : (list?.projects ?? [])
setProjects(rows)
// 设计 §6.2:进入驾驶舱默认选中第一个可见项目,驱动概览首屏。
if (rows.length > 0) setPid(prev => prev || rows[0].projectId)
})
.catch(e => { if (!c) setError(e.message) })
return () => { c = true }
}, [identity])
// 项目/筛选变化 → 重新取概览(统计与根文档来自同一快照)。
useEffect(() => {
if (!pid) return
reload(pid, filter)
}, [pid, filter, reload])
// 个人视图偏好(设计 §7.3):按 登录世代+项目 隔离,仅本地;恢复最近视图。
useEffect(() => {
if (!pid) return
const prefs = loadPrefs(`epoch-${identity}`, pid)
setViewMode(prefs.lastView)
setDensity(prefs.density)
setPanelTab(prefs.rightPanelTab)
}, [identity, pid])
const applyPrefs = useCallback((patch: { lastView?: ViewMode; density?: Density; rightPanelTab?: PanelTabId }) => {
if (!pid) return
setViewMode(v => patch.lastView ?? v)
setDensity(d => patch.density ?? d)
setPanelTab(t => patch.rightPanelTab ?? t)
const prefs = loadPrefs(`epoch-${identity}`, pid)
savePrefs(`epoch-${identity}`, pid, { ...prefs, ...patch })
}, [identity, pid])
// 展开文档:按需加载章节子页(键集分页;旧项目/旧筛选的响应丢弃;
// 加载失败只影响该分支——行内保留重试,不拖垮其它面板)。
const loadChildren = useCallback((docId: string, cursor?: string) => {
if (!pid) return
const g = gen.current
const ctx = contextKey(pid, filter)
api.rdmCommand<CockpitTreePage>('rdm.cockpit.tree', { projectId: pid, parent: docId, cursor, limit: 50, filter })
.then(page => {
if (g !== gen.current || ctx !== contextKey(pid, filter)) return
setTreePages(prev => {
const old = cursor ? prev[docId] : { items: [] as CockpitTreeNode[], hasMore: false }
const items = cursor ? [...(old?.items || []), ...page.items] : page.items
return { ...prev, [docId]: { items, nextCursor: page.nextCursor, hasMore: page.hasMore, meta: page.meta } }
})
})
.catch(() => { /* 行内重试入口承担 */ })
}, [pid, filter])
if (error) return <CockpitErrorState raw={error} />
if (!projects) return <div className="empty-note">加载中…</div>
if (projects.length === 0) return <EmptyState icon="folder">暂无可见研发项目——项目成员身份由项目负责人授予</EmptyState>
const docIds = (overview?.documents || []).map(d => d.ref.id)
const splitNotice = !!overview && Object.values(treePages).some(p => !sameSnapshot(overview.meta, p.meta))
const selectDoc = (id: string) => setSelected({ ref: { kind: 'document', id } })
const selectSection = (docId: string, sec: CockpitTreeNode) => setSelected({ ref: sec.ref, parentDocId: docId })
return (
<div className="cockpit">
<div className="cockpit-header">
<select className="ui-input cockpit-project" value={pid} onChange={e => { setPid(e.target.value); setSelected(null); setFilter('') }} aria-label="选择项目">
{projects.map(p => <option key={p.projectId} value={p.projectId}>{p.name}</option>)}
</select>
{filter && <button type="button" className="cockpit-filter-chip" onClick={() => setFilter('')}>
筛选:{STAT_FILTERS.find(s => s.filter === filter)?.label || filter} ✕
</button>}
<span className="cockpit-snap">{overview ? `快照 ${overview.meta.snapshotRevision}` : '加载中…'}</span>
<div className="cockpit-viewtoggle" role="group" aria-label="视图切换">
<button type="button" className={`cockpit-viewbtn${viewMode === 'panorama' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'panorama' })}>全景</button>
<button type="button" className={`cockpit-viewbtn${viewMode === 'reader' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'reader' })}>阅读器</button>
<button type="button" className="cockpit-viewbtn" onClick={() => applyPrefs({ density: density === 'compact' ? 'full' : 'compact' })} title="切换密度">{density === 'compact' ? '紧凑' : '宽松'}</button>
</div>
<button type="button" className="cockpit-refresh" onClick={() => reload(pid, filter)} disabled={!pid}>刷新</button>
</div>
{splitNotice && (
<div className="cockpit-banner" role="status">统计与树快照不一致——请刷新以对齐最新快照</div>
)}
{loadErr && <div className="cockpit-banner cockpit-banner-err" role="alert">概览读取失败:{loadErr}(保留上一快照,可重试)</div>}
{overview && (
<div className="cockpit-stats" role="group" aria-label="项目统计">
<div className="cockpit-stat">
<span className="cockpit-stat-num">{statDisplay(overview.stats, 'completion').value}</span>
<span className="cockpit-stat-label">完成度{statDisplay(overview.stats, 'completion').hint ? `(${statDisplay(overview.stats, 'completion').hint})` : ''}</span>
</div>
<div className="cockpit-stat"><span className="cockpit-stat-num">{statDisplay(overview.stats, 'documents').value}</span><span className="cockpit-stat-label">文档</span></div>
{STAT_FILTERS.map(s => {
const d = statDisplay(overview.stats, s.key)
const active = filter === s.filter
return (
<button key={s.key} type="button" className={`cockpit-stat${active ? ' cockpit-stat-active' : ''}`}
onClick={() => setFilter(f => toggleFilter(f, s.filter))}
title={d.hint ? `${s.label}:${d.hint}` : `筛选:${s.label}`}>
<span className="cockpit-stat-num">{d.value}</span>
<span className="cockpit-stat-label">{s.label}{d.hint ? `(${d.hint})` : ''}</span>
</button>
)
})}
<div className="cockpit-stat cockpit-stat-unavail"><span className="cockpit-stat-num">—</span><span className="cockpit-stat-label">待确认(未纳入投影)</span></div>
<div className="cockpit-stat"><span className="cockpit-stat-num">{overview.stats.updatedAt ? new Date(overview.stats.updatedAt).toLocaleString('zh-CN') : '—'}</span><span className="cockpit-stat-label">最近更新</span></div>
</div>
)}
<div className={`cockpit-columns${selected ? ' cockpit-columns-3' : ''}`}>
<div className="cockpit-tree" role="tree" aria-label="文档结构树">
<SectionLabel>结构树</SectionLabel>
{!overview && <div className="empty-note">加载结构…</div>}
{overview && docIds.length === 0 && <div className="empty-note">暂无受控文档</div>}
{overview && overview.meta.truncated && (
<div className="cockpit-truncated">文档清单已截断({overview.meta.truncationReasons?.join(';') || '超出上限'})</div>
)}
{overview && overview.documents.map(d => (
<TreeRow key={d.ref.id} node={d} depth={0}
expanded={treePages[d.ref.id] !== undefined}
childPage={treePages[d.ref.id]}
selectedId={selected?.ref.id}
onSelectNode={n => (n.ref.kind === 'document' ? selectDoc(n.ref.id) : selectSection(d.ref.id, n))}
onExpand={() => { if (treePages[d.ref.id] === undefined) loadChildren(d.ref.id) }}
onLoadMore={() => { const p = treePages[d.ref.id]; if (p?.nextCursor) loadChildren(d.ref.id, p.nextCursor) }}
onRetryChild={() => { const p = treePages[d.ref.id]; loadChildren(d.ref.id, p?.nextCursor) }}
/>
))}
</div>
<div className="cockpit-center">
{!overview && <div className="empty-note">加载概览…</div>}
{overview && !selected && (
<PanoramaHome overview={overview} onSelect={selectDoc} />
)}
{overview && selected && viewMode === 'panorama' && (
<PanoramaNode pid={pid} selection={selected} overview={overview}
snapRev={overview.meta.snapshotRevision}
childPage={selected.ref.kind === 'document' ? treePages[selected.ref.id] : undefined}
density={density}
onClear={() => setSelected(null)}
onOpenReader={() => applyPrefs({ lastView: 'reader' })} />
)}
{overview && selected && viewMode === 'reader' && (
<CockpitReader pid={pid} selection={selected}
sections={selected.ref.kind === 'document' ? (treePages[selected.ref.id]?.items || []) : []}
density={density} onBack={() => applyPrefs({ lastView: 'panorama' })} />
)}
</div>
{overview && selected && (
<div className="cockpit-right">
<CockpitPanel pid={pid} selection={selected} tab={panelTab} onTab={t => applyPrefs({ rightPanelTab: t })} density={density} />
</div>
)}
</div>
</div>
)
}
function TreeRow({ node, depth, expanded, childPage, selectedId, onSelectNode, onExpand, onLoadMore, onRetryChild }: {
node: CockpitTreeNode; depth: number
expanded: boolean
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
selectedId?: string
onSelectNode: (n: CockpitTreeNode) => void
onExpand: () => void
onLoadMore: () => void
onRetryChild: () => void
}) {
const badges = treeBadges(node)
const selected = selectedId === node.ref.id
return (
<div className="cockpit-tree-branch">
<div className={`cockpit-tree-row${selected ? ' cockpit-tree-row-active' : ''}`} style={{ paddingLeft: depth * 14 }} role="treeitem" aria-selected={selected}>
{node.ref.kind === 'document' && (
<button type="button" className="cockpit-tree-caret" aria-label={expanded ? '折叠' : '展开'}
aria-expanded={expanded} onClick={e => { e.stopPropagation(); if (!expanded) onExpand() }}>
{expanded ? '▾' : '▸'}
</button>
)}
<button type="button" className="cockpit-tree-title" onClick={() => onSelectNode(node)}>{node.title}</button>
{badges.map(b => <span key={b} className="cockpit-badge">{b}</span>)}
{node.lastChangedAt && <span className="cockpit-tree-time">{new Date(node.lastChangedAt).toLocaleDateString('zh-CN')}</span>}
</div>
{expanded && !childPage && (
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onRetryChild}>章节加载失败,重试</button>
)}
{expanded && childPage && (
<div>
{childPage.items.length === 0 && <div className="cockpit-tree-empty" style={{ paddingLeft: (depth + 1) * 14 }}>无章节</div>}
{childPage.items.map(sec => (
<TreeRow key={sec.ref.id} node={sec} depth={depth + 1} expanded={false} childPage={undefined}
selectedId={selectedId} onSelectNode={onSelectNode}
onExpand={onExpand} onLoadMore={onLoadMore} onRetryChild={onRetryChild} />
))}
{childPage.hasMore && childPage.items.length > 0 && (
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onLoadMore}>加载更多章节</button>
)}
</div>
)}
</div>
)
}
function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSelect: (id: string) => void }) {
return (
<div>
<SectionLabel>{overview.project.title} · 文档概览</SectionLabel>
{overview.documents.length === 0 && <EmptyState icon="folder">暂无受控文档——文档由模板实例化或导入后进入驾驶舱</EmptyState>}
<div className="cockpit-doclist">
{overview.documents.map(d => (
<button key={d.ref.id} type="button" className="cockpit-docrow" onClick={() => onSelect(d.ref.id)}>
<span className="cockpit-docrow-title">{d.title}</span>
<span className="cockpit-docrow-meta">{nodeSummaryLine(d)}</span>
</button>
))}
</div>
<SectionLabel>最近变化</SectionLabel>
{overview.recentChanges.length === 0 && <div className="empty-note">暂无可见变化</div>}
<div className="cockpit-changes">
{overview.recentChanges.map((c, i) => (
<div key={`${c.node.id}-${i}`} className="cockpit-changerow">
<span className="cockpit-changesum">{c.summary} · {c.title}{c.actor ? ` · ${c.actor}` : ''}</span>
<span className="cockpit-changetime">{new Date(c.changedAt).toLocaleString('zh-CN')}</span>
</div>
))}
</div>
</div>
)
}
function PanoramaNode({ pid, selection, overview, childPage, density, snapRev, onClear, onOpenReader }: {
pid: string; selection: Selection; overview: CockpitOverview
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
density: Density
snapRev: string
onClear: () => void
onOpenReader: () => void
}) {
const [detail, setDetail] = useState<any>(null)
const [err, setErr] = useState('')
const gen = useRef(0)
useEffect(() => {
const g = ++gen.current
setDetail(null); setErr('')
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id })
.then(d => {
if (g !== gen.current) return
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
}
setDetail(d)
})
.catch(e => { if (g === gen.current) setErr(e.message) })
return () => { gen.current++ }
}, [pid, selection.ref.kind, selection.ref.id, snapRev])
const node = (detail?.node || null) as CockpitTreeNode | null
const title = node?.title || selection.ref.id
return (
<div>
<div className="cockpit-nodehead">
<button type="button" className="cockpit-back" onClick={onClear}>← 返回概览</button>
<span className="cockpit-nodetitle">{title}</span>
{node && <span className="cockpit-nodesum">{nodeSummaryLine(node)}</span>}
<button type="button" className="cockpit-filter-chip" onClick={onOpenReader}>在阅读器中打开</button>
</div>
{err && <CockpitErrorState raw={err} />}
{detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && (
<div className="empty-note">内容受限({detail.body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
)}
{selection.ref.kind === 'section' && detail?.versions && detail.versions.length > 0 && (
<VersionBlock versions={detail.versions} density={density} />
)}
{detail && detail.meta && !sameSnapshot(detail.meta, overview.meta) && (
<div className="cockpit-truncated">节点详情与统计快照不同({detail.meta.snapshotRevision} vs {overview.meta.snapshotRevision})——刷新对齐</div>
)}
{node && node.ref.kind === 'document' && (
<>
<SectionLabel>章节状态</SectionLabel>
{!childPage && <div className="empty-note">章节按需加载——在左侧树展开</div>}
{childPage && childPage.items.length === 0 && <div className="empty-note">无章节</div>}
{childPage && childPage.items.length > 0 && (
<table className="cockpit-childtable">
<thead><tr><th>章节</th><th>内容</th><th>核实</th><th>来源</th><th>冲突</th><th>最近更新</th></tr></thead>
<tbody>
{childPage.items.map(sec => (
<tr key={sec.ref.id}>
<td>{sec.title}</td>
<td>{{ complete: '完整', partial: '部分', empty: '空', inaccessible: '受限', unavailable: '不可读' }[sec.state.content] || sec.state.content}</td>
<td>{sec.state.verification === 'not_applicable' ? '—' : sec.state.verification}</td>
<td>{sec.state.sourceCount}</td>
<td>{sec.state.conflictCount}</td>
<td>{sec.lastChangedAt ? new Date(sec.lastChangedAt).toLocaleDateString('zh-CN') : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
{node && node.ref.kind === 'section' && (
<div className="empty-note">点「在阅读器中打开」查看连续正文与版本差异;右侧为该节点的依据与影响面板</div>
)}
{detail?.meta?.truncated && (
<div className="cockpit-truncated">详情已截断:{detail.meta.truncationReasons?.join(';')}</div>
)}
</div>
)
}
+125
View File
@@ -0,0 +1,125 @@
// 统一渲染 Task 11:整文审签与规则绑定面板。纯展示:入口、载荷与校验来自 documentRenderPresentation,
// 异步操作状态由工作区唯一持有并经 props 注入;入口只在服务端允许(能力 ∧ 冻结职责/项目设置权限)时出现。
import { useState } from 'react'
import { Btn, Chip, SectionLabel } from '@ui'
import {
BIND_SOURCE_LABELS, validateReviewText, ruleLabel,
type BindSource, type BindableRule, type DecisionEntries, type DecisionKind, type DocSubmissionView, type RuleRef,
} from '../documentRenderPresentation'
import { OperationStatus, type OperationState } from './renderFeedback'
export const ACTION_OP_LABELS: Record<string, string> = {
approve: '整文审核通过', reject: '整文驳回', final: '整文终批', bind: '绑定规则', unbind: '解绑规则',
}
const DECISION_LABELS: Record<DecisionKind, string> = { approve: '审核通过', reject: '驳回', final: '终批' }
const SUBMISSION_STATE: Record<string, { label: string; tone: 'ok' | 'info' | 'danger' | 'plain' }> = {
submitted: { label: '审签中', tone: 'info' },
rejected: { label: '已驳回', tone: 'danger' },
final: { label: '已终批', tone: 'ok' },
}
export function ReviewDecisionPanel({ sub, entries, busy, status, onRetry, onDecide }: {
sub: DocSubmissionView
entries: DecisionEntries
busy: boolean
status: OperationState
onRetry: () => void
onDecide: (kind: DecisionKind, text: string) => void
}) {
const [text, setText] = useState('')
const [hint, setHint] = useState('')
const reviewerIds = Object.keys(sub.reviews)
const approvedCount = reviewerIds.filter(id => sub.reviews[id] === true).length
const stateView = SUBMISSION_STATE[sub.state] ?? { label: '状态未知', tone: 'plain' as const }
const anyEntry = entries.approve || entries.reject || entries.final
const decide = (kind: DecisionKind) => {
// 驳回必须给出原因;审核通过与终批的说明可选(服务端同样可选)。
if (kind === 'reject') {
const bad = validateReviewText(text)
if (bad) { setHint(bad); return }
}
setHint('')
onDecide(kind, text)
}
return (
<>
<div className="drw-row drw-row-between">
<SectionLabel>整文审签</SectionLabel>
<span className="drw-row">
<Chip tone={stateView.tone}>{stateView.label}</Chip>
<span className="drw-muted">第 {sub.round} 轮 · 审核 {approvedCount}/{reviewerIds.length}</span>
</span>
</div>
{anyEntry && (
<>
<textarea className="ui-input drw-textarea" aria-label="审签说明" value={text}
onChange={e => setText(e.target.value)} placeholder="说明可留空;驳回时必须填写原因" />
{hint && <div className="drw-inline-err">{hint}</div>}
<div className="drw-row">
{entries.approve && <Btn variant="ghost" disabled={busy} onClick={() => decide('approve')}>{DECISION_LABELS.approve}</Btn>}
{entries.reject && <Btn variant="danger" disabled={busy} onClick={() => decide('reject')}>{DECISION_LABELS.reject}</Btn>}
{entries.final && <Btn variant="primary" disabled={busy} onClick={() => decide('final')}>{DECISION_LABELS.final}</Btn>}
</div>
</>
)}
<OperationStatus state={status} onRetry={onRetry} labels={ACTION_OP_LABELS} />
</>
)
}
const SCOPES: BindSource[] = ['document', 'document-type', 'project-default']
const ruleKey = (r: RuleRef) => `${r.profileId}@${r.version}`
export function ProfileBindingPanel({ rules, bindings, typeKey, busy, status, onRetry, onBind, onUnbind }: {
rules: BindableRule[]
bindings: Partial<Record<BindSource, RuleRef>>
typeKey: string | undefined
busy: boolean
status: OperationState
onRetry: () => void
onBind: (source: BindSource, rule: RuleRef) => void
onUnbind: (source: BindSource) => void
}) {
const [choice, setChoice] = useState('')
const selected = rules.find(r => ruleKey(r) === choice)
return (
<>
<SectionLabel>规则绑定</SectionLabel>
{rules.length === 0 ? (
<div className="empty-note">没有可绑定的已发布规则</div>
) : (
<div className="drw-bind-rules" role="radiogroup" aria-label="可绑定规则">
{rules.map(r => (
<label key={ruleKey(r)} className={`drw-bind-rule${choice === ruleKey(r) ? ' sel' : ''}`}>
<input type="radio" name="drw-bind-rule" value={ruleKey(r)} checked={choice === ruleKey(r)} onChange={() => setChoice(ruleKey(r))} />
<span className="drw-mono">{ruleLabel(r)}</span>
</label>
))}
</div>
)}
{SCOPES.map(source => {
const bound = bindings[source]
const needsType = source === 'document-type'
const bindDisabled = busy || !selected || (needsType && !typeKey)
return (
<div key={source} className="drw-bind-row">
<div className="drw-row drw-row-between">
<span className="drw-profile-source">{BIND_SOURCE_LABELS[source]}</span>
<span className="drw-mono">{bound ? ruleLabel(bound) : '未绑定'}</span>
</div>
<div className="drw-row">
<Btn variant="ghost" disabled={bindDisabled} onClick={() => selected && onBind(source, selected)}>绑定所选规则</Btn>
{bound && <Btn variant="ghost" disabled={busy} onClick={() => onUnbind(source)}>解绑</Btn>}
</div>
{needsType && !typeKey && <div className="drw-muted">文档类型未就绪,暂不能绑定文档类型规则</div>}
</div>
)
})}
<OperationStatus state={status} onRetry={onRetry} labels={ACTION_OP_LABELS} />
</>
)
}
File diff suppressed because it is too large Load Diff
+40 -82
View File
@@ -1,9 +1,13 @@
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key);
// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。
import { useState } from 'react'
import { api } from '../api'
// LoginPage —— 用户名+密码唯一入口;
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
// grant_setup 与 one_time_password 免当前密码,current_required 必须校验当前密码
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
import { useEffect, useState } from 'react'
import { api, type PasswordChangeMode } from '../api'
import { Icon, BrandMark } from '@ui'
import { ForcedPasswordModeSection, isForcedPasswordMode, requiresCurrentPassword, submitForcedPassword } from './passwordChangeMode'
import { humanChangePasswordError, humanLoginError } from './loginErrors'
function Err({ msg }: { msg: string }) {
if (!msg) return null
@@ -31,10 +35,8 @@ function BrandPanel() {
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
const [tab, setTab] = useState<'password' | 'loginkey'>('password')
const [owner, setOwner] = useState('')
const [password, setPassword] = useState('')
const [key, setKey] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
@@ -54,74 +56,60 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
} finally { setBusy(false) }
}
const submitKey = async () => {
setErr(''); setBusy(true)
try {
// 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。
const me = await api.redeemLoginKey(key.trim())
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
onMustChange(me.owner)
return
}
onAuthed()
} catch (e) {
setErr(humanRedeemError(String(e)))
} finally { setBusy(false) }
}
return (
<div className="lg-wrap">
<BrandPanel />
<div className="lg-formside">
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
<form className="lg-card" onSubmit={e => { e.preventDefault(); submitPassword() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">登录</h2>
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key</p>
<div className="lg-tabs" role="tablist">
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
</div>
{tab === 'password' ? (
<>
<label className="lg-label">用户名</label>
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
<label className="lg-label">密码</label>
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
</>
) : (
<>
<label className="lg-label">一次性登录 Key</label>
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
<p className="lg-hint">忘记密码时由管理员签发;单次有效、默认 30 分钟内兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
</>
)}
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员重置临时密码</p>
<label className="lg-label">用户名</label>
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
<label className="lg-label">密码</label>
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
</form>
</div>
</div>
)
}
/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。
* 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框;
* 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
const [mode, setMode] = useState<PasswordChangeMode | null>(null)
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
useEffect(() => {
let active = true
api.me().then(me => {
if (!active) return
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
!isForcedPasswordMode(me.passwordChangeMode)) {
setErr('当前设密凭据不再有效,请重新登录。')
return
}
setMode(me.passwordChangeMode)
}).catch(() => { if (active) setErr('无法确认当前设密方式,请重新登录。') })
return () => { active = false }
}, [owner])
const submit = async () => {
setErr('')
if (!mode) { setErr('设密方式尚未由服务端确认'); return }
if (requiresCurrentPassword(mode) && !current) { setErr('请填写当前密码'); return }
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.changePassword('', next)
await submitForcedPassword(mode, current, next)
setDone(true)
} catch (e) {
setErr(humanChangePasswordError(String(e)))
@@ -143,13 +131,14 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">设置新密码</h2>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续;无需输入当前密码</p>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
<ForcedPasswordModeSection mode={mode} current={current} onCurrentChange={setCurrent} />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
<button className="lg-submit" type="submit" disabled={busy || !mode || (requiresCurrentPassword(mode) && !current) || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
</form>
)}
@@ -157,34 +146,3 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
</div>
)
}
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
export function humanRedeemError(raw: string): string {
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
* 不能沿用登录的「用户名或密码不正确」一刀切。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
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 '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
+63
View File
@@ -0,0 +1,63 @@
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
import { useEffect, useRef, useState } from 'react'
import { api, type AccountProfile, type AccountRecord } from '../api'
import { useStore } from '../store'
import { Collapsible, Icon, SettingsGroup } from '@ui'
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
]
export function MyAccountSettings() {
const me = useStore(s => s.me)
const [account, setAccount] = useState<AccountRecord | null>(null)
const [draft, setDraft] = useState<AccountProfile | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const sequence = useRef(0)
useEffect(() => {
if (!me?.auth) return
const seq = ++sequence.current
api.meProfile().then(profile => {
if (seq !== sequence.current) return
setAccount(profile); setDraft(profile.profile)
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
return () => { sequence.current++ }
}, [me?.owner, me?.auth])
if (!me?.auth) return null
const save = async () => {
if (!account || !draft) return
const patch: Partial<AccountProfile> = {}
for (const field of fields) if (draft[field.key] !== account.profile[field.key]) patch[field.key] = draft[field.key]
if (Object.keys(patch).length === 0) { setNotice('资料没有变化'); return }
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.patchMeProfile(account.profileVersion, patch)
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存')
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
finally { setBusy(false) }
}
return <SettingsGroup title="我的账号">
{account && draft ? <>
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空。</p>
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
<input id={`my-profile-${field.key}`} className="lg-input" value={draft[field.key]}
onChange={event => setDraft({ ...draft, [field.key]: event.target.value })} />
</div>)}
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
</Collapsible>
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密),原密码随生成即失效。</p>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
</SettingsGroup>
}
+7 -4
View File
@@ -24,6 +24,7 @@ export function PreviewPane() {
})
: undefined
const art = active ? artifacts.find(a => a.id === active.id) : undefined
const contentUrl=art?api.artifactContentUrl(art.id)+(active?.sourceContext?.expectedHash?`?expectedHash=${encodeURIComponent(active.sourceContext.expectedHash)}`:''):''
const textKind = !!art && isTextKind(art)
const [content, setContent] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
@@ -35,12 +36,12 @@ export function PreviewPane() {
setContent(null)
if (!art || !isTextKind(art)) return
let cancelled = false
fetch(api.artifactContentUrl(art.id))
.then(r => r.text())
fetch(contentUrl)
.then(r => {if(!r.ok)throw new Error('source unavailable');return r.text()})
.then(text => { if (!cancelled) setContent(text) })
.catch(() => { if (!cancelled) setContent('(读取失败)') })
return () => { cancelled = true }
}, [art?.id])
}, [art?.id,contentUrl])
if (!active) return null
if (active.id.startsWith('surface:')) {
@@ -96,6 +97,7 @@ export function PreviewPane() {
await api.saveArtifactContent(art.id, content ?? '')
setDirty(false)
store.toast('已保存')
const conv=store.getState().currentConvId;if(conv)void store.refreshCards(conv)
}}>保存</Btn>}
</>
)}
@@ -108,6 +110,7 @@ export function PreviewPane() {
</>
)}
</div>
{active.sourceContext&&<div className="acard-status-note" role="note"><p>{active.sourceContext.notice}</p>{active.sourceContext.quote&&<blockquote>{active.sourceContext.quote}</blockquote>}</div>}
<div className="preview-pane-body">
{editing && textKind ? (
<textarea className="search-input preview-pane-editor" value={content ?? ''}
@@ -118,7 +121,7 @@ export function PreviewPane() {
id: art.id,
name: art.name,
mime: art.mime,
contentUrl: api.artifactContentUrl(art.id),
contentUrl,
downloadUrl: api.artifactDownloadUrl(art.id),
}} />
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { startLatestFetch, TreeNodeRow } from './RdmAuditWorkspace'
function deferred<T>() {
let resolve!: (v: T) => void
let reject!: (e: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
const flush = () => new Promise(r => setTimeout(r, 0))
describe('startLatestFetch(历史请求单一所有者)', () => {
it('较新的请求已发起时丢弃过期响应', async () => {
const ref = { current: 0 }
const onData = vi.fn()
const onError = vi.fn()
const older = deferred<string>()
const newer = deferred<string>()
startLatestFetch(ref, () => older.promise, onData, onError)
startLatestFetch(ref, () => newer.promise, onData, onError)
newer.resolve('newer')
await flush()
older.resolve('older')
await flush()
expect(onData.mock.calls).toEqual([['newer']])
expect(onError).not.toHaveBeenCalled()
})
it('过期请求的失败不写回错误', async () => {
const ref = { current: 0 }
const onError = vi.fn()
const older = deferred<string>()
startLatestFetch(ref, () => older.promise, vi.fn(), onError)
startLatestFetch(ref, () => new Promise<string>(() => {}), vi.fn(), onError)
older.reject(new Error('stale'))
await flush()
expect(onError).not.toHaveBeenCalled()
})
it('清理(卸载或参数变化)后在途响应被丢弃', async () => {
const ref = { current: 0 }
const onData = vi.fn()
const pending = deferred<string>()
const cleanup = startLatestFetch(ref, () => pending.promise, onData, vi.fn())
cleanup()
pending.resolve('late')
await flush()
expect(onData).not.toHaveBeenCalled()
})
it('只推进自己的 ref,不影响其他 seq 所有者', () => {
const historyRef = { current: 0 }
const eventsRef = { current: 7 }
startLatestFetch(historyRef, () => new Promise<string>(() => {}), vi.fn(), vi.fn())
expect(historyRef.current).toBe(1)
expect(eventsRef.current).toBe(7)
})
})
describe('TreeNodeRow 无障碍属性', () => {
const noop = () => {}
const render = (item: { kind: string; id: string; title: string; hasChildren?: boolean }, children = {}) =>
renderToStaticMarkup(<TreeNodeRow item={item} children={children} onSelect={noop} onLoadMore={noop} onOpenDoc={noop} />)
it('可展开行:折叠时 aria-expanded=false,箭头 aria-hidden', () => {
const html = render({ kind: 'spine', id: 's1', title: '骨架' })
expect(html).toContain('aria-expanded="false"')
expect(html).toMatch(/<span class="rdm-audit-tree-caret" aria-hidden="true">▸<\/span>/)
})
it('可展开行:已加载下级时 aria-expanded=true', () => {
const html = render({ kind: 'spine', id: 's1', title: '骨架' }, { s1: { items: [], hasMore: false, truncated: false } })
expect(html).toContain('aria-expanded="true"')
expect(html).toMatch(/<span class="rdm-audit-tree-caret" aria-hidden="true">▾<\/span>/)
})
it('不可展开行省略 aria-expanded 与箭头', () => {
const html = render({ kind: 'doc', id: 'd1', title: '文档' })
expect(html).not.toContain('aria-expanded')
expect(html).not.toContain('rdm-audit-tree-caret')
})
})
+371 -106
View File
@@ -5,19 +5,35 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
import {
describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable,
relationOtherEndpoint,
type AuditEventViewInput,
} from '../rdmAuditPresentation'
import {
collectRecursiveTree, isTreeItemInspectable, mergeTreePage,
type AuditTreeItem, type AuditTreePage,
} from '../rdmAuditTree'
import { store, useStore } from '../store'
import { exportNeedsDraft } from '../workspaces'
import {
CITATION_CHIP_TONE, citationStateLabel, citationStyle, legStateLabel, REG_TREE_KIND_LABELS,
regsTreeParams, sealStateLabel,
type RegBatchDetail, type RegCitationDetail,
} from '../regCitations'
import { DocumentRenderWorkspace, saveBase64File } from './DocumentRenderWorkspace'
import { CitationCard, CitationListItem, CitationStateChips } from './RegCitation'
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
interface InspectProject { projectId: string; name: string; canAudit: boolean }
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
interface CoverageItem { face: string; reason: string }
interface ListShell<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
interface ListShell<T = any> { items: T[]; businessAudit?: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
interface TreeItem { kind: string; id: string; title: string; state?: string }
type TreeItem = AuditTreeItem
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
interface EventDTO extends AuditEventViewInput {}
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
@@ -32,6 +48,23 @@ const TABS = [
] as const
type TabId = (typeof TABS)[number]['id']
const OPERATION_FILTERS = [
['', '全部操作'],
['doc.instantiate', '创建文档实例'],
['section.add', '新增文档章节'],
['slice.ingest', '新增项目资料'],
['edge.candidate', '提出追溯候选'],
['edge.confirm', '确认追溯关系'],
['change.requested', '提出变更申请'],
['change.applied', '应用变更'],
['open-item.add', '登记项目问题'],
['open-item.close', '关闭项目问题'],
['rdm.work.observe', '拆解工作来源'],
['rdm.work.risk', '发现项目风险'],
['rdm.work.risk.answer', '回答风险澄清'],
['rdm.structure.decide', '决定结构变更'],
] as const
/** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断;
* 「无可审计项目」由调用处独立空态承担,不走本组件。 */
function RdmErrorState({ raw }: { raw: string }) {
@@ -93,7 +126,7 @@ export function RdmAuditWorkspace() {
</div>
<div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
{tab === 'materials' && <MaterialsTab pid={pid} />}
{tab === 'materials' && <MaterialsTab key={pid} pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />}
</div>
@@ -169,19 +202,6 @@ interface ExportDownloaded {
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)
@@ -231,7 +251,7 @@ function ExportSection({ pid }: { pid: string }) {
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)
saveBase64File(dl.fileName, dl.mime, dl.dataB64)
} catch (e) {
if (current()) setErr(e instanceof Error ? e.message : String(e))
} finally {
@@ -275,33 +295,221 @@ function ExportSection({ pid }: { pid: string }) {
// ---- 资料与文档 ----
function MaterialsTab({ pid }: { pid: string }) {
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
const [kind, setKind] = useState<'notes' | 'docs' | 'regs'>('docs')
// 打开文档时树列表保持挂载(只隐藏),返回后展开状态不丢。
const [openDoc, setOpenDoc] = useState<TreeItem | null>(null)
return (
<div className="rdm-audit-materials">
<div className="rdm-audit-materials-side">
{openDoc && (
<DocumentRenderWorkspace projectId={pid} docId={openDoc.id} docTitle={openDoc.title} onBack={() => setOpenDoc(null)} />
)}
<div className="rdm-audit-materials-side" hidden={!!openDoc}>
<div className="tabs">
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
<button className={`tab${kind === 'regs' ? ' active' : ''}`} onClick={() => setKind('regs')}>法规核验</button>
</div>
<TreeList pid={pid} kind={kind} />
{kind === 'regs' ? <RegsTreeList pid={pid} /> : <TreeList pid={pid} kind={kind} onOpenDoc={setOpenDoc} />}
</div>
</div>
)
}
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
// ---- 法规核验树(U7:rdm.inspect.tree kind=regs;根层证据批,parent=<batchId> 列引用) ----
function RegTreeNode({ item, selected, onSelect }: { item: TreeItem; selected: boolean; onSelect: () => void }) {
return (
<button className={`rdm-audit-tree-node${selected ? ' sel' : ''}`} onClick={onSelect}>
<span className="rdm-audit-tree-kind">{REG_TREE_KIND_LABELS[item.kind] || item.kind}</span>
<span className="rdm-audit-tree-title">{item.title || item.id}</span>
{item.state && <Chip tone={CITATION_CHIP_TONE[citationStyle({ state: item.state })]}>{citationStateLabel(item.state)}</Chip>}
</button>
)
}
function RegsTreeList({ pid }: { pid: string }) {
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
const [err, setErr] = useState('')
const [children, setChildren] = useState<Record<string, ListShell<TreeItem>>>({})
const [selected, setSelected] = useState<TreeItem | null>(null)
const [detail, setDetail] = useState<RegBatchDetail | RegCitationDetail | null>(null)
const [detailErr, setDetailErr] = useState('')
// genRef:项目切换代际(树与子页);selRef:选中代际(快速切换时丢弃旧详情响应)。
const genRef = useRef(0)
const selRef = useRef(0)
useEffect(() => {
const gen = ++genRef.current
selRef.current++
setShell(null); setErr(''); setSelected(null); setDetail(null); setDetailErr(''); setChildren({})
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid))
.then(s => { if (gen === genRef.current) setShell({ ...s, items: s.items || [] }) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid])
const loadMore = () => {
if (!shell?.nextCursor) return
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid, { cursor: shell.nextCursor }))
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...(s.items || [])] } : s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const loadChildren = (batchId: string, cursor?: string) => {
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid, { parent: batchId, cursor }))
.then(s => {
if (gen !== genRef.current) return
setChildren(prev => {
const items = cursor ? [...(prev[batchId]?.items || []), ...(s.items || [])] : (s.items || [])
return { ...prev, [batchId]: { ...s, items } }
})
})
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const toggleBatch = (item: TreeItem) => {
if (children[item.id]) {
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
return
}
loadChildren(item.id)
}
const selectNode = (item: TreeItem) => {
setSelected(item); setDetail(null); setDetailErr('')
const gen = genRef.current
const sel = ++selRef.current
const current = () => gen === genRef.current && sel === selRef.current
api.rdmCommand<RegBatchDetail | RegCitationDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
.then(d => { if (current()) setDetail(d) })
.catch(e => { if (current()) setDetailErr(e.message) })
}
if (err) return <RdmErrorState raw={err} />
if (!shell) return <div className="empty-note">加载中…</div>
if (shell.items.length === 0) return <EmptyState icon="folder">暂无法规核验证据批</EmptyState>
return (
<div className="rdm-audit-tree">
<div className="rdm-audit-tree-list">
{shell.items.map(item => {
const sub = children[item.id]
return (
<div key={item.id}>
<div className="regcite-tree-row">
<button type="button" className="regcite-tree-caret" aria-label={sub ? '收起引用' : '展开引用'}
aria-expanded={!!sub} onClick={() => toggleBatch(item)}>{sub ? '▾' : '▸'}</button>
<RegTreeNode item={item} selected={selected?.id === item.id} onSelect={() => selectNode(item)} />
</div>
{sub && (
<div className="rdm-audit-tree-children">
{sub.items.length === 0 && <div className="empty-note">无引用</div>}
{sub.items.map(c => (
<RegTreeNode key={c.id} item={c} selected={selected?.id === c.id} onSelect={() => selectNode(c)} />
))}
{sub.hasMore && (
<button className="btn v-ghost rdm-audit-more" onClick={() => loadChildren(item.id, sub.nextCursor)}>加载更多引用</button>
)}
{sub.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
)}
</div>
)
})}
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
{selected && (
<div className="rdm-audit-detail">
<SectionLabel>核验详情</SectionLabel>
{detailErr ? <RdmErrorState raw={detailErr} />
: !detail ? <div className="empty-note">加载中…</div>
: selected.kind === 'reg-batch'
? <RegBatchView item={selected} detail={detail as RegBatchDetail} />
: <RegCitationView item={selected} detail={detail as RegCitationDetail} />}
</div>
)}
</div>
)
}
function RegBatchView({ item, detail }: { item: TreeItem; detail: RegBatchDetail }) {
const reasons = detail.stateReasons || []
const citations = detail.citations || []
const resolutions = detail.resolutions || []
return (
<div className="rdm-audit-detail-body">
<div><strong>{detail.title || item.title || item.id}</strong></div>
<div className="regcite-mono">reg-batch:{item.id}</div>
<div>状态:<CitationStateChips c={{ state: detail.state }} /></div>
{reasons.length > 0 && (
<div>
<SectionLabel>状态原因</SectionLabel>
<ul className="regcite-reasons">{reasons.map((r, i) => <li key={i}>{r}</li>)}</ul>
</div>
)}
{detail.legStates && (
<div>
<SectionLabel>来源腿</SectionLabel>
<dl className="regcite-fields">
<dt>知识库</dt><dd>{legStateLabel(detail.legStates.kb, detail.legStates.kbReason)}</dd>
<dt>网络</dt><dd>{legStateLabel(detail.legStates.web, detail.legStates.webReason)}</dd>
</dl>
</div>
)}
{detail.seal && (
<div>
<SectionLabel>封存</SectionLabel>
<dl className="regcite-fields">
<dt>状态</dt><dd>{sealStateLabel(detail.seal.state)}</dd>
<dt>密钥</dt><dd className="regcite-mono">{detail.seal.keyId || '—'}</dd>
<dt>清单哈希</dt><dd className="regcite-mono">{detail.seal.manifestSha256 || '—'}</dd>
</dl>
</div>
)}
<div>
<SectionLabel>引用({citations.length})</SectionLabel>
{citations.length === 0 && <div className="empty-note">该批没有引用</div>}
{citations.map(c => <CitationListItem key={c.id} id={c.id} citation={c} scope={`audit-${item.id}`} />)}
</div>
{resolutions.length > 0 && (
<div>
<SectionLabel>人工裁决({resolutions.length})</SectionLabel>
{resolutions.map((r, i) => (
<div key={i} className="rdm-audit-version">
{r.decision} · {r.actor} · {new Date(r.at).toLocaleString('zh-CN')}{r.note ? ` · ${r.note}` : ''}
</div>
))}
</div>
)}
</div>
)
}
function RegCitationView({ item, detail }: { item: TreeItem; detail: RegCitationDetail }) {
return (
<div className="rdm-audit-detail-body">
<div><strong>{detail.title || item.title || item.id}</strong></div>
<CitationCard id={item.id} citation={detail.citation} scope="audit" />
</div>
)
}
function TreeList({ pid, kind, onOpenDoc }: { pid: string; kind: 'notes' | 'docs'; onOpenDoc: (doc: TreeItem) => void }) {
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
const [err, setErr] = useState('')
const [selected, setSelected] = useState<TreeItem | null>(null)
const [detail, setDetail] = useState<ObjectDetail | null>(null)
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
const [detailErr, setDetailErr] = useState('')
const [children, setChildren] = useState<Record<string, AuditTreePage>>({})
const genRef = useRef(0)
const detailGenRef = useRef(0)
useEffect(() => {
const gen = ++genRef.current
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
detailGenRef.current++
setShell(null); setErr(''); setSelected(null); setDetail(null); setDetailErr(''); setChildren({})
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
.then(s => { if (gen === genRef.current) setShell(s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
@@ -315,24 +523,42 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const toggleDoc = (item: TreeItem) => {
const toggleNode = (item: TreeItem) => {
if (children[item.id]) {
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
return
}
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) })
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, parent: item.id, cursor: '' })
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s })) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const loadMoreChildren = (item: TreeItem) => {
const current = children[item.id]
if (!current?.nextCursor) return
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', {
projectId: pid, kind, parent: item.id, cursor: current.nextCursor,
})
.then(s => {
if (gen !== genRef.current) return
setChildren(prev => ({ ...prev, [item.id]: mergeTreePage(prev[item.id], s) }))
})
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const selectNode = (item: TreeItem) => {
if (item.kind === 'doc') { toggleDoc(item); return }
setSelected(item); setDetail(null)
const gen = genRef.current
if (isTreeItemExpandable(item)) toggleNode(item)
const detailGen = ++detailGenRef.current
if (!isTreeItemInspectable(item)) {
setSelected(null); setDetail(null); setDetailErr('')
return
}
setSelected(item); setDetail(null); setDetailErr('')
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
.then(d => { if (gen === genRef.current) setDetail(d) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
.then(d => { if (detailGen === detailGenRef.current) setDetail(d) })
.catch(e => { if (detailGen === detailGenRef.current) setDetailErr(e.message) })
}
if (err) return <RdmErrorState raw={err} />
@@ -342,37 +568,53 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
return (
<div className="rdm-audit-tree">
<div className="rdm-audit-tree-list">
{shell.items.map(item => (
<div key={item.id}>
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
onClick={() => selectNode(item)}>
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{item.kind}</span>
<span className="rdm-audit-tree-title">{item.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
</button>
{item.kind === 'doc' && children[item.id] && (
<div className="rdm-audit-tree-children">
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
{children[item.id].map(sub => (
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
onClick={() => selectNode(sub)}>
<span className="rdm-audit-tree-kind">{sub.kind}</span>
<span className="rdm-audit-tree-title">{sub.title}</span>
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
</button>
))}
</div>
)}
</div>
))}
{shell.items.map(item => <TreeNodeRow key={item.id} item={item} selectedId={selected?.id} children={children} onSelect={selectNode} onLoadMore={loadMoreChildren} onOpenDoc={onOpenDoc} />)}
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
{selected && (
<div className="rdm-audit-detail">
<SectionLabel>对象详情</SectionLabel>
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
{detailErr ? <RdmErrorState raw={detailErr} /> : !detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
</div>
)}
</div>
)
}
export function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore, onOpenDoc }: {
item: TreeItem
selectedId?: string
children: Record<string, AuditTreePage>
onSelect: (item: TreeItem) => void
onLoadMore: (item: TreeItem) => void
onOpenDoc: (doc: TreeItem) => void
}) {
const expandable = isTreeItemExpandable(item)
const nestedPage = children[item.id]
const nested = nestedPage?.items
const readable = describeTreeItem(item)
return (
<div>
<div className="rdm-audit-tree-row">
<button className={`rdm-audit-tree-node${selectedId === item.id ? ' sel' : ''}`} onClick={() => onSelect(item)}
aria-expanded={expandable ? Boolean(nested) : undefined}>
{expandable && <span className="rdm-audit-tree-caret" aria-hidden="true">{nested ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{readable.kind}</span>
<span className="rdm-audit-tree-title">{readable.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : item.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(item.state)}</Chip>}
</button>
{item.kind === 'doc' && (
<button type="button" className="btn v-ghost rdm-audit-tree-open" onClick={() => onOpenDoc(item)}
aria-label={`阅读与交付:${readable.title}`}>阅读与交付</button>
)}
</div>
{nested && (
<div className="rdm-audit-tree-children">
{nested.length === 0 && <div className="empty-note">无下级节点</div>}
{nested.map(child => <TreeNodeRow key={child.id} item={child} selectedId={selectedId} children={children} onSelect={onSelect} onLoadMore={onLoadMore} onOpenDoc={onOpenDoc} />)}
{nestedPage.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => onLoadMore(item)}>加载更多下级节点</button>}
{nestedPage.truncated && <div className="rdm-audit-truncated">该节点的下级内容已截断</div>}
</div>
)}
</div>
@@ -397,21 +639,17 @@ function TraceTab({ pid }: { pid: string }) {
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, sel])
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
// (parent=<docId>),按文档聚合后合并展示。
const [sections, setSections] = useState<TreeItem[]>([])
const [treeItems, setTreeItems] = useState<TreeItem[]>([])
const [pickErr, setPickErr] = useState('')
useEffect(() => {
if (sel) return
let c = false
;(async () => {
const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
const secs: TreeItem[] = []
for (const d of top.items.filter(i => i.kind === 'doc')) {
const sub = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id })
secs.push(...sub.items.filter(i => i.kind === 'section'))
}
if (!c) setSections(secs)
})().catch(() => {})
setTreeItems([]); setPickErr('')
const items = await collectRecursiveTree((parent, cursor) =>
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent, cursor }))
if (!c) setTreeItems(items.filter(item => item.kind !== 'doc'))
})().catch(e => { if (!c) setPickErr(e instanceof Error ? e.message : String(e)) })
return () => { c = true }
}, [pid, sel])
@@ -419,13 +657,14 @@ function TraceTab({ pid }: { pid: string }) {
return (
<div>
<SectionLabel>选择对象查看来源与影响</SectionLabel>
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
{pickErr ? <RdmErrorState raw={pickErr} /> : treeItems.length === 0 ? <EmptyState icon="folder">暂无可追溯对象</EmptyState> : (
<div className="rdm-audit-pick">
{sections.map(s => (
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
{s.title}
{treeItems.map(item => {
const readable = describeTreeItem(item)
return <button key={item.id} className="btn v-ghost" onClick={() => setSel({ kind: item.kind, id: item.id, title: readable.title })}>
{readable.kind} · {readable.title}
</button>
))}
})}
</div>
)}
</div>
@@ -440,7 +679,7 @@ function TraceTab({ pid }: { pid: string }) {
<div className="rdm-audit-trace-head">
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
<strong>{sel.title}</strong>
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
<Chip tone="plain">{humanizeKind(sel.kind)} · {sel.id}</Chip>
</div>
{err && <RdmErrorState raw={err} />}
{!relations && !err && <div className="empty-note">加载中…</div>}
@@ -449,11 +688,11 @@ function TraceTab({ pid }: { pid: string }) {
<div className="rdm-audit-trace-cols">
<div className="rdm-audit-trace-col">
<SectionLabel>来源依据({sources.length})</SectionLabel>
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} selected={sel} />)}
</div>
<div className="rdm-audit-trace-col">
<SectionLabel>影响对象({impacts.length})</SectionLabel>
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} selected={sel} />)}
</div>
</div>
)}
@@ -462,20 +701,35 @@ function TraceTab({ pid }: { pid: string }) {
)
}
function RelationRow({ r }: { r: RelationEntry }) {
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
function RelationRow({ r, selected }: { r: RelationEntry; selected: { kind: string; id: string } }) {
const other = relationOtherEndpoint(r, selected)
const readable = humanizeRelation(r)
return (
<div className="rdm-audit-rel">
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{readable.layer}</Chip>
<span className="rdm-audit-rel-type">{readable.type}</span>
<span className="rdm-audit-rel-endpoint">{humanizeKind(other?.kind)}“{other?.title || other?.id || '未知对象'}”{other?.ver ? `(版本 ${other.ver})` : ''}</span>
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' || r.state === 'stale' ? 'warn' : 'plain'}>{readable.state}</Chip>
</div>
)
}
// ---- 操作记录(事件时间线) ----
// 单一所有者:仅最近一次发起的请求可写回;返回的清理函数作废在途请求。
export function startLatestFetch<T>(
genRef: { current: number },
request: () => Promise<T>,
onData: (data: T) => void,
onError: (message: string) => void,
): () => void {
const gen = ++genRef.current
request()
.then(d => { if (gen === genRef.current) onData(d) })
.catch(e => { if (gen === genRef.current) onError(e instanceof Error ? e.message : String(e)) })
return () => { if (gen === genRef.current) genRef.current++ }
}
function TimelineTab({ pid }: { pid: string }) {
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
const [cursor, setCursor] = useState('')
@@ -487,6 +741,7 @@ function TimelineTab({ pid }: { pid: string }) {
const [historySeq, setHistorySeq] = useState(0)
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
const genRef = useRef(0)
const historyGenRef = useRef(0)
const load = useCallback((cur: string, replace: boolean) => {
const gen = ++genRef.current
@@ -497,7 +752,8 @@ function TimelineTab({ pid }: { pid: string }) {
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
.then(s => {
if (gen !== genRef.current) return
setItems(prev => replace ? s.items : [...prev, ...s.items])
const page = replace ? [...(s.businessAudit || []), ...s.items] : s.items
setItems(prev => replace ? page : [...prev, ...page])
setCursor(s.nextCursor || '')
setHasMore(s.hasMore)
})
@@ -508,12 +764,9 @@ function TimelineTab({ pid }: { pid: string }) {
useEffect(() => {
if (!historyMode) { setHistoryData(null); return }
const gen = genRef.current
setHistoryData(null)
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
.then(d => { if (gen === genRef.current) setHistoryData(d) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
return startLatestFetch(historyGenRef, () => api.rdmCommand<HistoryData>('rdm.inspect.history', params), setHistoryData, setErr)
}, [pid, historyMode, historySeq])
return (
@@ -521,8 +774,9 @@ function TimelineTab({ pid }: { pid: string }) {
<div className="rdm-audit-timeline-filters">
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
onChange={e => setFilterActor(e.target.value)} />
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
onChange={e => setFilterOp(e.target.value)} />
<select className="ui-input" aria-label="按操作筛选" value={filterOp} onChange={e => setFilterOp(e.target.value)}>
{OPERATION_FILTERS.map(([value, label]) => <option key={value || 'all'} value={value}>{label}</option>)}
</select>
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
{historyMode ? '← 返回当前' : '历史模式'}
</button>
@@ -573,27 +827,38 @@ function TimelineTab({ pid }: { pid: string }) {
}
function OpGroupView({ group }: { group: OpGroup }) {
const [open, setOpen] = useState(false)
const first = group.events[0] ? describeAuditEvent(group.events[0]) : null
return (
<div className="rdm-audit-opgroup">
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
<Chip tone="plain">{group.events.length} 个事件</Chip>
</button>
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
<Collapsible id={`rdm-audit-operation-${group.operationId}`} trigger={(
<span className="rdm-audit-opgroup-head">
<span className="rdm-audit-opgroup-actor">{first?.subject || group.actor}</span>
<span className="rdm-audit-opgroup-action">{first?.action || '项目操作'}</span>
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
<Chip tone="plain">包含 {group.events.length} 项记录</Chip>
</span>
)}>
{group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
</Collapsible>
</div>
)
}
function EventRow({ ev }: { ev: EventDTO }) {
const view = describeAuditEvent(ev)
return (
<div className="rdm-audit-event">
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
<span className="rdm-audit-event-op">{ev.op}</span>
<span className="rdm-audit-event-actor">{ev.actor}</span>
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
<div className="rdm-audit-event-head">
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleString('zh-CN')}</span>
<strong className="rdm-audit-event-action">{view.action}</strong>
<Chip tone={ev.disclosure === 'ok' && view.outcome === '已生效' ? 'ok' : 'warn'}>{view.outcome}</Chip>
</div>
<div className="rdm-audit-event-summary">{view.subject}处理了{view.object}</div>
<dl className="rdm-audit-event-fields">
<div><dt>原因</dt><dd>{view.reason}</dd></div>
<div><dt>影响</dt><dd>{view.impact}</dd></div>
<div><dt>依据</dt><dd>{view.source}</dd></div>
</dl>
</div>
)
}
@@ -604,8 +869,8 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
return (
<div className="rdm-audit-detail-body">
<div><strong>{detail.title || detail.id}</strong></div>
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
<div>{humanizeKind(detail.kind)} · 对象编号 {detail.id}{detail.ver ? ` · 版本 ${detail.ver}` : ''}</div>
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : detail.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(detail.state)}</Chip></div>}
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
{detail.versions && detail.versions.length > 0 && (
<div>
@@ -625,7 +890,7 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
{detail.relations && detail.relations.length > 0 && (
<div>
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
{detail.relations.map((r, i) => <RelationRow key={i} r={r} selected={{ kind: detail.kind, id: detail.id }} />)}
</div>
)}
{detail.recentEvents && detail.recentEvents.length > 0 && (
+183
View File
@@ -0,0 +1,183 @@
// U7 法规结构化引用的共享呈现:编号标记、引用卡片、引用清单(审计工作区与驾驶舱阅读器共用)。
// 正文与原文片段一律作为文本节点渲染,不经 HTML。非 verified 状态用未核验样式,invalid 用无效样式;
// 折叠只用 @ui Collapsible;颜色/字号/圆角只消费 @ui/tokens.css(见 reg-citations.css)。
import { Fragment, useState, type ReactNode } from 'react'
import { Chip, Collapsible } from '@ui'
import {
CITATION_CHIP_TONE, citationListOrder, citationMarkText, citationStateLabel, citationStyle,
disclosureLabel, excerptView, parseCitedBlocks, safeWebHref, staleLabel,
type CitationView,
} from '../regCitations'
import '../reg-citations.css'
/** 状态徽标:核验状态 + 过期「待复核」。 */
export function CitationStateChips({ c }: { c: { state?: string; stale?: boolean; staleReason?: string } }) {
const stale = staleLabel(c)
return (
<span className="regcite-chips">
<Chip tone={CITATION_CHIP_TONE[citationStyle({ state: c.state })]}>{citationStateLabel(c.state)}</Chip>
{stale && <Chip tone="warn">{stale}</Chip>}
</span>
)
}
function citationHeading(c: CitationView): string {
const parts = [c.docNo, c.clauseNo].filter(Boolean)
return parts.length > 0 ? parts.join(' ') : (c.title || c.id)
}
function formatTime(at: string | undefined): string {
if (!at) return '—'
const d = new Date(at)
return Number.isNaN(d.getTime()) ? at : d.toLocaleString('zh-CN')
}
function Excerpt({ id, label, text, redaction }: { id: string; label: string; text?: string; redaction?: string }) {
if (!text) return <div className="regcite-redaction">{label}未展示:{redaction}</div>
return (
<Collapsible id={id} trigger={<span className="regcite-toggle">{label}</span>}>
<blockquote className="regcite-excerpt">{text}</blockquote>
</Collapsible>
)
}
/** 引用卡片:元数据 + 可展开原文片段(受披露约束);缺失或 invalid 的 ID 只给无效说明。 */
export function CitationCard({ id, citation, scope }: { id: string; citation?: CitationView; scope: string }) {
const style = citationStyle(citation)
if (!citation || style === 'invalid') {
return (
<div className="regcite-card regcite-card-invalid">
<div className="regcite-card-head">
<Chip tone="danger">无效引用</Chip>
<span className="regcite-mono">{id}</span>
</div>
<div className="regcite-note">
{citation ? '引用 ID 无法解析为证据批' : '本节引用清单中没有该引用'}——不得作为法律依据
</div>
</div>
)
}
const web = citation.web
const href = safeWebHref(web?.finalUrl || web?.url)
const fields: [string, ReactNode][] = []
const add = (label: string, v: ReactNode) => { if (v !== undefined && v !== null && v !== '') fields.push([label, v]) }
add('文号', citation.docNo)
add('标题', citation.title)
add('版本', citation.editionLabel)
add('效力状态', citation.legalStatus)
add('条款', citation.clauseNo)
add('章节路径', citation.headingPath?.length ? citation.headingPath.join(' › ') : undefined)
add('语料修订', citation.corpusRevision && <span className="regcite-mono">{citation.corpusRevision}</span>)
add('原文披露', citation.disclosure && disclosureLabel(citation.disclosure))
if (web) {
add('网络来源', href
? <a className="regcite-link" href={href} target="_blank" rel="noopener noreferrer">{web.finalUrl || web.url}</a>
: web.finalUrl || web.url)
add('官方来源', web.official ? '是' : '否')
add('抓取时间', formatTime(web.fetchedAt))
}
add('证据批', citation.batchId && <span className="regcite-mono">{citation.batchId}</span>)
add('引用 ID', <span className="regcite-mono">{citation.id}</span>)
const ex = excerptView(citation)
return (
<div className={`regcite-card regcite-card-${style}`}>
<div className="regcite-card-head">
<span className="regcite-card-title">{citationHeading(citation)}</span>
<CitationStateChips c={citation} />
</div>
{style !== 'valid' && <div className="regcite-note">未核验引用——不得作为有效法律依据</div>}
{citation.claim && <div className="regcite-claim">主张:{citation.claim}</div>}
<dl className="regcite-fields">
{fields.map(([label, v]) => (
<Fragment key={label}><dt>{label}</dt><dd>{v}</dd></Fragment>
))}
</dl>
<Excerpt id={`regcite-excerpt-${scope}-${citation.id}`} label="原文片段"
text={'excerpt' in ex ? ex.excerpt : undefined}
redaction={'redaction' in ex ? ex.redaction : undefined} />
{web?.excerpt && (
<Excerpt id={`regcite-web-${scope}-${citation.id}`} label="网页摘录" text={web.excerpt} />
)}
</div>
)
}
/** 引用清单一行:摘要触发 + 折叠卡片。 */
export function CitationListItem({ id, n, citation, scope }: { id: string; n?: number; citation?: CitationView; scope: string }) {
const style = citationStyle(citation)
return (
<div className={`regcite-item regcite-item-${style}`}>
<Collapsible id={`regcite-item-${scope}-${id}`} trigger={
<span className="regcite-item-head">
{n !== undefined && <span className="regcite-item-num">[{n}]</span>}
<span className="regcite-item-title">{citation && style !== 'invalid' ? citationHeading(citation) : id}</span>
{citation && style !== 'invalid' && citation.title && citation.title !== citationHeading(citation) && (
<span className="regcite-item-sub">{citation.title}</span>
)}
{citation && style !== 'invalid'
? <CitationStateChips c={citation} />
: <Chip tone="danger">无效引用</Chip>}
</span>
}>
<CitationCard id={id} citation={citation} scope={scope} />
</Collapsible>
</div>
)
}
/** 带引用的正文:[cit-…] 渲染为编号标记,点击在段落下展开引用卡片;正文下附引用清单。 */
export function CitedBody({ texts, citations, scope, paraClassName = 'cockpit-reader-para' }: {
texts: string[]
citations?: CitationView[] | null
scope: string
paraClassName?: string
}) {
const [open, setOpen] = useState<string | null>(null)
const { blocks, order } = parseCitedBlocks(texts)
const index = new Map((citations || []).map(c => [c.id, c]))
const list = citationListOrder(order, citations)
return (
<>
{blocks.map((segs, bi) => {
const ids = [...new Set(segs.flatMap(s => (s.kind === 'cite' ? [s.id] : [])))]
if (ids.length === 0) {
return <p key={bi} className={paraClassName}>{segs.map(s => (s.kind === 'text' ? s.text : '')).join('')}</p>
}
return (
<div key={bi} className="regcite-block">
<p className={paraClassName}>
{segs.map((s, si) => {
if (s.kind === 'text') return <Fragment key={si}>{s.text}</Fragment>
const c = index.get(s.id)
const key = `${bi}:${s.id}`
const isOpen = open === key
return (
<button key={si} type="button"
className={`regcite-mark regcite-${citationStyle(c)}${isOpen ? ' regcite-mark-open' : ''}`}
aria-expanded={isOpen}
title={`引用 ${s.n}:${c ? citationStateLabel(c.state) : '无效引用'}`}
onClick={() => setOpen(isOpen ? null : key)}>
[{citationMarkText(s.n, c)}]
</button>
)
})}
</p>
{ids.map(id => (
<Collapsible key={id} open={open === `${bi}:${id}`}>
<CitationCard id={id} citation={index.get(id)} scope={`${scope}-inline`} />
</Collapsible>
))}
</div>
)
})}
{list.length > 0 && (
<div className="regcite-list">
<div className="regcite-list-head">引用({list.length})</div>
{list.map((id, i) => (
<CitationListItem key={id} id={id} n={i + 1} citation={index.get(id)} scope={scope} />
))}
</div>
)}
</>
)
}
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { CapabilityChangePreview, CapabilityGroupEditor, rolePermissionSaveError } from './RolePermissionsPanel'
import {
buildCapabilityGroups, capabilityGroupChanges, createRolePermissionDraft, linkedChainBreaks, setCapabilityGroup,
type RolePermissionValue,
} from '../rolePermissions'
const catalog = [
{ actionId: 'project.create', label: '创建项目', group: '项目入口' },
{ actionId: 'project.settings', label: '项目设置', group: '项目管理' },
{ actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' },
{ actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' },
{ actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' },
{ actionId: 'document.author', label: '编制文档', group: '文档动作' },
{ actionId: 'document.review', label: '审核文档', group: '文档动作' },
{ actionId: 'document.approve', label: '批准文档', group: '文档动作' },
]
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
const off: RolePermissionValue = { allowed: false, capAllowed: false }
const values = Object.fromEntries(catalog.map(item => [item.actionId, off]))
const groups = buildCapabilityGroups(catalog)
describe('CapabilityGroup role editor', () => {
it('replaces the per-capability checkbox grid with one default/cap pair per business group', () => {
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled={false} onToggle={vi.fn()} />)
expect(html.match(/type="checkbox"/g)).toHaveLength(groups.length * 2)
expect(html).toContain('创建与管理本人项目 默认授予')
expect(html).toContain('文档编制与审签 角色上限')
expect(html).not.toContain('编制文档 默认允许')
expect(html).not.toContain('管理项目成员 默认授予')
})
it('shows linked own-project management as controlled items with their association source', () => {
const create = groups.find(group => group.id === 'project-create')!
const linked = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true).values
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={linked} labels={labels} disabled onToggle={vi.fn()} />)
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
expect(html).toContain('分配文档三权:已联动')
const before = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled onToggle={vi.fn()} />)
expect(before).toContain('未联动(开启创建项目后自动补齐)')
})
it('marks a partially granted group instead of hiding the difference', () => {
const partial = { ...values, 'document.author': { allowed: true, capAllowed: true } }
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={partial} labels={labels} disabled={false} onToggle={vi.fn()} />)
expect(html).toContain('默认授予(部分)')
})
it('previews the grouped difference with the affected source and affected user count', () => {
const create = groups.find(group => group.id === 'project-create')!
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
labels={labels} roleName="项目经理" bindingCount={7} />)
expect(html).toContain('影响来源:职位角色「项目经理」 · 影响 7 名绑定用户')
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
expect(html).toContain('联动补齐 项目设置、管理项目成员、管理项目权限、分配文档三权(仅本人创建的项目)')
})
it('keeps the draft and names a linked conflict the server rejected as a whole', () => {
expect(rolePermissionSaveError(new Error('422: linked capability conflict project.members.manage')))
.toMatch(/关联能力冲突.*整笔拒绝.*本地草稿已保留/)
expect(rolePermissionSaveError(new Error('500: boom'))).toContain('本地草稿已保留')
})
it('keeps every action label visible inside the linked-source list once project creation opens', () => {
const create = groups.find(group => group.id === 'project-create')!
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={draft.values} labels={labels} disabled onToggle={vi.fn()} />)
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
expect(html).toContain('项目设置:已联动')
expect(html).toContain('管理项目权限:已联动')
expect(html).toContain('分配文档三权:已联动')
})
it('splits every preview row by business group when multiple groups change together', () => {
const create = groups.find(group => group.id === 'project-create')!
const duties = groups.find(group => group.id === 'document-duties')!
let draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
draft = setCapabilityGroup(draft, duties, 'allowed', true)
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
labels={labels} roleName="项目经理" bindingCount={7} />)
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
expect(html).toContain('文档编制与审签:默认 拒绝 → 允许')
})
it('treats a 403 the same as a 400 for linked-capability rejection phrasing', () => {
expect(rolePermissionSaveError(new Error('403: linked capability conflict'))).toMatch(/关联能力冲突/)
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).toContain('本地草稿已保留')
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).not.toMatch(/关联能力冲突/)
})
})
+409
View File
@@ -0,0 +1,409 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
import {
buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft, ensureLinkedCapabilities,
linkedChainBreaks, rebaseRoleDraft, setCapabilityGroup, validateRolePermissionDraft, visiblePatch,
type CapabilityGroup, type CapabilityGroupChange, type GroupLevel, type RolePermissionDraft, type RolePermissionValue,
} from '../rolePermissions'
import { Collapsible, Icon } from '@ui'
import { useStore } from '../store'
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
export function filterRoles(
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
): RoleRecord[] {
const normalized = query.trim().toLocaleLowerCase()
return roles.filter(role => {
if (status === 'enabled' && !role.enabled) return false
if (status === 'disabled' && role.enabled) return false
if (!normalized) return true
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
return mode === 'exact' ? fields.some(value => value === normalized) : fields.some(value => value.includes(normalized))
})
}
export function groupPermissionPage(catalog: PermissionDescriptor[], group: string, page: number, size: number): PermissionDescriptor[] {
const grouped = catalog.filter(item => !group || item.group === group)
return grouped.slice(page * size, page * size + size)
}
export function shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean {
return activeSequence === responseSequence
}
export function hasSystemAction(actions: string[] | undefined, action: string): boolean {
return actions?.includes(action) ?? false
}
function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<string, RolePermissionValue> {
const existing = new Map(role.permissions.map(item => [item.actionId, item]))
return Object.fromEntries(catalog.filter(item => !item.protected).map(item => {
const value = existing.get(item.actionId)
return [item.actionId, { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }]
}))
}
const LEVEL_TEXT: Record<GroupLevel, string> = { on: '允许', off: '拒绝', mixed: '部分' }
function linkedStatus(values: Record<string, RolePermissionValue>, actionId: string, createOn: boolean): string {
if (values[actionId]?.capAllowed) return '已联动(来源:创建项目,仅本人创建的项目)'
return createOn ? '待联动补齐(保存时随创建项目一并补齐)' : '未联动(开启创建项目后自动补齐)'
}
/** 业务能力组:组开关替代逐动作勾选;联动项为组内受控项,只展示来源。 */
export function CapabilityGroupEditor({ groups, values, labels, disabled, onToggle }: {
groups: CapabilityGroup[]
values: Record<string, RolePermissionValue>
labels: Record<string, string>
disabled: boolean
onToggle: (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => void
}) {
return <div className="rp-groups">{groups.map(group => {
const state = capabilityGroupState(values, group)
const createOn = state.allowed !== 'off' || state.capAllowed !== 'off'
return <section key={group.id} className="settings-group" aria-label={`能力组 ${group.label}`}>
<div className="ad-row">
<b title={`内部动作 ${group.actions.join('、')}`}>{group.label}</b>
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 默认授予`} checked={state.allowed === 'on'} disabled={disabled}
onChange={event => onToggle(group, 'allowed', event.target.checked)} />默认授予{state.allowed === 'mixed' ? '(部分)' : ''}</label>
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 角色上限`} checked={state.capAllowed === 'on'} disabled={disabled}
onChange={event => onToggle(group, 'capAllowed', event.target.checked)} />角色上限{state.capAllowed === 'mixed' ? '(部分)' : ''}</label>
</div>
{group.description && <p className="ad-hint">{group.description}</p>}
{group.linked.length > 0 && <ul className="rp-diff" aria-label={`${group.label} 联动项`}>{group.linked.map(id =>
<li key={id}>{labels[id] ?? id}:{linkedStatus(values, id, createOn)}</li>)}</ul>}
</section>
})}</div>
}
/** 变更预览:按业务组列出前后差异、联动补齐项、影响来源与受影响人数。 */
export function CapabilityChangePreview({ changes, breaks, labels, roleName, bindingCount }: {
changes: CapabilityGroupChange[]
breaks: string[]
labels: Record<string, string>
roleName: string
bindingCount: number
}) {
if (changes.length === 0 && breaks.length === 0) return <p className="ad-hint">没有权限变化</p>
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
return <div>
<p className="ad-hint" role="note">影响来源:职位角色「{roleName}」 · 影响 {bindingCount} 名绑定用户</p>
{breaks.length > 0 && <p className="ad-hint" role="note">关联待补齐:{names(breaks)}——保存时随「创建项目」一并补齐上限</p>}
<ul className="rp-diff">{changes.map(change => <li key={change.group.id}>
{change.group.label}:默认 {LEVEL_TEXT[change.before.allowed]} → {LEVEL_TEXT[change.after.allowed]},上限 {LEVEL_TEXT[change.before.capAllowed]} → {LEVEL_TEXT[change.after.capAllowed]}
{change.linkedAdded.length > 0 && <>;联动补齐 {names(change.linkedAdded)}(仅本人创建的项目)</>}
</li>)}</ul>
</div>
}
export function rolePermissionSaveError(error: unknown): string {
const raw = String(error)
if (/\b(400|403|422)\b/.test(raw) && /(link|conflict|关联|冲突|cap)/i.test(raw)) {
return `关联能力冲突,服务端已整笔拒绝,未保存任何修改;本地草稿已保留,请调整冲突项后重试(${raw.replace(/^Error:\s*/, '')})`
}
return `${raw.replace(/^Error:\s*/, '')};本地草稿已保留`
}
export function RolePermissionsPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
const canManageRoles = allowed('system.roles.manage')
const canViewPermissions = allowed('system.permissions.view')
const canManagePermissions = canViewPermissions && allowed('system.permissions.manage')
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [selectedId, setSelectedId] = useState('')
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
const [confirmDisable, setConfirmDisable] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [actionFilter, setActionFilter] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [view, setView] = useState<'single' | 'matrix'>('single')
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [busy, setBusy] = useState(false)
const [roleLoading, setRoleLoading] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const requestSequence = useRef(0)
const selectedIdRef = useRef('')
const savePermsRef = useRef<HTMLButtonElement>(null)
selectedIdRef.current = selectedId
const selected = roles?.find(role => role.roleId === selectedId) ?? null
const draft = selected ? drafts[selected.roleId] ?? null : null
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
const filtered = roles ?? []
const capabilityGroups = useMemo(() => buildCapabilityGroups(catalog.filter(item => !item.protected)), [catalog])
const labels = useMemo(() => Object.fromEntries(catalog.map(item => [item.actionId, item.label])), [catalog])
const changes = draft ? capabilityGroupChanges(draft, capabilityGroups) : []
const breaks = draft ? linkedChainBreaks(draft.values) : []
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
filters = { query, mode, status, action: actionFilter }) => {
const sequence = ++requestSequence.current
setRoleLoading(true)
setErr('')
const [roleResult, catalogResult] = await Promise.allSettled([
api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status,
action: filters.action || undefined, cursor: pageCursor, limit: 50 }),
canViewPermissions && (refreshCatalog || catalog.length === 0) ? api.permissionCatalog() : Promise.resolve(catalog),
])
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
setRoleLoading(false)
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
if (roleResult.status === 'rejected') {
setErr(String(roleResult.reason))
return
}
const rolePage = roleResult.value
setRoles(rolePage.roles ?? [])
setNextRoleCursor(rolePage.nextCursor ?? '')
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
else setSelectedId('')
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
}
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
const nextSequence = sequence ?? ++requestSequence.current
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
setSelectedId(role.roleId)
setName(role.name)
setDescription(role.description)
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
setDrafts(prev => prev[role.roleId] ? prev
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
setNotice('')
setErr('')
}
// DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
useEffect(() => {
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
roleDraftLeaveGuard.current = () => window.confirm(message)
return () => { roleDraftLeaveGuard.current = null }
}, [dirty, selected?.roleId, selected?.name])
useEffect(() => {
if (!dirty) return
const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [dirty])
useEffect(() => {
setNextRoleCursor(''); setRoleLoading(true)
const timer = window.setTimeout(() => { void load(roleCursor) }, 180)
return () => { window.clearTimeout(timer); requestSequence.current++ }
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
const create = async () => {
if (!newName.trim()) return
requestSequence.current++; setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
const role = await api.createRole(newName.trim(), newDescription.trim())
setNewName(''); setNewDescription('')
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
selectRole(role, catalog, ++requestSequence.current)
setNotice(`已创建角色 ${role.name}`)
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
setNotice(`已创建角色 ${role.name}`)
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
}
const saveMetadata = async () => {
if (!selected) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
await load(); setNotice('角色资料已保存')
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
}
finally { setBusy(false) }
}
const setEnabled = async () => {
if (!selected) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
await load(); setNotice(selected.enabled ? '角色已停用,现有绑定不再授予权限' : '角色已启用')
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
} finally { setBusy(false) }
}
const rebasePermissionConflict = async (roleId: string, currentDraft: RolePermissionDraft, operationSequence: number) => {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === roleId)
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
}
const savePermissions = async () => {
if (!selected || !draft) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
// 关联项不能以断链状态保存:提交前与服务端同规则补齐创建能力的联动上限。
const prepared = ensureLinkedCapabilities(draft)
const invalid = validateRolePermissionDraft(prepared)
if (invalid) { setErr(`${labels[invalid.actionId] ?? invalid.actionId} 的默认权限不能超过角色上限`); return }
const full = Object.entries(prepared.values).map(([actionId, value]) => ({ actionId, ...value }))
setBusy(true); setErr(''); setNotice('')
try {
const receipt = await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
setDrafts(current => {
if (!(roleId in current)) return current
const next = { ...current }; delete next[roleId]; return next
})
await load(roleCursor, true, selected.roleId)
const expanded = receipt?.linkedExpansion ?? []
setNotice(expanded.length ? `角色权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '角色权限已保存')
} catch (error) {
if (String(error).includes('409')) {
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
} else setErr(rolePermissionSaveError(error))
}
finally {
setBusy(false)
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
requestAnimationFrame(() => {
if (savePermsRef.current && !savePermsRef.current.disabled && document.activeElement === document.body) savePermsRef.current.focus()
})
}
}
const editGroup = (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => {
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: setCapabilityGroup(prev[selectedId], group, field, checked) } : prev)
}
// 矩阵按业务组汇总;只有选中角色可编辑组开关,其余角色只读摘要。
const matrix = () => <div className="rp-matrix-wrap">
<table className="ad-table rp-matrix">
<thead><tr><th>角色</th>{capabilityGroups.map(group => <th key={group.id} title={group.description}>{group.label}</th>)}</tr></thead>
<tbody>{filtered.map(role => {
const values = role.roleId === selectedId && draft ? draft.values : roleValues(role, catalog)
const editable = role.roleId === selectedId && !!draft && canManagePermissions
return <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
{capabilityGroups.map(group => {
const state = capabilityGroupState(values, group)
return <td key={group.id}>{editable ? <>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 默认授予`} checked={state.allowed === 'on'}
disabled={busy || roleLoading} onChange={event => editGroup(group, 'allowed', event.target.checked)} />默认</label>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 角色上限`} checked={state.capAllowed === 'on'}
disabled={busy || roleLoading} onChange={event => editGroup(group, 'capAllowed', event.target.checked)} />上限</label>
</> : <span className="ad-dim">默认{LEVEL_TEXT[state.allowed]} · 上限{LEVEL_TEXT[state.capAllowed]}</span>}</td>
})}
</tr>
})}</tbody>
</table>
</div>
return <div className="rp-panel">
{canManageRoles && <div className="ad-create">
<div className="ad-row">
<input className="lg-input" aria-label="新角色名称" placeholder="角色名称" value={newName} onChange={event => setNewName(event.target.value)} />
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={newDescription} onChange={event => setNewDescription(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || roleLoading || !newName.trim()} onClick={create}>创建角色</button>
</div>
</div>}
<Collapsible id="role-permission-filters" trigger="搜索与过滤">
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索角色" placeholder="角色名称、描述或 ID" value={query} disabled={busy} onChange={event => { setQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<select className="lg-input" aria-label="角色匹配方式" value={mode} disabled={busy} onChange={event => { setMode(event.target.value as typeof mode); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
</select>
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
</select>
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
</select>}
</div>
</Collapsible>
<div className="ad-row rp-view-toggle">
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
</div>
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{view === 'matrix' ? matrix() : <div className="rp-single">
<aside className="rp-role-list">
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
</button>)}
</aside>
<section className="rp-editor">
{selected && draft ? <>
<div className="ad-row">
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
{canManageRoles && (selected.enabled
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
</div>
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
</div>}
{canViewPermissions ? <>
<CapabilityGroupEditor groups={capabilityGroups} values={draft.values} labels={labels}
disabled={busy || roleLoading || !canManagePermissions} onToggle={editGroup} />
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
<CapabilityChangePreview changes={changes} breaks={breaks} labels={labels} roleName={selected.name} bindingCount={selected.bindingCount} />
</Collapsible>
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || (changes.length === 0 && breaks.length === 0) || allEditableIds.length === 0} onClick={savePermissions}>保存权限</button>}
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
</> : <p className="ad-hint">请选择角色</p>}
</section>
</div>}
</div>
}
+6 -3
View File
@@ -9,8 +9,9 @@ import type { ThemeChoice } from '../theme'
import type { SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
import { BorrowPanels } from './BorrowPanels'
import { MyAccountSettings } from './MyAccountSettings'
type Section = 'settings'
type Section = 'settings' | 'account'
// 分组显示名(项目设置面板共用)。
export const groupNames: Record<string, string> = {
@@ -19,7 +20,7 @@ export const groupNames: Record<string, string> = {
}
export function SettingsModal() {
const [section] = useState<Section>('settings')
const [section, setSection] = useState<Section>('settings')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
@@ -54,10 +55,11 @@ export function SettingsModal() {
return (
<SettingsShell
title="设置" active={section} onSelect={() => undefined}
title="设置" active={section} onSelect={key => setSection(key as Section)}
onClose={() => store.openSettings(false)}
sections={[
{ key: 'settings', label: '常规', icon: 'settings' },
{ key: 'account', label: '账号', icon: 'user' },
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
actions={section === 'settings' && schema.length > 0 ? (
@@ -108,6 +110,7 @@ export function SettingsModal() {
)
})}
{section === 'settings' && <BorrowPanels />}
{section === 'account' && <MyAccountSettings />}
</SettingsShell>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { TaskSidebar } from './TaskSidebar'
const mocked = vi.hoisted(() => ({ state: {} as Record<string, unknown> }))
vi.mock('../store', () => ({
useStore: (select: (value: Record<string, unknown>) => unknown) => select(mocked.state),
store: { openInbox: vi.fn(), openFiles: vi.fn(), focusPending: vi.fn(), setUI: vi.fn(), toggleLeft: vi.fn() },
}))
const member = { owner: 'bob', name: 'Bob', auth: true, systemActions: [],
linkedCapabilities: { projectCreate: false, linkedActions: [], authorityEpoch: 1 } }
const creator = { ...member, linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } }
function render(patch: Record<string, unknown>) {
mocked.state = {
projects: [], projectsLoaded: true, projectSync: { status: 'ok', failures: 0 }, tasks: [], me: member,
adminSections: [], artifacts: [], cards: [], projectCards: [], serverWorkspaces: [],
rdmAuditAvailable: false, rdmCockpitAvailable: false, currentConvId: null, currentProjectId: null,
ui: { theme: 'light', showArchived: false },
...patch,
}
return renderToStaticMarkup(<TaskSidebar />)
}
beforeEach(() => { mocked.state = {} })
describe('EmptyListNoCreate sidebar', () => {
it('shows a waiting-for-invite state without inviting a same-name project for a member without creation', () => {
const html = render({})
expect(html).toContain('尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。')
expect(html).not.toContain('新建项目')
expect(html).toMatch(/<button class="tsb-newtask-main" disabled=""/)
})
it('shows a loading state instead of an empty state before the list arrives', () => {
const html = render({ projectsLoaded: false })
expect(html).toContain('正在加载项目列表…')
expect(html).not.toContain('等待项目负责人邀请')
})
it('points a creator to explicit project creation', () => {
expect(render({ me: creator })).toContain('还没有项目:从「新建项目」开始。')
})
})
describe('AutoDiscover sidebar status', () => {
it('surfaces a degraded or offline project sync instead of silently showing stale projects', () => {
expect(render({ projectSync: { status: 'degraded', failures: 2 } })).toContain('项目列表同步异常,正在自动重试')
expect(render({ projectSync: { status: 'offline', failures: 0 } })).toContain('网络已断开,恢复后自动同步项目列表')
expect(render({})).not.toContain('同步异常')
})
it('keeps conversation creation available once a discovered project is current', () => {
const html = render({ projects: [{ id: 'host-b', name: '项目P', owner: 'carol' }], currentProjectId: 'host-b' })
expect(html).toMatch(/<button class="tsb-newtask-main" title="在当前项目新建对话/)
expect(html).toContain('从「新建对话」开始')
})
it('falls back to a project-less sidebar when the current discovery has no project', () => {
const html = render({ projects: [], currentProjectId: null })
expect(html).toContain('尚无项目')
expect(html).not.toContain('同步异常')
})
it('does not render a sync notice when the listing succeeded', () => {
expect(render({ projectSync: { status: 'ok', failures: 0 } })).not.toContain('同步异常')
expect(render({ projectSync: { status: 'idle', failures: 0 } })).not.toContain('同步异常')
})
})
+55 -23
View File
@@ -7,7 +7,9 @@ import { api } from '../api'
import { Icon } from '@ui'
import type { Conversation } from '../contracts'
import { relativeTime, filterTasks } from '../tasklist'
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
import {
canCreateProject, emptyProjectsNotice, projectCapabilitySummary, projectStage, projectStateLabel, projectWorkError,
} from '../projectmeta'
import { buildWorkspaceEntries } from '../workspaces'
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
@@ -53,11 +55,15 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const artifacts = useStore(s => s.artifacts)
const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
const scratchMode = useStore(s => !!s.scratchMode)
const showArchived = useStore(s => s.ui.showArchived)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
const theme = useStore(s => s.ui.theme)
const projectsLoaded = useStore(s => !!s.projectsLoaded)
const syncStatus = useStore(s => s.projectSync?.status ?? 'idle')
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
s.projectCards.filter(c => c.status === 'action_required').length)
@@ -69,13 +75,15 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const createConversation = async (projectId?: string) => {
try {
if (projectId && scratchMode) await store.openProject(projectId)
await store.createTask(projectId)
onConversationOpen?.()
} catch { /* store owns the actionable failure toast */ }
}
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable, rdmCockpit: rdmCockpitAvailable })
const scratchTasks = tasks.filter(t => t.scratch)
const visible = filterTasks(scratchMode ? scratchTasks : tasks.filter(t => t.projectId === currentProjectId && !t.scratch), q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const currentProject = projects.find(p => p.id === currentProjectId)
@@ -83,6 +91,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
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
const canCreate = canCreateProject(me)
const noProjects = projects.length === 0
const syncNotice = syncStatus === 'degraded' ? '项目列表同步异常,正在自动重试'
: syncStatus === 'offline' ? '网络已断开,恢复后自动同步项目列表' : null
const rowActions = (t: Conversation) => (
<span className="tsb-row-acts">
@@ -90,10 +102,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
</button>
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
{!t.scratch && <button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
<Icon name="shield" size={13} />
</button>
</button>}
</span>
)
@@ -112,7 +124,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
<Icon name="message" size={14} />
<span className="tsb-task-t" title={t.title}>{t.title || '未命名对话'}</span>
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
{t.crossPrivate && !t.scratch && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
</button>
{rowActions(t)}
@@ -127,9 +139,9 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<div className="tsb-brand">
<button className="tsb-projrow" title="切换项目"
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
<Icon name="folder" size={15} />
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
{stage && <span className="tsb-projrow-stage">{stage}</span>}
<Icon name={scratchMode ? 'message' : 'folder'} size={15} />
<span className="tsb-projrow-name">{scratchMode ? '无项目对话' : currentProject?.name ?? '选择项目'}</span>
{stage && !scratchMode && <span className="tsb-projrow-stage">{stage}</span>}
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button>
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={onCollapse}><Icon name="panel-left" /></button>
@@ -138,7 +150,9 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
<div className="tsb-actions">
<div className="tsb-newtask">
<button className="tsb-newtask-main" title="在当前项目新建对话(⌘N)" onClick={() => createConversation()}>
<button className="tsb-newtask-main" disabled={noProjects && !scratchMode}
title={scratchMode ? '新建无项目对话(⌘N)' : noProjects ? emptyProjectsNotice(projectsLoaded, canCreate) : '在当前项目新建对话(⌘N)'}
onClick={() => createConversation()}>
<Icon name="plus" size={15} />新建对话
</button>
<button className="tsb-newtask-caret" title="更多创建选项"
@@ -146,12 +160,12 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
</div>
<div className="tsb-search">
<Icon name="search" size={14} />
<input aria-label="搜索本项目会话" placeholder="搜索对话…" value={q} onChange={e => setQ(e.target.value)} />
<input aria-label={scratchMode ? '搜索无项目对话' : '搜索本项目会话'} placeholder="搜索对话…" value={q} onChange={e => setQ(e.target.value)} />
</div>
</div>
<div className="tsb-orgrow">
<span className="tsb-seclabel" style={{ padding: 0 }}>本项目会话</span>
<span className="tsb-seclabel" style={{ padding: 0 }}>{scratchMode ? '无项目对话' : '本项目会话'}</span>
<span style={{ flex: 1 }} />
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
@@ -159,7 +173,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
<div className="tsb-scroll">
{creatingProject && (
{syncNotice && <div className="tsb-tool-note" role="status">{syncNotice}</div>}
{canCreate && creatingProject && (
<div className="tsb-newproject">
<input className="tsb-search" autoFocus value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
@@ -184,7 +199,9 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
)}
{rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
<div className="tsb-empty">{scratchMode ? '开始一个通用对话;无项目对话彼此独立,不会使用项目文档与知识。'
: noProjects ? emptyProjectsNotice(projectsLoaded, canCreate)
: workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
)}
{rest.map(t => taskRow(t))}
</div>
@@ -203,7 +220,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<kbd>{entry.shortcut}</kbd>
</button>
))}
{currentProject && (
{currentProject && !scratchMode && (
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
<span className="tsb-tool-name">项目设置</span>
@@ -227,25 +244,37 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{/* 弹出菜单们 */}
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem icon="message" onClick={() => { void store.enterScratchMode(); onConversationOpen?.(); setMenu(null) }}>
无项目对话
<kbd>{scratchMode ? '当前 · ' : ''}{scratchTasks.filter(t => !t.archived).length} 会话</kbd>
</MenuItem>
{projects.map(p => {
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
const mine = !!p.owner && p.owner === me?.owner
return (
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
{p.name}
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
<kbd>{p.id === currentProjectId && !scratchMode ? '当前 · ' : pstage ? `${pstage} · ` : ''}{mine ? '我创建 · ' : ''}{count} 会话</kbd>
</MenuItem>
)
})}
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
<div className="tsb-menu-div" />
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
{noProjects && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
{canCreate && <>
<div className="tsb-menu-div" />
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
</>}
</Menu>
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>
<div className="tsb-menu-div" />
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
{scratchMode
? <MenuItem icon="message" onClick={() => { createConversation(); setMenu(null) }}>新建无项目对话<kbd>⌘N</kbd></MenuItem>
: !noProjects && <MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>}
{canCreate && <>
<div className="tsb-menu-div" />
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
</>}
{noProjects && !canCreate && !scratchMode && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
))}
@@ -255,6 +284,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{rowTask && <>
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
</>}
{rowTask && !rowTask.scratch && <>
<div className="tsb-menu-div" />
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
@@ -269,6 +300,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<span>
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
{me && <div className="tsb-acc-mail">{projectCapabilitySummary(me)}</div>}
</span>
</div>
<div className="tsb-menu-div" />
@@ -279,7 +311,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{(me?.isAdmin || (me?.systemActions?.length ?? 0) > 0 || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
+13 -8
View File
@@ -14,6 +14,7 @@ import { WorkExecutionRecord } from './WorkExecutionRecord'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
export const docBridge: DocBridge = {
onOpenTaskSource: (cardId, source) => store.openTaskSource(cardId, source),
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
onOpenDoc: id => {
const art = store.getState().artifacts.find(a => a.id === id)
@@ -21,7 +22,8 @@ export const docBridge: DocBridge = {
},
}
const inboxCallbacks = {
// 无项目对话(scratch)没有可留档的项目:只用不含 onArchive 的回调。
const scratchInboxCallbacks = {
onAction: (c: ActivityCard, id: string, payload?: WorkCardAction) => store.cardAction(c, id, payload),
onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c),
onReference: (c: ActivityCard) => store.referenceWork(c),
@@ -29,8 +31,8 @@ const inboxCallbacks = {
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
onArchive: (c: ActivityCard) => store.cardToProject(c),
}
const inboxCallbacks = { ...scratchInboxCallbacks, onArchive: (c: ActivityCard) => store.cardToProject(c) }
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxFilter }) {
@@ -62,7 +64,9 @@ export function Workbench() {
const cards = useStore(s => s.cards)
const plan = useStore(s => s.plan)
const todos = useStore(s => s.todos)
const inboxScope = useStore(s => s.inboxScope)
const scratch = useStore(s => !!s.tasks.find(t => t.id === s.currentConvId)?.scratch)
const storedScope = useStore(s => s.inboxScope)
const inboxScope = scratch ? 'conv' : storedScope
const projectCards = useStore(s => s.projectCards)
const filter = useStore(s => s.ui.inboxFilter)
const visible = inboxScope === 'project' ? projectCards : cards
@@ -86,7 +90,7 @@ export function Workbench() {
</div>
{rightTab === 'files'
? <FilesTab />
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} />}
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} scratch={scratch} />}
</>
)
}
@@ -104,13 +108,14 @@ export function hasActiveProgressCard(cards: ActivityCard[]): boolean {
return cards.some(c => c.kind === 'progress' && (c.status === 'active' || c.status === 'action_required'))
}
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards, scratch }: {
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
filter: InboxFilter
inboxScope: 'conv' | 'project'
projectCards: ActivityCard[]
cards: ActivityCard[]
scratch: boolean
}) {
const record = useStore(s => s.workRecord)
const showCurrentTask = !hasActiveProgressCard(cards)
@@ -121,16 +126,16 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
{!scratch && <button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
</button>
</button>}
<span style={{ flex: 1 }} />
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
</div>
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
: <InboxList cards={cards} docBridge={docBridge} callbacks={scratch ? scratchInboxCallbacks : inboxCallbacks} filter={filter} />}
<WorkspaceSurfaces />
</>}
</div>
+7 -1
View File
@@ -8,12 +8,13 @@ import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { DocumentCockpit } from './DocumentCockpit'
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
export function workspaceEntries(): WorkspaceEntry[] {
const s = store.getState()
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable })
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable, rdmCockpit: s.rdmCockpitAvailable })
}
export function Workspaces() {
@@ -36,6 +37,11 @@ export function Workspaces() {
<RdmAuditWorkspace />
</OverlayHost>
}
if (active === 'rdm-cockpit') {
return <OverlayHost title="文档驾驶舱" icon="📋" shortcut={entry?.shortcut} sayHint="打开文档驾驶舱" onClose={close}>
<DocumentCockpit />
</OverlayHost>
}
if (active.startsWith('plugin:')) {
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import type { Project } from '../contracts'
import { MemberDutyCell, RdmMembersSection, memberManagementView } from './adminRdmSections'
const project: Project = {
id: 'host-1', owner: 'alice', name: '项目P', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' },
}
describe('MemberManagementEntryHidden', () => {
it('stays read-only until the self check confirms member management', () => {
expect(memberManagementView(null, null)).toEqual({ canManage: false, notice: '正在核对成员管理权限…' })
expect(memberManagementView([], null).canManage).toBe(false)
expect(memberManagementView([{ action: 'member.manage', allowed: false, }], null).notice).toContain('成员增删由项目创建者负责')
expect(memberManagementView([{ action: 'member.manage', allowed: true }], null)).toEqual({ canManage: true, notice: null })
})
it('does not offer member management on a project that cannot accept work', () => {
expect(memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档').canManage).toBe(false)
})
it('renders no add, role change or removal entry before authorization is known', () => {
const html = renderToStaticMarkup(<RdmMembersSection project={project} />)
expect(html).toContain('正在核对成员管理权限…')
expect(html).not.toContain('添加成员')
expect(html).not.toContain('选择人员')
expect(html).toContain('实际职责')
})
})
describe('CreatorOwnerMemberDuties member table', () => {
it('separates the immutable creator from the owner label and plain members', () => {
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
expect(creator).toContain('创建者')
expect(creator).toContain('成员增删')
expect(creator).toContain('随「创建项目」联动')
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator="alice" />)
expect(owner).toContain('项目负责人')
expect(owner).toContain('不含成员管理')
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
expect(member).toContain('基础协作')
expect(member).not.toContain('成员增删')
})
it('falls back to plain member duty when the project has no creator recorded', () => {
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator={undefined} />)
expect(owner).toContain('项目负责人')
expect(owner).not.toContain('<span class="ad-chip ad-chip-admin">创建者')
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator={undefined} />)
expect(member).toContain('基础协作')
})
it('marks the creator cell as an admin chip and the plain member as a neutral chip', () => {
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
expect(creator).toContain('ad-chip-admin')
expect(member).toMatch(/ad-chip(?!-admin)/)
})
})
describe('MemberManagementEntryHidden notice clarity', () => {
it('names the project-wide stage error as the reason to gate management entries', () => {
const result = memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档,请联系项目负责人恢复后再新建对话')
expect(result).toEqual({ canManage: false, notice: null })
})
it('distinguishes pending self-check from a confirmed no-permission state', () => {
const pending = memberManagementView(null, null)
const denied = memberManagementView([{ action: 'project.members.manage', allowed: false }], null)
expect(pending.notice).toMatch(/正在核对/)
expect(denied.notice).toMatch(/成员增删由项目创建者负责/)
})
})
+181 -178
View File
@@ -1,198 +1,149 @@
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
// 文档职责」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// RDM 项目设置组件:成员 / 文档职责。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 门控集中在 src/rdmManagement.ts;分区可见性由 ProjectSettingsModal 按 store 的
// rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect, useState, useRef, useCallback } from 'react'
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { canManageProjectMembers, memberDuty, projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
import { Btn, Collapsible, Icon, Picker } from '@ui'
import {
ROLE_ACTIONS,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload, memberSetRolePayload,
permCheckPayload, describeSource,
rolesListPlaceholder, describeRoleListError,
type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type RdmMemberPage, type RdmProfRole,
type SearchFieldsChoice,
MATCH_MODE_LABELS, SEARCH_FIELDS, accountCandidatePager, accountSearchKey, appendMemberPage, memberListParams,
personLabel, roleOptionsPayload,
} from '../rdmManagement'
// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ----
interface RoleView {
roleId: string
name: string
disabled: boolean
version: number
actions: Record<string, [boolean, boolean]> | null
accounts: string[] | null
}
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 [newName, setNewName] = useState('')
// 动作编辑器
const [editRole, setEditRole] = useState('')
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
const [editDef, setEditDef] = useState(false)
const [editCap, setEditCap] = useState(true)
// 绑定
const [assignOwner, setAssignOwner] = useState('')
const refresh = async () => {
try {
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
setErr(''); setRoles(out.roles ?? [])
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
}
useEffect(() => { refresh() }, [])
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refresh() }
catch (e) { setErr(describeRoleListError(String(e))) } finally { setBusy(false) }
}
if (!me?.isAdmin) return <EmptyState icon="shield">仅平台管理员可管理专业角色</EmptyState>
// 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见
// rolesListPlaceholder 注释)。
const placeholder = rolesListPlaceholder(roles, err)
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()}
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
setNewName('')
})}>创建角色</button>
</div>
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
</div>
<table className="ad-table">
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
<tbody>
{(roles ?? []).map(r => (
<tr key={r.roleId}>
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></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}(绑定账号回落内置默认)`,
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
{r.disabled ? '启用' : '停用'}</button></td>
</tr>
))}
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
{placeholder && <tr><td colSpan={6} className="ad-dim">{placeholder}</td></tr>}
</tbody>
</table>
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
<div className="ad-row">
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
<option value="">选择角色…</option>
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<select className="lg-input" value={editAction}
onChange={e => {
setEditAction(e.target.value)
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>)}
</select>
<label className="ad-check"><input type="checkbox" checked={editDef}
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
<label className="ad-check"><input type="checkbox" checked={editCap}
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
onClick={() => run(`已配置 ${editRole} · ${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>}
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="账号(owner)" 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}`,
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
</div>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
interface MemberRow {
owner: string; role: string; addedBy: string; addedAt: string
owner: string; name?: string; role: string; addedBy: string; addedAt: string
profRole?: { roleId: string; name: string } | null
}
/** 成员管理入口门控:本人自省未到达或无成员管理资格时只读(fail-closed)。 */
export function memberManagementView(actions: { action: string; allowed: boolean }[] | null, writeError: string | null) {
if (actions === null) return { canManage: false, notice: '正在核对成员管理权限…' }
if (!canManageProjectMembers(actions)) {
return { canManage: false, notice: '你可以查看成员与实际职责;成员增删由项目创建者负责(需具备项目成员管理资格)。' }
}
return { canManage: !writeError, notice: null }
}
/** 实际职责:创建者(不可变)与界面身份「负责人」分开显示,并标明能力关联来源。 */
export function MemberDutyCell({ member, creator }: { member: { owner: string; role: string }; creator: string | undefined }) {
const duty = memberDuty(member, creator)
return <>
<span className={duty.kind === 'member' ? 'ad-chip' : 'ad-chip ad-chip-admin'}>{duty.label}</span>
<div className="ad-dim" title={`来源:${duty.source}`}>{duty.duties}</div>
</>
}
export function RdmMembersSection({ project }: { project: Project }) {
const projectId = project.id
const writeError = projectWorkError(project)
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
const me = useStore(s => s.me)
const [manageActions, setManageActions] = useState<{ action: string; allowed: boolean }[] | null>(null)
const manage = memberManagementView(manageActions, writeError)
const [rows, setRows] = useState<MemberRow[] | null>(null)
const [memberNext, setMemberNext] = useState('')
const [memberTotal, setMemberTotal] = useState<number | null>(null)
const [directoryOk, setDirectoryOk] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [memberQuery, setMemberQuery] = useState('')
const [memberRoleId, setMemberRoleId] = useState('')
// null=未加载或 rdm.role.options 失败:两处专业角色筛选均不渲染(不给误导选项)。
const [roleOptions, setRoleOptions] = useState<RdmProfRole[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [picked, setPicked] = useState<string[]>([])
const [pickedItems, setPickedItems] = useState<PersonPickItem[]>([])
const [candRoleId, setCandRoleId] = useState('')
const [candMatch, setCandMatch] = useState<MemberMatchMode>('fuzzy')
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
const [role, setRole] = useState<'member' | 'owner'>('member')
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
const rowsRef = useRef<MemberRow[] | null>(null)
rowsRef.current = rows
const knownPicks = useRef(new Map<string, PersonPickItem>())
// 成员表请求代次:切项目/切筛选推进代次,迟到响应丢弃。
const memberGen = useRef(0)
/** 人员选择器取数适配(P7-A):rdm.account.list 目录(member.manage 生效者)。
* query 透传后端(基座升级 AccountSearcher 后全量生效);旧基座忽略 query 时
* 对首页做本地包含匹配降级(翻页为浏览模式,不过滤)。 */
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
const out = await cmd<{ accounts?: { owner: string; name?: string; valid: boolean }[]; next?: string }>(
'rdm.account.list', { projectId, cursor, limit, query })
const joined = new Set((rowsRef.current ?? []).map(m => m.owner))
let items = (out.accounts ?? []).filter(a => a.valid).map(a => ({
id: a.owner,
title: a.name || a.owner,
subtitle: a.owner,
badge: joined.has(a.owner) ? '已加入' : undefined,
}))
if (query !== '' && cursor === '') {
const q = query.toLowerCase()
items = items.filter(it => it.id.toLowerCase().includes(q) || it.title.toLowerCase().includes(q))
}
return { items, nextCursor: out.next || undefined }
useEffect(() => {
setPicked([]); setPickedItems([]); knownPicks.current.clear()
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
setRoleOptions(null)
}, [project])
const refresh = async () => {
// rdm.role.options 受 member.manage 授权;未确认前先不发请求,避免普通成员被 403。
// 跟着 [project, manage.canManage] 重订:项目切换或权限放开后重发,迟到响应丢弃。
useEffect(() => {
if (!manage.canManage) return
let live = true
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
.catch(() => { if (live) setRoleOptions(null) })
return () => { live = false }
}, [project, manage.canManage])
useEffect(() => {
setManageActions(null)
if (!me?.owner) return
let live = true
cmd<{ actions?: { action: string; allowed: boolean }[] }>('rdm.perm.check', permCheckPayload(projectId, me.owner))
.then(out => { if (live) setManageActions(out.actions ?? []) })
.catch(() => { if (live) setManageActions([]) })
return () => { live = false }
}, [project, me?.owner])
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
[candMatch, candFields, candRoleId])
/** 人员选择器取数适配:rdm.account.list 服务端按 query/mode/searchFields/roleId 先过滤后分页。 */
const fetchPage = useMemo(() => {
const page = accountCandidatePager(params => cmd<RdmAccountPage>('rdm.account.list', params), projectId,
{ ...candOptions, joinedOwners: () => (rowsRef.current ?? []).map(m => m.owner) })
return async (query: string, cursor: string, limit: number) => {
const out = await page(query, cursor, limit)
for (const it of out.items) knownPicks.current.set(it.id, it)
return out
}
}, [project, candOptions])
/** cursor 空=重载首页(clear 时先清空表格);非空=追加下一页。 */
const loadMembers = useCallback(async (cursor: string, clear: boolean) => {
const gen = cursor ? memberGen.current : ++memberGen.current
if (cursor) setLoadingMore(true)
else {
setLoadingMore(false)
if (clear) { setErr(''); setRows(null); setMemberNext(''); setMemberTotal(null) }
}
try {
const out = await cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
setRows(out.members ?? [])
} catch (e) { setErr(String(e)) }
}
useEffect(() => { setPicked([]); refresh() }, [project])
const out = await cmd<RdmMemberPage>('rdm.project.member.list',
memberListParams(projectId, { query: memberQuery, roleId: memberRoleId, cursor }))
if (gen !== memberGen.current) return
setRows(prev => appendMemberPage(cursor ? prev : null, out.members))
setMemberNext(out.next || '')
setMemberTotal(typeof out.total === 'number' ? out.total : null)
setDirectoryOk(out.accountDirectoryAvailable !== false)
} catch (e) {
if (gen === memberGen.current) setErr(String(e))
} finally {
if (cursor && gen === memberGen.current) setLoadingMore(false)
}
}, [project, memberQuery, memberRoleId])
useEffect(() => {
const timer = setTimeout(() => { void loadMembers('', true) }, memberQuery.trim() ? 250 : 0)
return () => { clearTimeout(timer); ++memberGen.current }
}, [loadMembers])
const refresh = () => loadMembers('', false)
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
@@ -200,43 +151,89 @@ export function RdmMembersSection({ project }: { project: Project }) {
catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const onPick = (ids: string[]) => {
setPicked(ids)
setPickedItems(ids.flatMap(id => {
const it = knownPicks.current.get(id)
return it ? [it] : []
}))
}
const pickedId = picked[0] ?? ''
const pickedItem = pickedItems.find(it => it.id === pickedId)
const pickedValid = !!pickedItem && !pickedItem.disabled && !(rows ?? []).some(m => m.owner === pickedItem.owner)
const pickerDisabled = busy || !!writeError
const filtering = memberQuery.trim() !== '' || memberRoleId !== ''
const roleFilter = (value: string, onChange: (v: string) => void, label: string) =>
roleOptions && roleOptions.length > 0 ? (
<select className="lg-input" aria-label={label} value={value} onChange={e => onChange(e.target.value)}>
<option value="">全部专业角色</option>
{roleOptions.map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
) : null
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-create">
{manage.notice && <p className="ad-hint" role="note">{manage.notice}</p>}
{manage.canManage && <div className="ad-create">
<div className="ad-row">
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
<select className="lg-input" aria-label="匹配方式" value={candMatch}
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select>
<select className="lg-input" aria-label="检索字段" value={candFields}
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
</div>
<div className="ad-row">
<Picker
aria-label="选择人员"
selected={picked}
onSelect={setPicked}
onSelect={onPick}
selectedItems={pickedItems}
fetchPage={fetchPage}
placeholder="输入账号或姓名,或浏览候选…"
reloadKey={accountSearchKey(projectId, candOptions)}
disabled={pickerDisabled}
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示匹配账号"
/>
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option>
<option value="owner">项目负责人</option>
<option value="owner">项目负责人(界面身份,不含成员管理)</option>
</select>
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedId}
onClick={() => run(`已添加 ${pickedId}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role))
setPicked([])
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
setPicked([]); setPickedItems([])
})}>添加成员</button>
</div>
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。被加入的成员无需重新登录,项目会自动出现在其项目列表。</p>
</div>}
<div className="ad-row">
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
onChange={e => setMemberQuery(e.target.value)} />
{roleFilter(memberRoleId, setMemberRoleId, '成员专业角色筛选')}
{rows !== null && <span className="ad-dim">共 {memberTotal ?? rows.length} 名成员匹配</span>}
</div>
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
<table className="ad-table">
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>实际职责</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
<tbody>
{(rows ?? []).map(m => (
<tr key={m.owner}>
<td><span className="ad-mono">{m.owner}</span>{m.profRole?.name ? <span className="ad-dim"> · {m.profRole.name}</span> : null}</td>
<td className="ad-mono">{personLabel(m)}</td>
<td className="ad-dim">{m.profRole?.name || '—'}</td>
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
<td><MemberDutyCell member={m} creator={project.owner} /></td>
<td className="ad-dim">{m.addedBy || '—'}</td>
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
<td>
<td>{manage.canManage && <>
<button className="ad-mini" disabled={busy || !!writeError}
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
@@ -246,13 +243,19 @@ export function RdmMembersSection({ project }: { project: Project }) {
<button className="ad-mini" disabled={busy || !!writeError}
onClick={() => run(`已移除 ${m.owner}`,
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
</td>
</>}</td>
</tr>
))}
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无成员</td></tr>}
{rows === null && <tr><td colSpan={6} className="ad-dim">加载中…</td></tr>}
{rows !== null && rows.length === 0 && <tr><td colSpan={7} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
{rows === null && <tr><td colSpan={7} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
</tbody>
</table>
{memberNext && rows !== null && (
<div className="ad-row">
<button className="ad-mini" disabled={loadingMore}
onClick={() => loadMembers(memberNext, false)}>{loadingMore ? '加载中…' : '加载更多成员'}</button>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
+45 -108
View File
@@ -9,19 +9,13 @@ import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocB
import { SettingInput, flatten, unflatten } from './SettingsModal'
import type { SettingDesc } from '../contracts'
import { docBridge } from './Workbench'
import { randomPassword } from '../passwords'
import { accountActionPolicy, canAppointAdmins } from '../accountAuthority'
import { CreateAdminOption, IdentityChip } from './AccountUsersPanel'
import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCredential } from './oneTimePasswordUi'
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制 + 2026-10-13 恢复 Key)----
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
interface LoginKeyRow { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }
/** Key 状态徽标:复用既有 chip 体系,不新增颜色。 */
function keyStatusChip(status: string) {
if (status === 'unused') return <span className="ad-chip ad-chip-warn">待使用</span>
if (status === 'consumed') return <span className="ad-chip">已使用</span>
if (status === 'expired') return <span className="ad-chip">已过期</span>
return <span className="ad-chip">已撤销</span>
}
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码的唯一处置是
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
export function UsersPanel() {
const me = useStore(s => s.me)
@@ -30,60 +24,35 @@ export function UsersPanel() {
const [notice, setNotice] = useState('')
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [initialPassword, setInitialPassword] = useState('')
const [createAsAdmin, setCreateAsAdmin] = useState(false)
const [generatedCredential, setGeneratedCredential] = useState<GeneratedCredential | null>(null)
const [busy, setBusy] = useState(false)
// 登录 Key 面板:keyFor 打开指定用户的签发/状态视图;issued 明文仅签发响应持有一次。
const [keyFor, setKeyFor] = useState<string | null>(null)
const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null)
const [keys, setKeys] = useState<LoginKeyRow[] | null>(null)
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
const refreshKeys = async (owner: string) => {
try { setKeys(await api.adminListLoginKeys(owner)) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const openKeyPanel = async (owner: string) => {
setErr(''); setNotice(''); setIssued(null); setKeyFor(owner); setKeys(null)
await refreshKeys(owner)
}
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
setBusy(true)
try {
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
setOwner(''); setName(''); setPw('')
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined, canAppointAdmins(me) && createAsAdmin)
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
setOwner(''); setName(''); setInitialPassword(''); setCreateAsAdmin(false)
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const issue = async () => {
if (!keyFor) return
setErr(''); setNotice(''); setBusy(true)
const issueOneTimePassword = async (u: AdminUserRow) => {
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
try {
const out = await api.adminIssueLoginKey(keyFor)
setIssued({ key: out.key, expiresAt: out.expiresAt })
await refreshKeys(keyFor)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const copyKey = async () => {
if (!issued) return
try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') }
}
const revoke = async (id: string) => {
if (!keyFor) return
setErr(''); setBusy(true)
try {
await api.adminRevokeLoginKey(keyFor, id)
await refreshKeys(keyFor)
const out = await api.adminIssueOneTimePassword(u.owner)
setGeneratedCredential(oneTimePasswordCredential(out))
setNotice(`已为 ${u.owner} 生成一次性登录密码。用户使用后必须立即设置新密码。`)
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
@@ -94,69 +63,37 @@ export function UsersPanel() {
<div className="ad-row">
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
</div>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。</p>
<CreateAdminOption me={me} checked={createAsAdmin} disabled={busy} onChange={setCreateAsAdmin} />
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{generatedCredential && <GeneratedCredentialPanel credential={generatedCredential} onClose={() => setGeneratedCredential(null)} />}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{keyFor ? (
<div className="ad-reset">
<div className="ad-row">
<span><b>{keyFor}</b> 的一次性登录 Key(忘记密码恢复)</span>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={issue}>{issued ? '重新签发(旧 Key 与受限会话立即失效)' : '生成 Key'}</button>
<button type="button" className="ad-mini" onClick={() => { setKeyFor(null); setIssued(null); setKeys(null) }}>关闭</button>
</div>
{issued && (
<div className="ad-row">
<input className="lg-input lg-mono" readOnly value={issued.key} aria-label="一次性登录 Key(仅显示一次)" onFocus={e => e.currentTarget.select()} />
<button type="button" className="ad-mini" onClick={copyKey}>复制</button>
</div>
)}
{issued && <p className="ad-hint">明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。</p>}
{keys !== null && keys.length > 0 && (
<table className="ad-table">
<thead><tr><th>签发时间</th><th>用途</th><th>状态</th><th>过期时间</th><th></th></tr></thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td className="ad-dim">{new Date(k.createdAt).toLocaleString()}</td>
<td>{k.purpose === 'recover' ? '恢复' : '激活'}</td>
<td>{keyStatusChip(k.status)}</td>
<td className="ad-dim">{new Date(k.expiresAt).toLocaleString()}</td>
<td>{k.status === 'unused' && <button className="ad-mini" disabled={busy} onClick={() => revoke(k.id)}>撤销</button>}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
) : (
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" onClick={() => openKeyPanel(u.owner)}>生成登录 Key</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点击该用户行的「生成一次性登录密码」:用户登录后必须立即设置新密码。</p>}
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => {
const policy = accountActionPolicy(me, u)
return <tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (policy.resetPassword
? <button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
: <span className="ad-dim">{policy.protectedReason || '受保护账号不可生成一次性登录密码'}</span>
)}</td>
</tr>
})}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
</div>
)
}
+32 -5
View File
@@ -1,14 +1,17 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../api'
import type { Project } from '../../contracts'
import { projectWorkError } from '../../projectmeta'
import { Btn, Collapsible, EmptyState, Icon } from '@ui'
import { dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload } from '../../rdmManagement'
import {
SEARCH_FIELDS, accountCandidatePager, accountSearchKey, dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload,
type DutyAccountIds, type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type SearchFieldsChoice,
} from '../../rdmManagement'
import {
absorbServerDocs, dirtyDocs, editCell, savePayload,
type CellDuty, type DutyDocDraft, type DutyMember,
} from '../../duties/draft'
import { DutyByDocumentView, DutyByMemberView, DutyChangePreview, DutyMatrixView } from '../../duties/views'
import { DutyByDocumentView, DutyByMemberView, DutyCandidateSearch, DutyChangePreview, DutyMatrixView } from '../../duties/views'
type ViewName = 'member' | 'document' | 'matrix'
@@ -68,6 +71,9 @@ export function RdmDutiesSection({ project }: { project: Project }) {
const [confirm, setConfirm] = useState(false)
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
const [legacyOpen, setLegacyOpen] = useState(false)
const [candMode, setCandMode] = useState<MemberMatchMode>('fuzzy')
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
const [accountIds, setAccountIds] = useState<DutyAccountIds>({})
const seq = useRef(0)
const op = useRef(0)
const docsRef = useRef(docs)
@@ -117,12 +123,25 @@ export function RdmDutiesSection({ project }: { project: Project }) {
return () => { seq.current += 1 }
}, [project, query, stage, typeKey, assignment, docCursor])
useEffect(() => { setAccountIds({}) }, [project])
const candOptions = useMemo(() => ({ docId, mode: candMode, searchFields: SEARCH_FIELDS[candFields].fields }),
[docId, candMode, candFields])
const fetchCandidates = useMemo(() => accountCandidatePager(
params => api.rdmProjectCommand<RdmAccountPage>(project, 'rdm.account.list', params), projectId, candOptions,
), [project, candOptions])
const onCell = (id: string, owner: string, duty: CellDuty) => {
setPreview([])
setConfirm(false)
setDocs(current => editCell(current, id, owner, duty))
}
const onCandidate = (id: string, candidate: PersonPickItem, duty: Exclude<CellDuty, 'none'>) => {
setAccountIds(current => ({ ...current, [candidate.owner]: candidate.id }))
onCell(id, candidate.owner, duty)
}
const visibleDocs = docs.filter(doc => {
const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase()
if (query && !text.includes(query.trim().toLowerCase())) return false
@@ -132,7 +151,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
})
const save = async () => {
const payload = savePayload(docsRef.current, confirm)
const payload = savePayload(docsRef.current, confirm, accountIds)
if (payload.issues.length > 0) {
setErr(payload.issues.join(';'))
return
@@ -154,7 +173,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
return
}
sent = true
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm).docs))
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm, accountIds).docs))
if (request !== seq.current) return
setNotice('已保存文档职责')
setPreview([])
@@ -228,6 +247,14 @@ export function RdmDutiesSection({ project }: { project: Project }) {
</div>
{view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />}
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
{view === 'document' && (() => {
const doc = docs.find(item => item.docId === docId)
return doc ? (
<DutyCandidateSearch doc={doc} mode={candMode} fields={candFields} fetchPage={fetchCandidates}
reloadKey={accountSearchKey(projectId, candOptions)} disabled={disabled}
onMode={setCandMode} onFields={setCandFields} onAssign={(candidate, duty) => onCandidate(doc.docId, candidate, duty)} />
) : null
})()}
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
<div className="ad-row">
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
+21
View File
@@ -0,0 +1,21 @@
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
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 '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
+21
View File
@@ -0,0 +1,21 @@
import type { AdminIssuedOneTimePassword } from '../api'
export interface GeneratedCredential {
owner: string
password: string
kind: 'initial' | 'one-time'
}
export function oneTimePasswordCredential(out: AdminIssuedOneTimePassword): GeneratedCredential {
return { owner: out.owner, password: out.oneTimePassword, kind: 'one-time' }
}
export function GeneratedCredentialPanel({ credential, onClose }: { credential: GeneratedCredential; onClose: () => void }) {
return <div className="ad-reset">
<p className="ad-hint">{credential.kind === 'initial'
? `初始密码仅显示一次,请立即安全交付给 ${credential.owner}。`
: `一次性登录密码仅显示一次,请立即安全交付给 ${credential.owner};使用后必须强制改密。`}</p>
<input className="lg-input ad-mono" readOnly value={credential.password} aria-label="一次性密码" />
<button type="button" className="ad-mini" onClick={onClose}>关闭</button>
</div>
}
+27
View File
@@ -0,0 +1,27 @@
import { api, type PasswordChangeMode } from '../api'
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
return mode === 'current_required'
}
export function isForcedPasswordMode(mode: PasswordChangeMode | null | undefined): mode is PasswordChangeMode {
return mode === 'grant_setup' || mode === 'current_required' || mode === 'one_time_password'
}
export function ForcedPasswordModeSection({ mode, current, onCurrentChange }: { mode: PasswordChangeMode | null; current: string; onCurrentChange: (value: string) => void }) {
if (mode === 'current_required') {
return <>
<label className="lg-label" htmlFor="forced-current">当前密码</label>
<input id="forced-current" className="lg-input" type="password" value={current}
onChange={e => onCurrentChange(e.target.value)} autoComplete="current-password" />
</>
}
if (mode === 'grant_setup' || mode === 'one_time_password') {
return <p className="lg-hint">当前为受限设密会话,此次无需输入当前密码。</p>
}
return null
}
export async function submitForcedPassword(mode: PasswordChangeMode, current: string, next: string) {
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
}
+33
View File
@@ -0,0 +1,33 @@
// 统一渲染的失败与操作状态展示:工作区与各面板共用,只渲染,不持有异步状态。
// 原始回执只进 Collapsible 折叠区;可读文案由 describeRenderFailure 统一给出。
import { Btn, Collapsible } from '@ui'
import { describeRenderFailure, type RenderOperation } from '../documentRenderPresentation'
export interface OperationState { op: RenderOperation | null; status: 'idle' | 'running' | 'done' | 'failed'; error: string }
export function RenderFailure({ error, id }: { error: unknown; id: string }) {
const f = describeRenderFailure(error)
return (
<div className="drw-failure" role="alert">
<div className="drw-failure-msg">{f.message}</div>
<Collapsible id={`drw-failure-${id}`} trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
<code className="rdm-audit-err-raw">{f.raw}</code>
</Collapsible>
</div>
)
}
export function OperationStatus({ state, onRetry, labels }: { state: OperationState; onRetry: () => void; labels: Record<string, string> }) {
if (state.status === 'idle' || !state.op) return null
const label = labels[state.op.kind] ?? '操作'
if (state.status === 'running') return <div className="drw-op"><span className="mk-sweep">{label}进行中</span></div>
if (state.status === 'failed') {
return (
<div className="drw-op">
<RenderFailure error={state.error} id={`op-${state.op.kind}`} />
<Btn variant="ghost" onClick={onRetry}>重试</Btn>
</div>
)
}
return null
}
+29 -1
View File
@@ -18,6 +18,8 @@ export interface Conversation {
allowCrossRead?: boolean; crossPrivate?: boolean;
permissionMode?: 'default' | 'plan' | 'auto_edit' | '';
sessionNote?: string;
/** 无项目对话:服务端内部项目只承载这一条会话,不出现在项目列表里。 */
scratch?: boolean;
createdAt: string; updatedAt: string;
}
@@ -150,6 +152,7 @@ export interface ActivityCard {
id: ID; projectId: ID; conversationId?: ID;
sourceRunId?: ID; anchorMessageId?: ID;
kind: CardKind; title: string; status: CardStatus; tone?: string;
task?: TaskSnapshot;
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
}
@@ -351,7 +354,7 @@ export interface PluginPageDecl {
export interface ParsedDocument {
meta: {
filename: string; mime?: string; parser: string; confidence?: number;
filename: string; mime?: string; parser: string; confidence?: number; origin?: DocumentOrigin;
crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' };
parsedAt: string;
};
@@ -414,3 +417,28 @@ export interface SurfaceMeta {
conversationId?: ID;
updatedAt: string;
}
// Persistent work projection; host-owned identities and revisions.
export interface DocumentOrigin { artifactId: ID; contentHash: string; parserRevision: string }
export interface TaskPhase { id: string; label: string; observedAt?: string }
export interface TaskSourceRef { id: string; artifactId: ID; contentHash: string; parserRevision: string; blockIndex: number; label: string; quote?: string; availability?: string }
export interface TaskResultRef { artifactId?: ID; surfaceId?: ID; version?: string; contentHash?: string; availability: string; name?: string }
export interface TaskFinding { id: string; text: string; impact?: string; kind: string; status: string; revision: number; sources: TaskSourceRef[]; assumptions?: string; corrects?: string }
export interface TaskSnapshot {
inputs?: TaskSourceRef[]
limitations?: string[]
schemaVersion: number; taskId: ID; attemptId: ID; cardId: ID; projectId: ID; conversationId: ID;
sourceMessageId?: ID; parentRunId?: ID; runId?: ID; ownerInstanceId: string; executionEpoch?: number;
revision: number; sourceSeq: number; streamScope: string; inputRevision?: string; title: string; goal?: string;
executionState: 'queued'|'running'|'awaiting_user'|'completed'|'failed'|'interrupted'; outcomeCompleteness: 'none'|'partial'|'complete'; deliveryState: 'pending'|'available'|'reported'; phase: TaskPhase;
focus?: string; coverage?: string; remaining?: string; nextStep?: string; conclusion?: string;
findings?: TaskFinding[]; actions?: UIAction[]; resultRefs?: TaskResultRef[]; lastActivityAt?: string; lastMeaningfulUpdateAt?: string;
checkpointKeys?: Record<string,string>; checkpointCount?: number; checkpointSeq?: number;
}
export interface TaskSourceQuote { sourceId: string; blockIndex: number; quote: string }
interface WorkEvidencePremise { role: "total" | "phase" | "dependent" | "direct_relation"; sourceIndex: number; value?: number }
interface WorkFindingEvidence { mode: "direct_relation" | "difference" | "unresolved_relation"; premises: WorkEvidencePremise[]; result?: number }
export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[]; evidence?: WorkFindingEvidence }
export interface WorkCheckpointCandidate { checkpointId: string; phaseId: string; focus?: string; remaining?: string; nextStep?: string; findings: WorkFindingCandidate[] }
export interface UIAction { id: string; label: string; style?: string; command?: string }
+214
View File
@@ -0,0 +1,214 @@
// 文档驾驶舱 I2 纯逻辑层测试:能力门控、筛选切换、统计呈现(真 0 vs 不可算)、
// 快照一致性/更新提示、上下文键、选择保留、树徽标。组件取数编排不在此覆盖。
import { describe, expect, it } from 'vitest'
import {
contextKey, nodeSummaryLine, sameSnapshot, selectionSurvives, statDisplay,
STAT_FILTERS, toggleFilter, treeBadges, updateNotice,
type CockpitMeta, type CockpitStats, type CockpitTreeNode,
} from './documentCockpit'
import { cockpitReady, COCKPIT_COMMANDS } from './workspaces'
const meta = (rev: string, seq: number): CockpitMeta => ({
schemaVersion: 'document-cockpit.v1', projectId: 'p1', snapshotRevision: rev,
asOfSeq: seq, generatedAt: '2026-10-16T00:00:00Z', truncated: false,
})
describe('cockpitReady', () => {
const all = COCKPIT_COMMANDS.map(id => ({ id, enabled: true }))
it('七命令齐备且 enabled 才就绪', () => {
expect(cockpitReady(null)).toBe(false)
expect(cockpitReady([])).toBe(false)
expect(cockpitReady(all.map(c => ({ ...c, enabled: false })))).toBe(false)
expect(cockpitReady(all.slice(0, 6))).toBe(false)
expect(cockpitReady(all)).toBe(true)
})
})
describe('统计呈现:真 0 与不可算不得互相冒充', () => {
const stats: CockpitStats = {
documentCount: 0,
completion: { known: 3, total: 5, state: 'known' },
emptyNodeCount: 0,
unverifiedSourceCount: 2,
conflictCount: null as unknown as number,
pendingChangeCount: null,
updatedAt: '',
statReasons: { conflictCount: 'upstream_error', pendingChangeCount: 'not_projected_v1' },
}
it('真 0 显示 0', () => {
expect(statDisplay(stats, 'empty').value).toBe('0')
expect(statDisplay(stats, 'documents').value).toBe('0')
})
it('null 显示 — 并带原因', () => {
const d = statDisplay(stats, 'conflict')
expect(d.value).toBe('—')
expect(d.hint).toBe('upstream_error')
})
it('未纳入投影的统计显式标注原因', () => {
const d = statDisplay(stats, 'pending')
expect(d.value).toBe('—')
expect(d.hint).toBe('not_projected_v1')
})
it('完成度三分态:known/partial/unavailable', () => {
expect(statDisplay(stats, 'completion').value).toBe('3/5')
expect(statDisplay({ ...stats, completion: { known: 1, total: 5, state: 'partial' } }, 'completion').hint).toBe('部分计算')
const u = statDisplay({ ...stats, completion: { known: 0, total: 0, state: 'unavailable' } }, 'completion')
expect(u.value).toBe('—')
})
})
describe('筛选切换', () => {
it('点击切换、再点清除;统计键与筛选值一一对应', () => {
expect(toggleFilter('', 'state:empty')).toBe('state:empty')
expect(toggleFilter('state:empty', 'state:empty')).toBe('')
expect(toggleFilter('state:empty', 'state:conflict')).toBe('state:conflict')
expect(STAT_FILTERS.map(s => s.filter)).toEqual(['state:empty', 'state:unverified', 'state:conflict'])
})
})
describe('快照一致性与更新提示', () => {
it('同 revision 同 seq 视为同快照', () => {
expect(sameSnapshot(meta('wm:5', 5), meta('wm:5', 5))).toBe(true)
expect(sameSnapshot(meta('wm:5', 5), meta('wm:6', 6))).toBe(false)
expect(sameSnapshot(undefined, meta('wm:5', 5))).toBe(false)
})
it('展示快照落后时提示刷新,一致时不提示', () => {
expect(updateNotice(meta('wm:5', 5), meta('wm:6', 6))).toContain('wm:6')
expect(updateNotice(meta('wm:6', 6), meta('wm:6', 6))).toBeNull()
expect(updateNotice(null, meta('wm:6', 6))).toBeNull()
})
})
describe('旧响应丢弃的上下文键', () => {
it('项目与筛选任一变化都改变键', () => {
expect(contextKey('p1', '')).not.toBe(contextKey('p2', ''))
expect(contextKey('p1', '')).not.toBe(contextKey('p1', 'state:empty'))
expect(contextKey('p1', 'state:empty')).toBe(contextKey('p1', 'state:empty'))
})
})
describe('刷新不重置选择', () => {
it('文档仍在根清单则保留', () => {
const kept = selectionSurvives({ kind: 'document', id: 'd1' }, ['d1', 'd2'])
expect(kept).toEqual({ kind: 'document', id: 'd1' })
})
it('文档被筛掉则清空', () => {
expect(selectionSurvives({ kind: 'document', id: 'd9' }, ['d1', 'd2'])).toBeNull()
})
it('章节选择随父文档存活', () => {
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d1'], 'd1')).toEqual({ kind: 'section', id: 's1' })
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d2'], 'd1')).toBeNull()
expect(selectionSurvives(null, ['d1'])).toBeNull()
})
})
describe('树徽标与状态行', () => {
const node = (over: Partial<CockpitTreeNode['state']> & { visibility?: string }): CockpitTreeNode => ({
ref: { kind: 'section', id: 's1' }, title: '章节', path: [], childCount: 0,
visibility: over.visibility || 'full',
state: {
content: 'complete', verification: 'not_applicable', sourceCount: 0,
conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true, ...over,
},
} as CockpitTreeNode)
it('诚实徽标:空/受限/未核实/冲突计数', () => {
expect(treeBadges(node({}))).toEqual([])
expect(treeBadges(node({ content: 'empty' }))).toContain('空')
expect(treeBadges(node({ verification: 'unverified' }))).toContain('未核实')
expect(treeBadges(node({ conflictCount: 2 }))).toContain('冲突 2')
expect(treeBadges(node({ visibility: 'metadata_only' }))).toContain('仅元数据')
})
it('结构缺口显式标注(冻结事实:doc→section 两级)', () => {
const line = nodeSummaryLine(node({ content: 'empty' }))
expect(line).toContain('内容为空')
expect(line).toContain('层级止于章节')
})
})
// ---- I3:阅读器与右栏纯逻辑 ----
import {
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
prefsKey, mergePrefs, loadPrefs,
type CockpitHistorySummary, type CockpitVersionSummary,
} from './documentCockpit'
describe('偏好:按用户+项目隔离,枚举外脏值回落默认', () => {
it('prefsKey 区分用户与项目', () => {
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('a', 'p2'))
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('b', 'p1'))
})
it('mergePrefs 逐项校验', () => {
expect(mergePrefs(null)).toEqual({ lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' })
expect(mergePrefs({ lastView: 'reader', density: 'full', rightPanelTab: 'conflicts' })).toEqual({
lastView: 'reader', density: 'full', rightPanelTab: 'conflicts',
})
const junk = mergePrefs({ lastView: 'galaxy', density: 42, rightPanelTab: 'nope' })
expect(junk.lastView).toBe('panorama')
expect(junk.density).toBe('compact')
expect(junk.rightPanelTab).toBe('sources')
})
it('loadPrefs 在无 localStorage 环境安全回落', () => {
expect(loadPrefs('a', 'p1').lastView).toBe('panorama')
})
})
describe('版本摘要与行级差异', () => {
const v = (seq: number, content?: string, redacted?: boolean): CockpitVersionSummary => ({
seq, version: `v${seq}`, at: '2026-10-16T00:00:00Z', basisCount: 0, content, redacted,
})
it('当前+前一版;受限整体降级', () => {
const s = versionSummary([v(3, '三'), v(2, '二'), v(1, '一')])
expect(s.current?.version).toBe('v3')
expect(s.previous?.version).toBe('v2')
expect(s.redacted).toBe(false)
const r = versionSummary([v(3, undefined, true)])
expect(r.redacted).toBe(true)
expect(versionSummary(undefined).current).toBeUndefined()
})
it('diffLines 标记新增/删除/保留', () => {
const d = diffLines('共同\n新增行', '共同\n删除行')
expect(d).toContainEqual({ flag: '=', text: '共同' })
expect(d).toContainEqual({ flag: '+', text: '新增行' })
expect(d).toContainEqual({ flag: '-', text: '删除行' })
})
})
describe('历史严格合并:乱序与重复丢弃(DV-R05)', () => {
const h = (seq: number): CockpitHistorySummary => ({ seq, at: '', op: 'x', summary: `s${seq}` })
it('只接受 seq 更大的条目', () => {
const r = mergeHistoryStrict([h(10), h(9)], [h(11), h(8)])
expect(r.items.map(i => i.seq)).toEqual([10, 9, 11])
expect(r.dropped).toBe(1)
})
it('重复 seq 丢弃且计入 dropped', () => {
const r = mergeHistoryStrict([h(10)], [h(10), h(11)])
expect(r.items.map(i => i.seq)).toEqual([10, 11])
expect(r.dropped).toBe(1)
})
it('空历史接受全部', () => {
expect(mergeHistoryStrict([], [h(1), h(2)]).items.map(i => i.seq)).toEqual([1, 2])
})
it('historyLine 带操作者与本地时间', () => {
expect(historyLine(h(1))).toContain('s1')
})
})
describe('阅读器装配:受限章节给占位不冒充空内容', () => {
const sec = (id: string, content: string): CockpitTreeNode => ({
ref: { kind: 'section', id }, title: id, path: [], childCount: 0,
state: { content, verification: 'not_applicable', sourceCount: 0, conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true },
visibility: 'full',
})
it('正文/受限/未加载三分态', () => {
const out = assembleReader(
[sec('a', 'complete'), sec('b', 'complete'), sec('c', 'empty')],
{
a: { body: [{ kind: 'text', text: '正文A' }] },
b: { body: [{ kind: 'restricted', reason: 'section-duty-or-authorship' }] },
},
)
expect(out[0].blocks).toEqual([{ kind: 'text', text: '正文A' }])
expect(out[1].blocks[0].kind).toBe('restricted')
expect(out[2].blocks[0].reason).toBe('not_loaded')
})
})
+342
View File
@@ -0,0 +1,342 @@
// 文档驾驶舱 I2 纯逻辑层(rollout I2;契约:Dev 仓 docs/superpowers/specs
// 2026-10-16-document-visualization-read-model-contract.md @ 37b272ba,
// 四项取舍冻结见 plans/…-rollout.md §7)。只放可测判定:筛选切换、统计呈现
//(真 0 vs 不可算)、快照一致性与更新提示、上下文键与选择保留。组件只做取数编排与渲染。
// ---- 契约类型(I1 后端 DTO 的前端精简形态) ----
export interface CockpitNodeRef { kind: string; id: string; version?: string }
export interface CockpitMeta {
schemaVersion: string
projectId: string
snapshotRevision: string
asOfSeq: number
generatedAt: string
truncated: boolean
truncationReasons?: string[]
}
export interface CockpitNodeState {
content: string // complete|partial|empty|inaccessible|unavailable
verification: string // verified|mixed|unverified|disputed|not_applicable
sourceCount: number
conflictCount: number
impactCount: number
missingChildCount: number
structuralGap: boolean
}
export interface CockpitTreeNode {
ref: CockpitNodeRef
title: string
path: string[]
parent?: CockpitNodeRef
childCount: number
state: CockpitNodeState
lastChangedAt?: string
lastChangedBy?: string
visibility: string // full|redacted|metadata_only
}
export interface CockpitStats {
documentCount: number
completion: { known: number; total: number; state: string }
emptyNodeCount: number
unverifiedSourceCount: number
conflictCount: number
pendingChangeCount: number | null
updatedAt?: string
statReasons: Record<string, string>
}
export interface CockpitChangeSummary {
node: CockpitNodeRef
title: string
changedAt: string
actor?: string
kind: string
summary: string
version?: string
}
export interface CockpitOverview {
meta: CockpitMeta
project: { title: string }
stats: CockpitStats
documents: CockpitTreeNode[]
recentChanges: CockpitChangeSummary[]
}
export interface CockpitTreePage {
meta: CockpitMeta
items: CockpitTreeNode[]
nextCursor?: string
hasMore: boolean
}
// ---- 统计带:点击即切换共享筛选(视图规格 §2;未知筛选由后端 400 拒绝) ----
export const STAT_FILTERS: { key: string; label: string; filter: string }[] = [
{ key: 'empty', label: '空缺节点', filter: 'state:empty' },
{ key: 'unverified', label: '未核实来源', filter: 'state:unverified' },
{ key: 'conflict', label: '冲突', filter: 'state:conflict' },
]
/** 点击统计卡:同筛选再次点击=清除(只定位,不执行写入)。 */
export function toggleFilter(current: string, filter: string): string {
return current === filter ? '' : filter
}
/** 统计呈现:null(不可算)显示 — 加原因;真 0 显示 0。两侧都不得互相冒充(契约 §2/§6)。 */
export function statDisplay(stats: CockpitStats, key: string): { value: string; hint?: string } {
const reasons = stats.statReasons || {}
if (key === 'documents') return { value: String(stats.documentCount) }
if (key === 'completion') {
const c = stats.completion
if (c.state === 'unavailable') return { value: '—', hint: reasons['completion'] || '无法计算' }
if (c.state === 'partial') return { value: `${c.known}/${c.total}`, hint: '部分计算' }
return { value: `${c.known}/${c.total}` }
}
const map: Record<string, number | null | undefined> = {
empty: stats.emptyNodeCount,
unverified: stats.unverifiedSourceCount,
conflict: stats.conflictCount,
pending: stats.pendingChangeCount,
}
const v = map[key]
// 原因表按后端 DTO 字段名(statReasons 键=字段名),展示键需映射。
const reasonKey: Record<string, string> = {
empty: 'emptyNodeCount', unverified: 'unverifiedSourceCount',
conflict: 'conflictCount', pending: 'pendingChangeCount',
}
if (v === null || v === undefined) return { value: '—', hint: reasons[reasonKey[key] || key] || '暂不可计算' }
return { value: String(v) }
}
// ---- 快照一致性(DV-R05 前端侧):统计与树徽标同快照才同屏可信 ----
export function sameSnapshot(a?: CockpitMeta, b?: CockpitMeta): boolean {
if (!a || !b) return false
return a.snapshotRevision === b.snapshotRevision && a.asOfSeq === b.asOfSeq
}
/** 更新提示:展示中快照落后于最新已见快照时提示刷新(不抢占滚动,视图规格 §5)。 */
export function updateNotice(viewed: CockpitMeta | null, latest: CockpitMeta | null): string | null {
if (!viewed || !latest) return null
if (viewed.snapshotRevision === latest.snapshotRevision && viewed.asOfSeq === latest.asOfSeq) return null
return `内容已更新(快照 ${latest.snapshotRevision})——刷新查看最新状态`
}
/** 上下文键:项目+筛选标识一轮取数上下文;不匹配的旧响应直接丢弃(契约 §8)。 */
export function contextKey(projectId: string, filter: string): string {
return `${projectId}\u0001${filter}`
}
/** 刷新不重置选择:文档仍在根清单(或选中章节的父文档仍在)则保留,否则清空。 */
export function selectionSurvives(
selected: CockpitNodeRef | null,
rootDocIds: string[],
parentDocId?: string,
): CockpitNodeRef | null {
if (!selected) return null
if (selected.kind === 'document') return rootDocIds.includes(selected.id) ? selected : null
if (parentDocId && rootDocIds.includes(parentDocId)) return selected
return null
}
/** 树行徽标(诚实状态:空/部分/受限/未核实/冲突计数;不虚构成功)。 */
export function treeBadges(n: CockpitTreeNode): string[] {
const out: string[] = []
if (n.state.content === 'empty') out.push('空')
else if (n.state.content === 'partial') out.push('部分')
else if (n.state.content === 'inaccessible') out.push('受限')
if (n.visibility === 'metadata_only') out.push('仅元数据')
if (n.state.verification === 'unverified') out.push('未核实')
if (n.state.conflictCount > 0) out.push(`冲突 ${n.state.conflictCount}`)
return out
}
/** 节点状态一句话总结(全景 §4.2)。 */
export function nodeSummaryLine(n: CockpitTreeNode): string {
const parts: string[] = []
const content = { complete: '内容完整', partial: '内容部分', empty: '内容为空', inaccessible: '内容受限', unavailable: '暂不可读' }[n.state.content] || n.state.content
parts.push(content)
if (n.state.structuralGap) parts.push('层级止于章节')
if (n.state.sourceCount > 0) parts.push(`来源 ${n.state.sourceCount}`)
if (n.state.conflictCount > 0) parts.push(`冲突 ${n.state.conflictCount}`)
if (n.state.impactCount > 0) parts.push(`影响 ${n.state.impactCount}`)
return parts.join(' · ')
}
// ---- I3:阅读器与右栏纯逻辑 ----
export interface CockpitVersionSummary {
seq: number
version: string
actor?: string
at: string
basisCount: number
content?: string
redacted?: boolean
}
export interface CockpitContentBlock { kind: string; text?: string; reason?: string }
export interface CockpitSourceSummary {
ref: { kind: string; id: string }
label: string
capturedAt?: string
confidence: string
verification: string
visibility: string
redactedReason?: string
}
export interface CockpitHistorySummary {
seq: number
at: string
op: string
actor?: string
summary: string
operationId?: string
}
export interface CockpitImpactSummary {
ref: { kind: string; id: string }
edgeType: string
edgeState: string
direction: string
layer: string
}
export interface CockpitAssertion { node: CockpitNodeRef; claim: string; visibility: string; redactedReason?: string }
export interface CockpitConflictSummary {
id: string
node: CockpitNodeRef
state: string
left: CockpitAssertion
right: CockpitAssertion
detectedAt: string
resolutionRef?: string
}
export interface CockpitPage<T> { meta?: CockpitMeta; items: T[]; nextCursor?: string; hasMore: boolean; truncated?: boolean }
export const PANEL_TABS = [
{ id: 'sources', label: '内容来源' },
{ id: 'history', label: '修改历史' },
{ id: 'impacts', label: '影响对象' },
{ id: 'conflicts', label: '冲突与风险' },
] as const
export type PanelTabId = (typeof PANEL_TABS)[number]['id']
export type ViewMode = 'panorama' | 'reader'
export type Density = 'compact' | 'full'
export interface CockpitPrefs {
lastView: ViewMode
density: Density
rightPanelTab: PanelTabId
}
/** 偏好按 用户+项目 隔离(设计 §7.3);仅本地存储,不构成授权。 */
export function prefsKey(actor: string, projectId: string): string {
return `cockpit-prefs:${actor}:${projectId}`
}
/** 解析偏好:枚举外的脏值逐项回落默认(panorama+compact+sources)。 */
export function mergePrefs(raw: unknown): CockpitPrefs {
const base: CockpitPrefs = { lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' }
if (!raw || typeof raw !== 'object') return base
const r = raw as Record<string, unknown>
const out = { ...base }
if (r.lastView === 'panorama' || r.lastView === 'reader') out.lastView = r.lastView
if (r.density === 'compact' || r.density === 'full') out.density = r.density
if (PANEL_TABS.some(t => t.id === r.rightPanelTab)) out.rightPanelTab = r.rightPanelTab as PanelTabId
return out
}
export function loadPrefs(actor: string, projectId: string): CockpitPrefs {
try {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(prefsKey(actor, projectId)) : null
return mergePrefs(raw ? JSON.parse(raw) : null)
} catch {
return mergePrefs(null)
}
}
export function savePrefs(actor: string, projectId: string, prefs: CockpitPrefs): void {
try {
if (typeof localStorage !== 'undefined') localStorage.setItem(prefsKey(actor, projectId), JSON.stringify(prefs))
} catch { /* 存储不可用时偏好静默降级为会话内 */ }
}
/** 版本摘要:当前版本 + 前一版本(视图规格 §5)。versions 为最新在前;受限版本无 content。 */
export function versionSummary(versions: CockpitVersionSummary[] | undefined): {
current?: CockpitVersionSummary
previous?: CockpitVersionSummary
redacted: boolean
} {
if (!versions || versions.length === 0) return { redacted: false }
const current = versions[0]
const previous = versions.length > 1 ? versions[1] : undefined
return { current, previous, redacted: !!current.redacted }
}
/** 历史面板合并:只接受严格更新(seq 更大且未见)的条目;过期与重复都计入 dropped(DV-R05/契约 §8)。 */
export function mergeHistoryStrict(existing: CockpitHistorySummary[], incoming: CockpitHistorySummary[]): {
items: CockpitHistorySummary[]
dropped: number
} {
const seen = new Set(existing.map(e => e.seq))
const maxSeq = existing.length > 0 ? Math.max(...seen) : -1
const items = [...existing]
let dropped = 0
for (const e of incoming) {
if (e.seq <= maxSeq || seen.has(e.seq)) {
dropped++
continue
}
seen.add(e.seq)
items.push(e)
}
return { items, dropped }
}
/** 阅读器装配:把文档的章节详情按树顺序组装为连续正文;受限章节给占位说明而非空白。 */
export function assembleReader(
sections: CockpitTreeNode[],
details: Record<string, { body?: CockpitContentBlock[]; versions?: CockpitVersionSummary[] } | undefined>,
): { ref: CockpitNodeRef; title: string; contentState: string; blocks: CockpitContentBlock[]; versions?: CockpitVersionSummary[] }[] {
return sections.map(sec => {
const d = details[sec.ref.id]
const readable = !!d?.body && d.body.length > 0 && d.body[0].kind === 'text'
const restricted = !!d?.body && d.body.length > 0 && d.body[0].kind === 'restricted'
const blocks: CockpitContentBlock[] = readable
? (d!.body as CockpitContentBlock[])
: restricted
? [{ kind: 'restricted', reason: d?.body?.[0]?.reason || 'section-duty-or-authorship' }]
: [{ kind: 'restricted', reason: 'not_loaded' }]
return { ref: sec.ref, title: sec.title, contentState: sec.state.content, blocks, versions: d?.versions }
})
}
/** 行级差异(阅读器 Collapsible 的完整对比):按行对齐的朴素 LCS 免实现——
* 以行为单位做集合比对标记 +/=/-,足够呈现「完整差异」而不冒充精确补丁。 */
export function diffLines(current: string, previous: string): { flag: '+' | '-' | '='; text: string }[] {
const cur = current.split('\n')
const prev = new Set(previous.split('\n'))
const curSet = new Set(cur)
const out: { flag: '+' | '-' | '='; text: string }[] = []
for (const line of cur) out.push({ flag: prev.has(line) ? '=' : '+', text: line })
for (const line of previous.split('\n')) if (!curSet.has(line)) out.push({ flag: '-', text: line })
return out
}
/** 历史条目的自然语言时间线文本(右栏 §6)。 */
export function historyLine(h: CockpitHistorySummary): string {
const who = h.actor ? `${h.actor} ` : ''
return `${h.summary}(${who}${new Date(h.at).toLocaleString('zh-CN')})`
}
+124
View File
@@ -0,0 +1,124 @@
// 统一渲染 Task 11(审签来源):整文审签的正式命令必须锚定一条已提交的对话消息(基座来源门禁)。
// 流程:宿主项目的审签记录对话 → 提交一条中文审签说明(用户消息,幂等编号 = 动作编号)→ 以该消息为锚点分发命令。
// 任一步失败都可读地拒绝且绝不分发命令;身份只由服务端认证主体决定,载荷不含任何身份字段。
import type { Conversation, Message, Project } from './contracts'
import { describeRenderFailure, type DecisionKind } from './documentRenderPresentation'
import { rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
export const DECISION_RECORD_TITLE = '文档审签记录'
const DECISION_ENTRANCE: CommandSource['entrance'] = 'button'
const CONVERSATION_STAGE = '审签记录对话'
const SOURCE_STAGE = '审签来源消息'
/** 只需要的会话端口:调用方传入共享的 api 对象,不另起一层 API。 */
export interface DecisionPort {
listConversations: (projectId: string) => Promise<Conversation[]>
createConversation: (projectId: string, title?: string) => Promise<Conversation>
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) => Promise<{ message?: Message; command?: string }>
rdmCommand: (id: string, params: Record<string, unknown>, source?: CommandSource) => Promise<unknown>
}
export interface DecisionRequest {
host: Project | undefined
/** 同一用户动作的编号:重试复用,服务端据此幂等返回同一条来源消息。 */
operationId: string
decision: DecisionKind
doc: { id: string; title: string }
round: number
note: string
command: string
params: Record<string, unknown>
}
function decisionLabel(kind: DecisionKind): string {
switch (kind) {
case 'approve': return '审核通过'
case 'reject': return '驳回'
case 'final': return '终批'
default: {
const exhaustive: never = kind
throw new Error(`unknown decision kind: ${String(exhaustive)}`)
}
}
}
/** 审签来源正文(中文):决定、文档、轮次与说明/原因;不含任何身份字段,首字符不为 “/” 以免被当作命令。 */
export function decisionSourceText(input: Pick<DecisionRequest, 'decision' | 'doc' | 'round' | 'note'>): string {
const doc = input.doc.title ? `${input.doc.title}(${input.doc.id})` : input.doc.id
const noteLabel = input.decision === 'reject' ? '驳回原因' : '说明'
return [
`审签决定:${decisionLabel(input.decision)}`,
`文档:${doc}`,
`轮次:第 ${input.round} 轮`,
`${noteLabel}:${input.note.trim() || '无'}`,
'来源:文档渲染工作区',
].join('\n')
}
/** 研发项目 → 其宿主对话项目(settings 中绑定的 rdm.projectId 相同者)。 */
export function hostProjectFor(projects: Project[], rdmProjectId: string): Project | undefined {
return projects.find(p => {
try { return rdmCommandProjectId(p) === rdmProjectId } catch { return false }
})
}
function refusal(stage: string, detail: string): Error {
return new Error(`${stage}未能提交,审签未执行:${detail}`)
}
function stepFailure(stage: string, err: unknown): Error {
const raw = err instanceof Error ? err.message : String(err ?? '')
if (/\b403\b/.test(raw)) return refusal(stage, '当前账号无权在该对话中记录审签')
if (/\b409\b/.test(raw)) return refusal(stage, '会话正在处理其他任务,请稍后重试')
if (/\b429\b/.test(raw)) return refusal(stage, '会话排队已满,请稍后重试')
return refusal(stage, describeRenderFailure(err).message)
}
/** 记录对话:复用宿主项目中标题为审签记录、未归档的对话;没有则新建。 */
async function recordConversation(port: DecisionPort, projectId: string): Promise<Conversation> {
let list: Conversation[]
try {
list = await port.listConversations(projectId)
} catch (e) {
throw stepFailure(CONVERSATION_STAGE, e)
}
const existing = list.find(c => c.title === DECISION_RECORD_TITLE && !c.archived)
if (existing) return existing
try {
return await port.createConversation(projectId, DECISION_RECORD_TITLE)
} catch (e) {
throw stepFailure(CONVERSATION_STAGE, e)
}
}
/** 提交来源消息:必须是已提交的同会话用户消息;被当作命令路由的说明不算来源。 */
async function recordSource(port: DecisionPort, convId: string, req: DecisionRequest): Promise<string> {
let out: { message?: Message; command?: string }
try {
out = await port.sendMessage(convId, decisionSourceText(req), [], `decision:${req.operationId}`)
} catch (e) {
throw stepFailure(SOURCE_STAGE, e)
}
if (out.command) throw refusal(SOURCE_STAGE, '审签说明被识别为命令,未作为来源记录')
const msg = out.message
if (!msg?.id || msg.role !== 'user' || msg.conversationId !== convId) {
throw refusal(SOURCE_STAGE, '服务端未返回已提交的对话消息')
}
return msg.id
}
/**
* 整文审签的唯一写路径:先记录来源,再以来源消息为锚点分发命令。
* 宿主项目缺失、项目未就绪、任一记录步骤失败时,命令一律不分发。
*/
export async function commitDecision(port: DecisionPort, req: DecisionRequest): Promise<unknown> {
const host = req.host
if (!host) throw new Error('未找到该研发项目对应的对话项目,请刷新项目列表后重试;审签未执行')
const gate = rdmProjectCommandError(host, req.command)
if (gate) throw new Error(`${gate};审签未执行`)
const conversation = await recordConversation(port, host.id)
const anchorMessageId = await recordSource(port, conversation.id, req)
return port.rdmCommand(req.command, req.params, {
conversationId: conversation.id, anchorMessageId, entrance: DECISION_ENTRANCE,
})
}
File diff suppressed because it is too large Load Diff
+19 -9
View File
@@ -1,4 +1,6 @@
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
import { dutyAccountIds, type DutyAccountIds } from '../rdmManagement'
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
interface DutyRoles {
@@ -35,6 +37,7 @@ interface DutySaveItem {
approver: string
expectedDutyVersion: number
confirmInvalidation: boolean
accountIds?: DutyAccountIds
}
export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
@@ -174,7 +177,10 @@ export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
return docs.filter(doc => doc.dirty)
}
export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): { docs: DutySaveItem[]; issues: string[] } {
/** accountIds:候选检索选中的 owner → userId;每份文档只携带其职责涉及的人。 */
export function savePayload(
docs: DutyDocDraft[], confirmInvalidation: boolean, accountIds?: DutyAccountIds,
): { docs: DutySaveItem[]; issues: string[] } {
const dirty = dirtyDocs(docs)
const issues: string[] = []
if (dirty.length === 0) issues.push('没有未保存的职责')
@@ -183,14 +189,18 @@ export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean):
for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`)
}
return {
docs: dirty.slice(0, 20).map(doc => ({
docId: doc.docId,
author: doc.author,
reviewers: doc.reviewers,
approver: doc.approver,
expectedDutyVersion: doc.dutyVersion,
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
})),
docs: dirty.slice(0, 20).map(doc => {
const ids = dutyAccountIds(doc, accountIds)
return {
docId: doc.docId,
author: doc.author,
reviewers: doc.reviewers,
approver: doc.approver,
expectedDutyVersion: doc.dutyVersion,
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
...(ids ? { accountIds: ids } : {}),
}
}),
issues,
}
}
+67
View File
@@ -1,5 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Picker } from '@ui'
import type { CellDuty, DutyDocDraft, DutyMember } from './draft'
import { cellDuty, completeness, replacementNote, saveIssues } from './draft'
import {
MATCH_MODE_LABELS, SEARCH_FIELDS,
type MemberMatchMode, type PersonPickItem, type SearchFieldsChoice,
} from '../rdmManagement'
const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [
{ value: 'none', label: '无职责' },
@@ -120,6 +126,67 @@ export function DutyByDocumentView({
)
}
type AssignedDuty = Exclude<CellDuty, 'none'>
/** 文档职责候选检索:rdm.account.list 携带 docId,只有当前有效成员可选;选中后按 userId 指派。 */
export function DutyCandidateSearch({
doc, mode, fields, fetchPage, reloadKey, disabled, onMode, onFields, onAssign,
}: {
doc: DutyDocDraft
mode: MemberMatchMode
fields: SearchFieldsChoice
fetchPage: (query: string, cursor: string, limit: number) => Promise<{ items: PersonPickItem[]; nextCursor?: string }>
reloadKey: string
disabled: boolean
onMode: (mode: MemberMatchMode) => void
onFields: (fields: SearchFieldsChoice) => void
onAssign: (candidate: PersonPickItem, duty: AssignedDuty) => void
}) {
const [picked, setPicked] = useState<PersonPickItem | null>(null)
const [duty, setDuty] = useState<AssignedDuty>('reviewer')
const known = useRef(new Map<string, PersonPickItem>())
useEffect(() => { known.current.clear(); setPicked(null) }, [reloadKey])
const fetchKnown = useCallback(async (query: string, cursor: string, limit: number) => {
const page = await fetchPage(query, cursor, limit)
for (const item of page.items) known.current.set(item.id, item)
return page
}, [fetchPage])
return (
<div className="settings-group">
<h4>搜索候选人</h4>
<div className="ad-row">
<select className="lg-input" aria-label="候选匹配方式" value={mode} onChange={event => onMode(event.target.value as MemberMatchMode)}>
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m => <option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select>
<select className="lg-input" aria-label="候选检索字段" value={fields} onChange={event => onFields(event.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f => <option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
</div>
<div className="ad-row">
<Picker
aria-label="职责候选人"
selected={picked ? [picked.id] : []}
selectedItems={picked ? [picked] : []}
onSelect={ids => setPicked(known.current.get(ids[0] ?? '') ?? null)}
fetchPage={fetchKnown}
reloadKey={reloadKey}
disabled={disabled}
placeholder={mode === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示当前有效成员"
/>
<select className="lg-input" aria-label="候选人职责" value={duty} onChange={event => setDuty(event.target.value as AssignedDuty)}>
{DUTY_OPTIONS.filter(option => option.value !== 'none').map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<button className="ad-mini" disabled={disabled || !picked || picked.disabled}
onClick={() => { if (picked) { onAssign(picked, duty); setPicked(null) } }}>
{picked ? `指派到 ${doc.title}` : '指派'}
</button>
</div>
{picked && <p className="ad-hint">{picked.title} · {picked.owner}{replacementNote(doc, picked.owner, duty) ? `(${replacementNote(doc, picked.owner, duty)})` : ''}</p>}
</div>
)
}
export function DutyMatrixView({
docs, members, disabled, narrow, onCell,
}: {
-11
View File
@@ -1,11 +0,0 @@
// 浏览器端随机初始密码(管理员未显式给定密码时):
// crypto.getRandomValues 拒绝易混淆字符(0/O/1/l/I),保证可抄写。
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'
export function randomPassword(length = 12): string {
const buf = new Uint32Array(length)
crypto.getRandomValues(buf)
let out = ''
for (let i = 0; i < length; i++) out += ALPHABET[buf[i] % ALPHABET.length]
return out
}
+2 -1
View File
@@ -3,13 +3,14 @@
export interface PreviewTab {
id: string
name: string
sourceContext?: {notice:string;quote:string;expectedHash?:string}
}
export function openPreviewTab(tabs: PreviewTab[], tab: PreviewTab): { tabs: PreviewTab[]; activeId: string } {
const i = tabs.findIndex(t => t.id === tab.id)
if (i >= 0) {
const next = tabs.slice()
next[i] = { ...next[i], name: tab.name || next[i].name }
next[i] = { ...next[i], name: tab.name || next[i].name,sourceContext:tab.sourceContext }
return { tabs: next, activeId: tab.id }
}
return { tabs: [...tabs, tab], activeId: tab.id }
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import {
canCreateProject, canManageProjectMembers, emptyProjectsNotice, memberDuty, noProjectError, projectCapabilitySummary,
} from './projectmeta'
const linked = (projectCreate: boolean) => ({ projectCreate, source: 'role', linkedActions: [], authorityEpoch: 3 })
describe('EmptyListNoCreate', () => {
it('asks a member without creation capability to wait for an invite instead of creating a project', () => {
const notice = emptyProjectsNotice(true, false)
expect(notice).toContain('等待项目负责人邀请')
expect(notice).not.toContain('新建')
expect(noProjectError(true, false)).not.toContain('新建')
})
it('does not call a list that has not arrived empty', () => {
expect(emptyProjectsNotice(false, true)).toBe('正在加载项目列表…')
expect(noProjectError(false, false)).toContain('尚未加载')
})
it('points a creator to explicit project creation', () => {
expect(emptyProjectsNotice(true, true)).toContain('新建项目')
})
})
describe('DefaultProjectDisabled creation entry', () => {
it('follows the server-computed creation link first', () => {
expect(canCreateProject({ auth: true, isAdmin: true, systemActions: ['project.create'], linkedCapabilities: linked(false) })).toBe(false)
expect(canCreateProject({ auth: true, systemActions: [], linkedCapabilities: linked(true) })).toBe(true)
})
it('falls back to explicit system actions and fails closed without an identity', () => {
expect(canCreateProject({ auth: true, systemActions: [] })).toBe(false)
expect(canCreateProject({ auth: true, systemActions: ['project.create'] })).toBe(true)
expect(canCreateProject(null)).toBe(false)
expect(canCreateProject({ auth: false })).toBe(true)
})
it('summarizes the creation source and the linked own-project management', () => {
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(true) })).toContain('来源:职位角色')
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: { ...linked(true), conflicts: ['project.members.manage'] } }))
.toContain('1 项关联被角色上限阻断')
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(false) })).toContain('被邀请加入后项目自动出现')
})
})
describe('CreatorOwnerMemberDuties', () => {
it('gives member management only to the immutable creator, not to an owner label', () => {
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice')).toMatchObject({ kind: 'creator', label: '创建者' })
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').duties).toContain('成员增删')
const owner = memberDuty({ owner: 'bob', role: 'owner' }, 'alice')
expect(owner).toMatchObject({ kind: 'owner', label: '项目负责人' })
expect(owner.duties).toContain('不含成员管理')
const member = memberDuty({ owner: 'carol', role: 'member' }, 'alice')
expect(member).toMatchObject({ kind: 'member', source: '成员关系' })
expect(member.duties).toContain('本人对话')
})
})
describe('MemberManagementEntryHidden capability', () => {
it('requires an allowed member management action from the self check', () => {
expect(canManageProjectMembers(null)).toBe(false)
expect(canManageProjectMembers([{ action: 'member.manage', allowed: false }])).toBe(false)
expect(canManageProjectMembers([{ action: 'doc.read', allowed: true }])).toBe(false)
expect(canManageProjectMembers([{ action: 'member.manage', allowed: true }])).toBe(true)
expect(canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])).toBe(true)
})
it('treats the two old and new capability ids as equivalent for backward compatibility', () => {
const onlyLegacy = canManageProjectMembers([{ action: 'member.manage', allowed: true }])
const onlyModern = canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])
const both = canManageProjectMembers([
{ action: 'member.manage', allowed: true },
{ action: 'project.members.manage', allowed: true },
])
expect(onlyLegacy).toBe(onlyModern)
expect(onlyModern).toBe(true)
expect(both).toBe(true)
})
})
describe('CreatorOwnerMemberDuties member identity', () => {
it('falls back to plain member when the creator record is missing', () => {
const member = memberDuty({ owner: 'carol', role: 'member' }, undefined)
expect(member).toMatchObject({ kind: 'member', label: '项目成员' })
expect(member.duties).toContain('基础协作')
})
it('names the source of each member kind distinctly', () => {
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').source).toMatch(/创建项目/)
expect(memberDuty({ owner: 'bob', role: 'owner' }, 'alice').source).toMatch(/成员关系/)
expect(memberDuty({ owner: 'carol', role: 'member' }, 'alice').source).toBe('成员关系')
})
})
describe('DefaultProjectDisabled empty state messaging', () => {
it('keeps both helpers pointing the member to wait for an invite', () => {
expect(emptyProjectsNotice(true, false)).toContain('等待项目负责人邀请')
expect(noProjectError(true, false)).toContain('等待项目负责人邀请')
})
it('keeps the loading notice distinct from a real empty list', () => {
expect(emptyProjectsNotice(false, false)).toMatch(/加载/)
expect(noProjectError(false, false)).toMatch(/加载/)
expect(emptyProjectsNotice(false, false)).not.toBe(noProjectError(false, false))
})
})
+69
View File
@@ -1,3 +1,5 @@
import type { LinkedCapabilities } from './api'
/** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */
export function projectStage(settings?: Record<string, unknown> | null): string | null {
const stage = settings?.stage
@@ -41,3 +43,70 @@ export function projectActionError(error: unknown): string {
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
return raw.replace(/^Error:\s*/, '')
}
interface ProjectActor {
auth?: boolean; isAdmin?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities
}
/** 只决定是否展示创建入口;服务端仍按当前身份最终判定。 */
export function canCreateProject(me: ProjectActor | null | undefined): boolean {
if (!me) return false
if (me.auth === false) return true
if (me.linkedCapabilities) return me.linkedCapabilities.projectCreate
// Backends without capability exposure keep the server as the only gate.
if (me.systemActions === undefined) return true
return !!me.isAdmin || me.systemActions.includes('project.create')
}
const CAPABILITY_SOURCE_LABELS: Record<string, string> = {
role: '职位角色', 'system-grant': '明确系统授权', 'bootstrap-admin': '初始管理员',
}
function capabilitySourceLabel(source: string | undefined): string {
return (source && CAPABILITY_SOURCE_LABELS[source]) || source || '未知来源'
}
/** 账户区一句话:本人是否能创建项目、来源、以及联动形成的本人项目管理资格。 */
export function projectCapabilitySummary(me: ProjectActor | null | undefined): string {
const linked = me?.linkedCapabilities
if (!linked) return canCreateProject(me) ? '可创建项目' : '未授予创建项目 · 被邀请加入后项目自动出现'
if (!linked.projectCreate) return '未授予创建项目 · 被邀请加入后项目自动出现'
const conflicts = linked.conflicts?.length ? ` · ${linked.conflicts.length} 项关联被角色上限阻断` : ''
return `可创建项目(来源:${capabilitySourceLabel(linked.source)})· 本人创建的项目自动具备设置、成员、项目权限与文档职责管理${conflicts}`
}
/** 空项目列表:未加载不当作空;无创建能力时只提示等待邀请,不诱导新建同名项目。 */
export function emptyProjectsNotice(loaded: boolean, canCreate: boolean): string {
if (!loaded) return '正在加载项目列表…'
if (canCreate) return '还没有项目:从「新建项目」开始。'
return '尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。'
}
/** 新建对话时没有可用项目:不再自动创建「默认项目」。 */
export function noProjectError(loaded: boolean, canCreate: boolean): string {
if (!loaded) return '项目列表尚未加载完成,请稍候再试'
if (canCreate) return '还没有项目,请先新建项目'
return '尚无可用项目,请等待项目负责人邀请你加入'
}
interface MemberDuty { kind: 'creator' | 'owner' | 'member'; label: string; duties: string; source: string }
/** 项目内实际职责:成员管理只随不可变创建者;负责人只是界面身份。 */
export function memberDuty(member: { owner: string; role: string }, creator: string | undefined): MemberDuty {
if (creator && member.owner === creator) {
return { kind: 'creator', label: '创建者', duties: '项目设置、成员增删、项目内权限、文档职责分配 + 基础协作',
source: '随「创建项目」联动(仅本人创建的项目)' }
}
if (member.role === 'owner') {
return { kind: 'owner', label: '项目负责人', duties: '基础协作:项目阅读与本人对话;不含成员管理',
source: '成员关系(界面身份不授予创建者管理权)' }
}
return { kind: 'member', label: '项目成员', duties: '基础协作:查看与切换项目、两树一网阅读、本人对话', source: '成员关系' }
}
const MEMBER_MANAGE_ACTIONS = new Set(['member.manage', 'project.members.manage'])
/** 本人自省结果里成员管理是否生效;未知(null)按无权处理。 */
export function canManageProjectMembers(actions: { action: string; allowed: boolean }[] | null | undefined): boolean {
return !!actions?.some(a => MEMBER_MANAGE_ACTIONS.has(a.action) && a.allowed)
}
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import {
describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState,
isTreeItemExpandable, relationOtherEndpoint,
} from './rdmAuditPresentation'
describe('RDM audit presentation', () => {
it('turns a structured document event into a readable action and impact', () => {
expect(describeAuditEvent({
seq: 18,
at: '2026-10-15T08:00:00Z',
op: 'doc.instantiate',
actor: 'alice',
source: 'template:dhf-proposal@v2',
target: { kind: 'doc', id: 'doc-1', title: '项目建议书' },
detail: '{"raw":"must not be shown"}',
operationId: 'op-1',
disclosure: 'ok',
result: 'committed',
impact: '新增 12 个递归节点',
reason: '从已发布模板建立项目实例',
})).toEqual({
action: '创建文档实例',
subject: 'Alice',
object: '文档“项目建议书”',
outcome: '已生效',
reason: '从已发布模板建立项目实例',
impact: '新增 12 个递归节点',
source: '模板:DHF 项目建议书(版本 v2)',
})
})
it('does not expose unknown operation codes or raw JSON as user-facing text', () => {
const view = describeAuditEvent({
seq: 19,
at: '2026-10-15T08:01:00Z',
op: 'internal.weird_opcode',
actor: 'user-42',
source: 'host',
target: { kind: 'node', id: 'node-secret' },
detail: '{"secret":"raw payload"}',
operationId: 'op-2',
disclosure: 'restricted',
})
expect(view.action).toBe('更新项目内容')
expect(JSON.stringify(view)).not.toContain('internal.weird_opcode')
expect(JSON.stringify(view)).not.toContain('raw payload')
expect(view.outcome).toBe('部分信息受限')
expect(view.object).toBe('节点(node-secret)')
expect(view.source).toBe('系统受信操作记录')
})
it('preserves domain provenance and object identity in readable audit rows', () => {
expect(describeAuditEvent({
seq: 20,
at: '2026-10-15T08:02:00Z',
op: 'edge.candidate',
actor: 'model-runner',
source: 'model',
target: { kind: 'edge', id: 'edg-7' },
operationId: 'op-3',
disclosure: 'model-candidate',
})).toMatchObject({
action: '提出追溯关系候选',
object: '追溯边(edg-7)',
source: '模型分析候选',
})
expect(describeAuditEvent({
seq: 21,
at: '2026-10-15T08:03:00Z',
op: 'section.add',
actor: 'alice',
source: 'human',
target: { kind: 'section', id: 'sec-2' },
operationId: 'op-4',
disclosure: 'ok',
}).source).toBe('项目成员操作')
})
it('humanizes tree, relationship, and lifecycle vocabulary', () => {
expect(humanizeKind('section')).toBe('章节')
expect(humanizeState('candidate')).toBe('待确认')
expect(humanizeState('retired')).toBe('已停用')
expect(humanizeState('stale')).toBe('待复核')
expect(humanizeRelation({ layer: 'compliance', type: 'verifies', state: 'active' })).toEqual({
layer: '合规关系',
type: '验证',
state: '已生效',
})
})
it('allows recursive sections to expand when the server reports children', () => {
expect(isTreeItemExpandable({ kind: 'doc', id: 'doc-1', title: '文档', hasChildren: true })).toBe(true)
expect(isTreeItemExpandable({ kind: 'doc', id: 'doc-2', title: '空文档', hasChildren: false })).toBe(false)
expect(isTreeItemExpandable({ kind: 'section', id: 'sec-1', title: '章节', hasChildren: true })).toBe(true)
expect(isTreeItemExpandable({ kind: 'paragraph', id: 'p-1', title: '段落', hasChildren: false })).toBe(false)
})
it('gives untitled template nodes a distinct readable name', () => {
expect(describeTreeItem({
kind: 'paragraph',
id: 'dn-1',
title: '',
order: 6,
templateBlockId: 'dhf.dx.a7850d1881.3.placeholder',
})).toEqual({ kind: '段落', title: '模板内容 7(3)' })
expect(describeTreeItem({ kind: 'table', id: 'dn-2', title: '', order: 2 })).toEqual({
kind: '表格',
title: '模板内容 3',
})
})
it('shows the endpoint opposite the selected object in a trace relationship', () => {
const relation = {
from: { kind: 'slice', id: 'slc-6' },
to: { kind: 'section', id: 'sec-2' },
}
expect(relationOtherEndpoint(relation, { kind: 'section', id: 'sec-2' })).toEqual(relation.from)
expect(relationOtherEndpoint(relation, { kind: 'slice', id: 'slc-6' })).toEqual(relation.to)
})
})
+184
View File
@@ -0,0 +1,184 @@
export interface AuditEventViewInput {
seq: number
at: string
op: string
actor: string
source: string
target: { kind: string; id: string; title?: string }
detail?: string
operationId: string
disclosure: string
summary?: string
result?: string
reason?: string
impact?: string
}
interface AuditEventPresentation {
action: string
subject: string
object: string
outcome: string
reason: string
impact: string
source: string
}
const KIND_LABELS: Record<string, string> = {
doc: '文档', section: '章节', node: '节点', note: '知识', slice: '资料切片', spine: '知识主题', edge: '追溯边',
paragraph: '段落', table: '表格', row: '表格行', cell: '表格单元格',
field: '填写字段', attachment: '附件',
requirement: '要求', risk: '风险', proposal: '变更提案', relation: '追溯关系',
template: '模板', project: '项目', conclusion: '结论', issue: '问题',
}
const STATE_LABELS: Record<string, string> = {
active: '已生效', candidate: '待确认', pending: '待处理', baselined: '已建立基线',
retired: '已停用', stale: '待复核',
approved: '已批准', rejected: '已拒绝', revoked: '已撤销', superseded: '已被取代',
failed: '失败', committed: '已生效', accepted: '已接受', cancelled: '已取消',
delivered: '已记录',
unknown: '结果未知', restricted: '部分信息受限',
}
const OP_LABELS: Record<string, string> = {
'doc.instantiate': '创建文档实例',
'section.add': '新增文档章节',
'section.version': '更新文档内容',
'section.state': '变更章节状态',
'section.tombstone': '停用文档章节',
'slice.ingest': '新增项目资料',
'slice.retag': '更新资料标签',
'slice.tombstone': '停用项目资料',
'spine.add': '新增知识主题',
'mount.add': '挂接项目资料',
'mount.move': '移动项目资料',
'mount.remove': '移除资料挂接',
'edge.candidate': '提出追溯关系候选',
'edge.confirm': '确认追溯关系',
'edge.retire': '停用追溯关系',
'change.requested': '提出变更申请',
'change.approved': '批准变更申请',
'change.applied': '应用变更',
'open-item.add': '登记项目问题',
'open-item.close': '关闭项目问题',
'dictionary.revise': '修订项目字典',
'req.change': '更新项目要求',
}
const RELATION_LABELS: Record<string, string> = {
verifies: '验证', supports: '支持', derives: '派生自', impacts: '影响',
traces: '追溯到', replaces: '取代', conflicts: '存在冲突', references: '引用',
}
const LAYER_LABELS: Record<string, string> = {
compliance: '合规关系', knowledge: '知识关系', document: '文档关系', project: '项目关系',
}
export function humanizeKind(kind: string): string {
return KIND_LABELS[kind] || '项目对象'
}
export function describeTreeItem(item: {
kind: string
id: string
title?: string
order?: number
templateBlockId?: string
}): { kind: string; title: string } {
const title = item.title?.trim()
if (title) return { kind: humanizeKind(item.kind), title }
const position = typeof item.order === 'number' ? item.order + 1 : undefined
const parts = (item.templateBlockId || '').split('.').filter(Boolean)
const semantic = new Set(['placeholder', 'example', 'fixed'])
while (parts.length > 0 && semantic.has(parts[parts.length - 1])) parts.pop()
const hint = parts.at(-1)
const numbered = position ? `模板内容 ${position}` : '未命名模板内容'
return {
kind: humanizeKind(item.kind),
title: hint ? `${numbered}(${hint})` : numbered,
}
}
export function humanizeState(state: string): string {
return STATE_LABELS[state] || '状态已记录'
}
function humanizeOperation(op: string): string {
if (OP_LABELS[op]) return OP_LABELS[op]
if (op.startsWith('doc.')) return '更新文档内容'
if (op.startsWith('section.')) return '更新文档内容'
if (op.startsWith('slice.') || op.startsWith('spine.') || op.startsWith('mount.') || op.startsWith('knowledge.')) return '更新项目知识'
if (op.startsWith('edge.') || op.startsWith('relation.') || op.startsWith('trace.')) return '更新追溯关系'
if (op.startsWith('change.')) return '处理项目变更'
if (op.startsWith('open-item.')) return '处理项目问题'
if (op.startsWith('risk.')) return '处理项目风险'
if (op.startsWith('proposal.')) return '处理结构变更'
return '更新项目内容'
}
function humanizeActor(actor: string): string {
if (!actor) return '未知成员'
if (/^[a-z][a-z0-9._-]*$/i.test(actor) && !actor.includes('-') && !actor.includes('_')) {
return actor.charAt(0).toUpperCase() + actor.slice(1)
}
return actor
}
function humanizeSource(source: string): string {
if (!source) return '当时未记录来源'
const template = /^template:([^@]+)@(.+)$/.exec(source)
if (template) {
const title = template[1] === 'dhf-proposal' ? 'DHF 项目建议书' : template[1]
return `模板:${title}(版本 ${template[2]})`
}
if (source === 'host') return '系统受信操作记录'
if (source === 'human') return '项目成员操作'
if (source === 'rule') return '项目规则推导'
if (source === 'model') return '模型分析候选'
if (source.startsWith('message:')) return '项目对话'
if (source.startsWith('artifact:')) return '项目文件'
return '已登记项目来源'
}
function humanizeOutcome(input: AuditEventViewInput): string {
if (input.disclosure !== 'ok') return input.disclosure === 'model-candidate' ? '模型候选,尚未确认' : '部分信息受限'
return humanizeState(input.result || 'committed')
}
export function describeAuditEvent(input: AuditEventViewInput): AuditEventPresentation {
const targetTitle = input.target?.title ? `“${input.target.title}”` : ''
const targetIdentity = targetTitle || (input.target?.id ? `(${input.target.id})` : '')
return {
action: input.summary || humanizeOperation(input.op),
subject: humanizeActor(input.actor),
object: `${humanizeKind(input.target?.kind)}${targetIdentity}`,
outcome: humanizeOutcome(input),
reason: input.reason || '未记录具体原因',
impact: input.impact || '影响范围尚未记录',
source: humanizeSource(input.source),
}
}
export function humanizeRelation(input: { layer?: string; type?: string; kind?: string; state?: string }) {
const type = input.type || input.kind || ''
return {
layer: LAYER_LABELS[input.layer || ''] || '项目关系',
type: RELATION_LABELS[type] || '关联',
state: humanizeState(input.state || ''),
}
}
export function relationOtherEndpoint<T extends { kind?: string; id?: string }>(
relation: { from?: T; to?: T },
selected: { kind: string; id: string },
): T | undefined {
if (relation.to?.kind === selected.kind && relation.to.id === selected.id) return relation.from
if (relation.from?.kind === selected.kind && relation.from.id === selected.id) return relation.to
return relation.from || relation.to
}
export function isTreeItemExpandable(item: { kind: string; hasChildren?: boolean; id?: string; title?: string }): boolean {
return item.kind === 'spine' || item.hasChildren === true
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { collectRecursiveTree, isTreeItemInspectable, mergeTreePage, type AuditTreePage } from './rdmAuditTree'
describe('RDM audit tree traversal', () => {
it('keeps child cursors and appends later pages', () => {
const first: AuditTreePage = {
items: [{ kind: 'paragraph', id: 'p-1', title: '一' }],
nextCursor: 'next', hasMore: true, truncated: false,
}
const second: AuditTreePage = {
items: [{ kind: 'paragraph', id: 'p-2', title: '二' }],
hasMore: false, truncated: false,
}
expect(mergeTreePage(first, second)).toEqual({
items: [...first.items, ...second.items],
hasMore: false,
truncated: false,
})
})
it('collects every page and every server-reported depth for trace selection', async () => {
const pages = new Map<string, AuditTreePage>([
['|', { items: [{ kind: 'doc', id: 'doc-1', title: '文档', hasChildren: true }], nextCursor: 'root-2', hasMore: true, truncated: false }],
['|root-2', { items: [{ kind: 'doc', id: 'doc-2', title: '文档二', hasChildren: false }], hasMore: false, truncated: false }],
['doc-1|', { items: [{ kind: 'section', id: 'dn-sec', title: '模板章', templateBlockId: 'tpl.sec', hasChildren: true }], nextCursor: 'child-2', hasMore: true, truncated: false }],
['doc-1|child-2', { items: [{ kind: 'section', id: 'sec-2', title: '正文', hasChildren: false }], hasMore: false, truncated: false }],
['dn-sec|', { items: [{ kind: 'paragraph', id: 'p-deep', title: '深层内容', hasChildren: false }], hasMore: false, truncated: false }],
])
const items = await collectRecursiveTree(async (parent, cursor) => {
const page = pages.get(`${parent}|${cursor}`)
if (!page) throw new Error(`unexpected page ${parent}|${cursor}`)
return page
})
expect(items.map(item => item.id)).toEqual(['doc-1', 'doc-2', 'dn-sec', 'sec-2', 'p-deep'])
})
it('only requests object details for legacy sections supported by the server', () => {
expect(isTreeItemInspectable({ kind: 'section', id: 'sec-2', title: '正文', templateBlockId: '' })).toBe(true)
expect(isTreeItemInspectable({ kind: 'section', id: 'dn-sec', title: '模板章', templateBlockId: 'tpl.sec' })).toBe(false)
expect(isTreeItemInspectable({ kind: 'paragraph', id: 'p-1', title: '内容' })).toBe(false)
})
})
+59
View File
@@ -0,0 +1,59 @@
export interface AuditTreeItem {
kind: string
id: string
title: string
state?: string
hasChildren?: boolean
order?: number
templateBlockId?: string
}
export interface AuditTreePage {
items: AuditTreeItem[]
nextCursor?: string
hasMore: boolean
truncated: boolean
}
type AuditTreePageFetcher = (parent: string, cursor: string) => Promise<AuditTreePage>
export function mergeTreePage(current: AuditTreePage, next: AuditTreePage): AuditTreePage {
return { ...next, items: [...current.items, ...next.items] }
}
async function collectParentPages(fetchPage: AuditTreePageFetcher, parent: string): Promise<AuditTreeItem[]> {
const items: AuditTreeItem[] = []
const seenCursors = new Set<string>()
let cursor = ''
for (let pageNo = 0; pageNo < 100; pageNo++) {
if (seenCursors.has(cursor)) throw new Error('树分页游标重复,已停止加载')
seenCursors.add(cursor)
const page = await fetchPage(parent, cursor)
items.push(...page.items)
if (!page.hasMore) return items
if (!page.nextCursor) throw new Error('树分页缺少后续游标')
cursor = page.nextCursor
}
throw new Error('树分页超过安全上限')
}
export async function collectRecursiveTree(fetchPage: AuditTreePageFetcher): Promise<AuditTreeItem[]> {
const roots = await collectParentPages(fetchPage, '')
const out = [...roots]
const queue = roots.filter(item => item.hasChildren === true)
const seenParents = new Set<string>()
while (queue.length > 0) {
const parent = queue.shift()!
if (seenParents.has(parent.id)) continue
seenParents.add(parent.id)
const children = await collectParentPages(fetchPage, parent.id)
out.push(...children)
if (out.length > 5000) throw new Error('树节点超过安全上限')
queue.push(...children.filter(item => item.hasChildren === true))
}
return out
}
export function isTreeItemInspectable(item: AuditTreeItem): boolean {
return item.kind === 'section' && !item.templateBlockId && !item.id.startsWith('dn-')
}
+202 -9
View File
@@ -27,6 +27,7 @@ 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.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan',
'rdm.role.options',
])
export function rdmProjectCommandError(project: Project, command: string): string | null {
@@ -101,19 +102,39 @@ export const roleActionPayload = (roleId: string, action: string, def: boolean,
export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId })
export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled })
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
({ projectId, member, role })
/** userId 为候选检索选中的稳定账号;服务端按 member 精确查找后比对,不一致即拒。 */
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member', userId?: string) =>
({ projectId, member, role, ...(userId ? { userId } : {}) })
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
/** 职责账号身份:owner → 候选检索选中的 userId。 */
export type DutyAccountIds = Record<string, string>
/** 只保留本条职责实际涉及的人;为空不携带。 */
export function dutyAccountIds(
roles: { author: string; reviewers: string[]; approver: string }, known: DutyAccountIds | undefined,
): DutyAccountIds | undefined {
if (!known) return undefined
const out: DutyAccountIds = {}
for (const owner of [roles.author, ...roles.reviewers, roles.approver]) {
if (owner && known[owner]) out[owner] = known[owner]
}
return Object.keys(out).length > 0 ? out : undefined
}
export const assignDocDutiesPayload = (
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean },
) => ({
projectId, docId, author, reviewers, approver,
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
})
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean; accountIds?: DutyAccountIds },
) => {
const accountIds = dutyAccountIds({ author, reviewers, approver }, extra?.accountIds)
return {
projectId, docId, author, reviewers, approver,
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
...(accountIds ? { accountIds } : {}),
}
}
export const dutyMatrixPayload = (
projectId: string,
@@ -135,7 +156,10 @@ export const dutyMatrixPayload = (
export const dutyPreviewPayload = (
projectId: string,
docs: { docId: string; author: string; reviewers: string[]; approver: string; expectedDutyVersion: number; confirmInvalidation: boolean }[],
docs: {
docId: string; author: string; reviewers: string[]; approver: string
expectedDutyVersion: number; confirmInvalidation: boolean; accountIds?: DutyAccountIds
}[],
) => ({ projectId, docs })
export const dutyLegacyPayload = (projectId: string) => ({ projectId })
@@ -152,6 +176,175 @@ export const docStatusPayload = (projectId: string, docId: string) => ({ project
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
/**
* 命令来源(基座 cmdbus.RequiresProjectWorkDecision):正式写命令必须带已提交的对话来源,
* 且位于请求体顶层(conversationId/anchorMessageId/entrance),不得放进 params。
* entrance 取服务端 CanonicalEntrance 集合;页面动作为 button。
*/
export interface CommandSource {
conversationId: string
anchorMessageId: string
entrance: 'button' | 'card' | 'slash' | 'chat' | 'api' | 'agent'
}
export const commandRequestBody = (params: Record<string, unknown>, source?: CommandSource) =>
source
? { params, conversationId: source.conversationId, anchorMessageId: source.anchorMessageId, entrance: source.entrance }
: { params }
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
export type MemberMatchMode = 'fuzzy' | 'exact'
type AccountSearchField = 'owner' | 'name'
export interface RdmProfRole { roleId: string; name: string }
/** rdm.account.list 单条候选。 */
export interface RdmAccount {
userId: string; owner: string; name?: string; valid: boolean; joined?: boolean
projectRole?: string; profRole?: RdmProfRole | null
}
export interface RdmAccountPage {
accounts?: RdmAccount[]; next?: string; directoryVersion?: string; count?: number
}
/** rdm.project.member.list 单条成员。 */
export interface RdmMemberRow {
owner: string; name?: string; role: string; addedBy: string; addedAt: string
profRole?: RdmProfRole | null
}
export interface RdmMemberPage {
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
}
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */
export interface PersonPickItem {
id: string; owner: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string
}
export const MEMBER_PAGE_LIMIT = 50
const ACCOUNT_PAGE_LIMIT = 50
export const JOINED_REASON = '已加入项目'
export const INVALID_REASON = '账号无效'
export const NOT_MEMBER_REASON = '非当前项目成员'
export const MATCH_MODE_LABELS: Record<MemberMatchMode, string> = {
fuzzy: '模糊匹配',
exact: '精确账号或姓名',
}
export type SearchFieldsChoice = 'both' | 'owner' | 'name'
export const SEARCH_FIELDS: Record<SearchFieldsChoice, { label: string; fields: AccountSearchField[] }> = {
both: { label: '账号和姓名', fields: ['owner', 'name'] },
owner: { label: '仅账号', fields: ['owner'] },
name: { label: '仅姓名', fields: ['name'] },
}
export const roleOptionsPayload = (projectId: string) => ({ projectId, purpose: 'members' as const })
interface AccountSearchOptions {
mode?: MemberMatchMode
searchFields?: AccountSearchField[]
roleId?: string
/** 携带即按该文档的职责候选检索(仅当前有效成员)。 */
docId?: string
}
/** 只用 mode(match 是服务端旧别名,两者并存且不一致会被拒);空串筛选不携带。 */
export function accountListParams(projectId: string, opts: AccountSearchOptions & {
query?: string; cursor?: string; limit: number
}) {
const query = opts.query?.trim() ?? ''
return {
projectId,
limit: Math.min(Math.max(1, opts.limit), ACCOUNT_PAGE_LIMIT),
mode: opts.mode ?? 'fuzzy',
...(opts.searchFields && opts.searchFields.length > 0 ? { searchFields: [...opts.searchFields] } : {}),
...(query ? { query } : {}),
...(opts.cursor ? { cursor: opts.cursor } : {}),
...(opts.roleId ? { roleId: opts.roleId } : {}),
...(opts.docId ? { docId: opts.docId } : {}),
}
}
/** Picker reloadKey:项目/文档/匹配方式/检索字段/角色任一变化都清空查询与游标。 */
export function accountSearchKey(projectId: string, opts: AccountSearchOptions): string {
return JSON.stringify([projectId, opts.docId ?? '', opts.mode ?? 'fuzzy', opts.searchFields ?? [], opts.roleId ?? ''])
}
/**
* Picker 取数适配。空 query 不发请求(服务端空查询不枚举);cursor 只在签发它的
* 同一检索条件(query + 选项)下沿用,条件变化即回到首页。
*/
export function accountCandidatePager(
run: (params: ReturnType<typeof accountListParams>) => Promise<RdmAccountPage>,
projectId: string,
opts: AccountSearchOptions & { joinedOwners?: () => Iterable<string> },
) {
const base = accountSearchKey(projectId, opts)
let issuedFor = ''
return async (query: string, cursor: string, limit: number): Promise<{ items: PersonPickItem[]; nextCursor?: string }> => {
const q = query.trim()
if (!q) { issuedFor = ''; return { items: [] } }
const key = `${base}|${q}`
const out = await run(accountListParams(projectId, { ...opts, query: q, cursor: key === issuedFor ? cursor : '', limit }))
issuedFor = out.next ? key : ''
const items = accountsToPickItems(out.accounts, opts.joinedOwners?.() ?? [], opts.docId ? 'duty' : 'member')
return { items, nextCursor: out.next || undefined }
}
}
export function memberListParams(projectId: string, opts: {
query?: string; roleId?: string; cursor?: string; limit?: number
match?: MemberMatchMode; projectRole?: 'owner' | 'member'
} = {}) {
const query = opts.query?.trim() ?? ''
return {
projectId,
limit: opts.limit ?? MEMBER_PAGE_LIMIT,
...(query ? { query } : {}),
...(opts.roleId ? { roleId: opts.roleId } : {}),
...(opts.cursor ? { cursor: opts.cursor } : {}),
...(opts.match ? { match: opts.match } : {}),
...(opts.projectRole ? { projectRole: opts.projectRole } : {}),
}
}
/** 人员标签:「姓名 · 账号」,姓名为空时只显示账号。 */
export function personLabel(p: { owner: string; name?: string }): string {
const name = p.name?.trim()
return name && name !== p.owner ? `${name} · ${p.owner}` : p.owner
}
/**
* 候选映射(id=userId;无 userId 的行无法稳定提交,不提供)。无效账号可见但不可选;
* member:已加入(服务端 joined 或已在成员表)不可选;duty:只有当前成员可选。
*/
export function accountsToPickItems(
accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = [], purpose: 'member' | 'duty' = 'member',
): PersonPickItem[] {
const joined = new Set(joinedOwners)
return (accounts ?? []).filter(a => a.userId && a.owner).map(a => {
const isJoined = a.joined === true || joined.has(a.owner)
const reason = !a.valid ? INVALID_REASON
: purpose === 'member' && isJoined ? JOINED_REASON
: purpose === 'duty' && !isJoined ? NOT_MEMBER_REASON : ''
return {
id: a.userId,
owner: a.owner,
title: a.name?.trim() || a.owner,
subtitle: a.owner,
...(reason ? { disabled: true, disabledReason: reason } : {}),
}
})
}
/** 成员表追加下一页:保序,已存在的账号不重复。 */
export function appendMemberPage(prev: RdmMemberRow[] | null, page: RdmMemberRow[] | undefined): RdmMemberRow[] {
const out = [...(prev ?? [])]
const seen = new Set(out.map(m => m.owner))
for (const m of page ?? []) {
if (seen.has(m.owner)) continue
seen.add(m.owner)
out.push(m)
}
return out
}
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
export function describeSource(source: string): string {
switch (source) {
+85
View File
@@ -0,0 +1,85 @@
/* U7 法规结构化引用:编号标记、引用卡片、引用清单与审计法规树。
颜色、圆角、字号只消费 @ui/tokens.css;折叠动效由 @ui Collapsible(mk-col)承担。
有效依据(verified)用实线成功色;未核验用虚线警告色;无效用虚线错误色加删除线。 */
.regcite-chips { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.regcite-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); color: var(--text-dim); word-break: break-all; }
.regcite-mark {
display: inline;
margin: 0 2px;
padding: 0 4px;
border: 1px solid var(--border);
border-radius: var(--radius-xs);
background: none;
font: inherit;
font-size: var(--fs-ui-sm);
line-height: 1.4;
vertical-align: baseline;
cursor: pointer;
}
.regcite-mark:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.regcite-mark.regcite-valid { color: var(--ok-text); border-color: var(--ok-border); background: var(--ok-soft); }
.regcite-mark.regcite-unverified { color: var(--warn-text); border-style: dashed; border-color: var(--warn); background: var(--warn-soft); }
.regcite-mark.regcite-invalid { color: var(--err-text); border-style: dashed; border-color: var(--err); background: var(--err-soft); text-decoration: line-through; }
.regcite-mark-open { outline: 1px solid var(--accent); outline-offset: 1px; }
.regcite-block { margin: 0; }
.regcite-card {
margin: 6px 0;
padding: 8px 12px;
border: 1px solid var(--border-soft);
border-left-width: 3px;
border-radius: var(--radius);
background: var(--bg-raised);
font-size: var(--fs-ui-sm);
color: var(--text);
}
.regcite-card-valid { border-left-color: var(--ok); }
.regcite-card-unverified { border-left-color: var(--warn); border-left-style: dashed; }
.regcite-card-invalid { border-left-color: var(--err); border-left-style: dashed; }
.regcite-card-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.regcite-card-title { font-weight: 600; color: var(--text); }
.regcite-note { margin-top: 4px; color: var(--text-dim); }
.regcite-claim { margin-top: 4px; color: var(--text); }
.regcite-fields { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 2px 10px; margin: 6px 0 0; }
.regcite-fields dt { color: var(--text-dim); }
.regcite-fields dd { margin: 0; color: var(--text); word-break: break-word; }
.regcite-link { color: var(--accent-text); word-break: break-all; }
.regcite-toggle { font-size: var(--fs-ui-sm); color: var(--accent-text); }
.regcite-excerpt {
margin: 4px 0 0;
padding: 6px 10px;
border-left: 2px solid var(--border-strong);
border-radius: var(--radius-xs);
background: var(--bg-inset);
white-space: pre-wrap;
color: var(--text);
}
.regcite-redaction { margin-top: 6px; color: var(--text-dim); }
.regcite-list { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 2px; }
.regcite-list-head { font-size: var(--fs-ui-sm); color: var(--text-dim); padding-bottom: 4px; }
.regcite-item-head { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; }
.regcite-item-num { font-family: var(--mono); color: var(--text-dim); }
.regcite-item-title { font-weight: 550; }
.regcite-item-sub { color: var(--text-dim); }
.regcite-item-invalid .regcite-item-title { color: var(--err-text); text-decoration: line-through; }
.regcite-reasons { margin: 0; padding-left: 18px; color: var(--text-dim); }
.regcite-tree-row { display: flex; align-items: center; gap: 2px; }
.regcite-tree-row .rdm-audit-tree-node { flex: 1; min-width: 0; }
.regcite-tree-caret {
flex: none;
width: 22px;
height: 26px;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-dim);
font-size: var(--fs-ui-sm);
cursor: pointer;
}
.regcite-tree-caret:hover { background: var(--bg-hover); color: var(--text); }
+235
View File
@@ -0,0 +1,235 @@
// U7 法规结构化引用纯逻辑层(契约:Dev 仓 docs/superpowers/specs
// 2026-10-18-regulations-citation-contract.md §1/§3/§5/§6)。只放可测判定:
// 正文 [cit-…] 标记解析与编号、核验状态文案与样式、原文省略原因、审计树请求参数。
// 引用是 RDM 证据批的派生视图;除 verified 外一律不得呈现为有效法律依据。
// ---- 契约类型 ----
interface CitationWeb {
url: string
finalUrl?: string
captureId?: string
contentSha256?: string
official?: boolean
fetchedAt?: string
excerpt?: string
excerptStart?: number
excerptEnd?: number
}
export interface CitationView {
id: string
batchId?: string
claim?: string
state: string // verified|single_source|degraded|edition_unresolved|conflict|unverified|not_applicable|invalid
stale?: boolean
staleReason?: string
regulationId?: string
editionId?: string
nodeId?: string
docNo?: string
title?: string
editionLabel?: string
legalStatus?: string
clauseNo?: string
headingPath?: string[]
corpusRevision?: string
excerpt?: string
excerptStart?: number
excerptEnd?: number
nodeSha256?: string
disclosure?: string // full|excerpt|metadata_only|withheld
redactionReason?: string
web?: CitationWeb
manifestSha256?: string
}
interface RegLegStates { kb: string; web: string; kbReason?: string; webReason?: string }
interface RegSealView { state: string; keyId?: string; manifestSha256?: string }
interface RegResolution { actor: string; decision: string; note?: string; at: string; batchManifestSha256?: string }
/** rdm.inspect.object kind=reg-batch(契约 §5)。 */
export interface RegBatchDetail {
title: string
state: string
stateReasons?: string[] | null
legStates?: RegLegStates
seal?: RegSealView
citations?: CitationView[] | null
resolutions?: RegResolution[] | null
}
/** rdm.inspect.object kind=reg-citation(契约 §5)。 */
export interface RegCitationDetail { title: string; citation: CitationView }
// ---- 正文标记:[cit-<12 位小写十六进制>] ----
const MARKER = /\[(cit-[0-9a-f]{12})\]/g
type CitedSegment =
| { kind: 'text'; text: string }
| { kind: 'cite'; id: string; n: number }
/** 解析多段正文:按首次出现编号(跨段连续),重复标记复用编号;不合规的近似标记原样保留为文本。 */
export function parseCitedBlocks(texts: string[]): { blocks: CitedSegment[][]; order: string[] } {
const order: string[] = []
const numbers = new Map<string, number>()
const blocks = texts.map(text => {
const out: CitedSegment[] = []
let last = 0
for (const m of text.matchAll(MARKER)) {
const at = m.index ?? 0
if (at > last) out.push({ kind: 'text', text: text.slice(last, at) })
const id = m[1]
let n = numbers.get(id)
if (n === undefined) {
order.push(id)
n = order.length
numbers.set(id, n)
}
out.push({ kind: 'cite', id, n })
last = at + m[0].length
}
if (last < text.length) out.push({ kind: 'text', text: text.slice(last) })
return out
})
return { blocks, order }
}
/** 引用清单顺序:正文编号在前,正文未出现的引用按返回顺序续编。 */
export function citationListOrder(order: string[], citations: CitationView[] | null | undefined): string[] {
const out = [...order]
const seen = new Set(order)
for (const c of citations || []) {
if (!seen.has(c.id)) { seen.add(c.id); out.push(c.id) }
}
return out
}
// ---- 核验状态 ----
const STATE_LABELS: Record<string, string> = {
verified: '已核验',
single_source: '单一来源',
degraded: '降级',
edition_unresolved: '版本未定',
conflict: '冲突',
unverified: '未核验',
not_applicable: '不适用',
invalid: '无效引用',
}
/** 状态文案;未知状态原样显示,不冒充已知状态。 */
export function citationStateLabel(state: string | undefined): string {
if (!state) return STATE_LABELS.unverified
return STATE_LABELS[state] ?? state
}
type CitationStyle = 'valid' | 'unverified' | 'invalid'
/** 样式档:仅 verified 且未过期才是有效依据;ID 缺失或 invalid 为无效;其余一律未核验档。 */
export function citationStyle(c: { state?: string; stale?: boolean } | undefined): CitationStyle {
if (!c || c.state === 'invalid') return 'invalid'
if (c.state === 'verified' && !c.stale) return 'valid'
return 'unverified'
}
export const CITATION_CHIP_TONE: Record<CitationStyle, 'ok' | 'warn' | 'danger'> = {
valid: 'ok',
unverified: 'warn',
invalid: 'danger',
}
const STALE_REASONS: Record<string, string> = {
node_text_changed: '条款文本已变更',
legal_status_changed: '效力状态已变更',
edition_superseded: '版本已被替代',
}
/** 过期提示:stale 显示「待复核」并附原因;未过期返回 null。 */
export function staleLabel(c: { stale?: boolean; staleReason?: string }): string | null {
if (!c.stale) return null
if (!c.staleReason) return '待复核'
return `待复核(${STALE_REASONS[c.staleReason] ?? c.staleReason})`
}
/** 标记文字:有效依据只显示编号;其余附状态文案,不靠颜色单独区分。 */
export function citationMarkText(n: number, c: CitationView | undefined): string {
const style = citationStyle(c)
if (style === 'valid') return `${n}`
if (style === 'invalid') return `${n}·${STATE_LABELS.invalid}`
return `${n}·${c?.stale && c.state === 'verified' ? '待复核' : citationStateLabel(c?.state)}`
}
// ---- 原文披露 ----
const REDACTION_LABELS: Record<string, string> = {
withheld_by_rights: '受版权限制',
metadata_only: '仅元数据',
no_source_text_permission: '无原文查看权限',
source_withdrawn: '来源已撤回',
}
/** 原文省略原因;未知原因原样显示。 */
export function redactionLabel(reason: string | undefined): string {
if (!reason) return '未提供原文'
return REDACTION_LABELS[reason] ?? reason
}
/** 原文片段呈现:有片段给片段;省略时给中文原因,不留空白。 */
export function excerptView(c: Pick<CitationView, 'excerpt' | 'redactionReason'>): { excerpt: string } | { redaction: string } {
if (c.excerpt) return { excerpt: c.excerpt }
return { redaction: redactionLabel(c.redactionReason) }
}
const DISCLOSURE_LABELS: Record<string, string> = {
full: '全文',
excerpt: '片段',
metadata_only: '仅元数据',
withheld: '已扣留',
}
export function disclosureLabel(d: string | undefined): string {
if (!d) return '—'
return DISCLOSURE_LABELS[d] ?? d
}
const LEG_LABELS: Record<string, string> = {
ok: '正常',
empty: '无结果',
unavailable: '不可用',
degraded: '降级',
error: '出错',
rejected: '已拒绝',
}
export function legStateLabel(state: string | undefined, reason?: string): string {
const label = state ? (LEG_LABELS[state] ?? state) : '—'
return reason ? `${label}(${reason})` : label
}
export function sealStateLabel(state: string | undefined): string {
if (state === 'sealed') return '已封存'
if (state === 'unsealed') return '未封存'
return state || '—'
}
/** 只有 http(s) 地址才可作为外链;其余地址按文本显示。 */
export function safeWebHref(url: string | undefined): string | null {
if (!url) return null
return /^https?:\/\//i.test(url) ? url : null
}
// ---- 审计读模型请求(rdm.inspect.tree kind=regs;契约 §5) ----
/** 根层列证据批;parent=<batchId> 列该批的引用。cursor 沿用既有键集分页。 */
export function regsTreeParams(projectId: string, opts: { parent?: string; cursor?: string } = {}): Record<string, unknown> {
const params: Record<string, unknown> = { projectId, kind: 'regs', cursor: opts.cursor ?? '' }
if (opts.parent) params.parent = opts.parent
return params
}
export const REG_TREE_KIND_LABELS: Record<string, string> = {
'reg-batch': '证据批',
'reg-citation': '引用',
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
PROJECT_CREATE_LINKED_ACTIONS, buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft,
ensureLinkedCapabilities, linkedChainBreaks, projectCreateSources, rebaseGrantDraft, rebaseRoleDraft, setCapabilityGroup,
type RolePermissionValue,
} from './rolePermissions'
const catalog = [
{ actionId: 'project.create', label: '创建项目', group: '项目入口' },
{ actionId: 'project.settings', label: '项目设置', group: '项目管理' },
{ actionId: 'project.archive', label: '归档项目', group: '项目管理' },
{ actionId: 'project.stage.advance', label: '推进项目阶段', group: '项目管理' },
{ actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' },
{ actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' },
{ actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' },
{ actionId: 'document.read', label: '读取文档', group: '文档动作' },
{ actionId: 'document.author', label: '编制文档', group: '文档动作' },
{ actionId: 'document.review', label: '审核文档', group: '文档动作' },
{ actionId: 'future.action', label: '未来动作', group: '扩展' },
]
const off: RolePermissionValue = { allowed: false, capAllowed: false }
const values = (overrides: Record<string, RolePermissionValue> = {}) =>
({ ...Object.fromEntries(catalog.map(item => [item.actionId, off])), ...overrides })
describe('CapabilityGroup model', () => {
const groups = buildCapabilityGroups(catalog)
const create = groups.find(group => group.id === 'project-create')!
it('groups actions by business capability and keeps unknown actions visible', () => {
expect(groups.map(group => group.id)).toEqual(['project-create', 'project-governance', 'document-duties', 'document-read', 'catalog:扩展'])
expect(create.actions).toEqual(['project.create'])
expect(create.linked).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
const all = groups.flatMap(group => [...group.actions, ...group.linked]).sort()
expect(all).toEqual(catalog.map(item => item.actionId).sort())
})
it('enabling project creation links own-project management caps without granting them by default', () => {
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
expect(draft.values['project.create']).toEqual({ allowed: true, capAllowed: true })
for (const id of PROJECT_CREATE_LINKED_ACTIONS) expect(draft.values[id]).toEqual({ allowed: false, capAllowed: true })
expect(capabilityGroupState(draft.values, create)).toEqual({ allowed: 'on', capAllowed: 'on', linkedReady: true })
})
it('turning project creation off keeps already linked management caps', () => {
const enabled = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
const disabled = setCapabilityGroup(createRolePermissionDraft(enabled.values), create, 'capAllowed', false)
expect(disabled.values['project.create']).toEqual(off)
for (const id of PROJECT_CREATE_LINKED_ACTIONS) expect(disabled.values[id].capAllowed).toBe(true)
})
it('reports mixed group state and the grouped preview with linked additions', () => {
const duties = groups.find(group => group.id === 'document-duties')!
const draft = setCapabilityGroup(createRolePermissionDraft(values({ 'document.author': { allowed: true, capAllowed: true } })), create, 'capAllowed', true)
expect(capabilityGroupState(draft.values, duties).allowed).toBe('mixed')
const changes = capabilityGroupChanges(draft, groups)
expect(changes.map(change => change.group.id)).toEqual(['project-create'])
expect(changes[0].before.capAllowed).toBe('off')
expect(changes[0].after.capAllowed).toBe('on')
expect(changes[0].linkedAdded).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
})
it('detects and repairs a broken link from a server baseline before saving', () => {
const broken = createRolePermissionDraft(values({ 'project.create': { allowed: true, capAllowed: true } }))
expect(linkedChainBreaks(broken.values)).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
expect(linkedChainBreaks(ensureLinkedCapabilities(broken).values)).toEqual([])
})
it('preserves the grouped draft when an optimistic conflict is replayed on newer server values', () => {
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
const server = values({ 'document.review': { allowed: true, capAllowed: true } })
const rebased = rebaseRoleDraft(draft, server)
expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true })
expect(rebased.values['project.members.manage'].capAllowed).toBe(true)
expect(rebased.values['document.review']).toEqual({ allowed: true, capAllowed: true })
})
it('replays explicit grant edits on the latest grants after a version conflict', () => {
expect(rebaseGrantDraft(['system.users.view'], ['project.create'], ['system.users.view', 'system.roles.view']))
.toEqual(['project.create', 'system.roles.view'])
})
it('names every project creation source', () => {
const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }
expect(projectCreateSources(role, ['project.create'])).toEqual(['职位角色「项目经理」', '明确系统授权'])
expect(projectCreateSources(null, [])).toEqual([])
})
it('reports each business-group toggle position alongside the linked chain readiness', () => {
const governance = groups.find(group => group.id === 'project-governance')!
const duties = groups.find(group => group.id === 'document-duties')!
expect(capabilityGroupState(values(), governance)).toEqual({ allowed: 'off', capAllowed: 'off', linkedReady: true })
expect(capabilityGroupState(values({ 'document.author': { allowed: true, capAllowed: true },
'document.review': { allowed: false, capAllowed: true } }), duties)).toEqual({
allowed: 'mixed', capAllowed: 'on', linkedReady: true,
})
})
it('keeps the linked additions accurate when an unrelated group also changes', () => {
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'capAllowed', true)
draft.values['project.archive'] = { allowed: true, capAllowed: true }
const governanceId = 'project-governance'
const changes = capabilityGroupChanges(draft, groups)
const governance = changes.find(change => change.group.id === governanceId)
expect(governance).toBeDefined()
expect(governance!.linkedAdded).toEqual([])
})
it('keeps draft baseline identity during non-conflict rebases', () => {
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
const same = rebaseRoleDraft(draft, draft.values)
expect(same.values).toEqual(draft.values)
expect(same.baseline).toEqual(draft.values)
expect(same.generation).toBeGreaterThan(draft.generation)
})
})
+225
View File
@@ -0,0 +1,225 @@
export type RolePermissionValue = { allowed: boolean; capAllowed: boolean }
type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean }
export interface RolePermissionDraft {
baseline: Record<string, RolePermissionValue>
values: Record<string, RolePermissionValue>
generation: number
}
function copy(values: Record<string, RolePermissionValue>): Record<string, RolePermissionValue> {
return Object.fromEntries(Object.entries(values).map(([actionId, value]) => [actionId, { ...value }]))
}
export function createRolePermissionDraft(values: Record<string, RolePermissionValue>, generation = 0): RolePermissionDraft {
return { baseline: copy(values), values: copy(values), generation }
}
export function updateRolePermission(
draft: RolePermissionDraft,
actionId: string,
value: RolePermissionValue,
): RolePermissionDraft {
return { ...draft, values: { ...draft.values, [actionId]: { ...value } }, generation: draft.generation + 1 }
}
export function mergeRoleDraft(
current: RolePermissionDraft,
serverValues: Record<string, RolePermissionValue>,
responseGeneration: number,
): RolePermissionDraft {
if (responseGeneration < current.generation) return current
return createRolePermissionDraft(serverValues, responseGeneration)
}
// Rebase local edits on a newer server baseline after an optimistic conflict.
// Only fields changed from the old baseline are replayed.
export function rebaseRoleDraft(
current: RolePermissionDraft,
serverValues: Record<string, RolePermissionValue>,
): RolePermissionDraft {
const values = copy(serverValues)
for (const [actionId, local] of Object.entries(current.values)) {
const old = current.baseline[actionId]
if (!old || old.allowed !== local.allowed || old.capAllowed !== local.capAllowed) values[actionId] = { ...local }
}
return { baseline: copy(serverValues), values, generation: current.generation + 1 }
}
export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: string[]): RolePermissionChange[] {
const seen = new Set<string>()
const changes: RolePermissionChange[] = []
for (const actionId of visibleActionIds) {
if (seen.has(actionId)) continue
seen.add(actionId)
const value = draft.values[actionId]
if (!value) continue
const baseline = draft.baseline[actionId]
if (baseline && baseline.allowed === value.allowed && baseline.capAllowed === value.capAllowed) continue
changes.push({ actionId, ...value })
}
return changes
}
// ---- 业务能力组(KTD1):普通界面按业务组配置,低层动作留给服务端鉴权与审计 ----
export const PROJECT_CREATE_ACTION = 'project.create'
/** 与 Backend catalog.ProjectCreateLinkedActions 同序:创建者在本人项目里的管理资格。 */
export const PROJECT_CREATE_LINKED_ACTIONS = [
'project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign',
] as const
interface CapabilityCatalogItem { actionId: string; label: string; group: string; protected?: boolean }
export interface CapabilityGroup {
id: string
label: string
description: string
/** 组开关直接控制的动作。 */
actions: string[]
/** 组内受控项:随主能力联动,不单独勾选。 */
linked: string[]
}
const BUSINESS_GROUPS: (Omit<CapabilityGroup, 'actions' | 'linked'> & { actions: readonly string[]; linked?: readonly string[] })[] = [
{ id: 'project-create', label: '创建与管理本人项目',
description: '创建新项目;本人创建的项目随之具备设置、成员增删、项目内权限与文档职责分配资格,不涉及他人项目',
actions: [PROJECT_CREATE_ACTION], linked: PROJECT_CREATE_LINKED_ACTIONS },
{ id: 'project-governance', label: '项目归档与阶段', description: '归档/恢复与推进本人创建项目的阶段',
actions: ['project.archive', 'project.stage.advance'] },
{ id: 'document-duties', label: '文档编制与审签', description: '履行已分配的文档编制、审核、批准与发布职责',
actions: ['document.author', 'document.review', 'document.approve', 'document.publish'] },
{ id: 'document-read', label: '文档阅读', description: '读取获准的项目文档;项目成员的基础协作另由成员关系提供',
actions: ['document.read'] },
{ id: 'project-content', label: '追溯与知识', description: '编辑追溯网、发布项目知识与读取业务审计',
actions: ['trace.edit', 'knowledge.publish', 'project.audit.read'] },
]
/** 目录里未归入业务组的动作按目录分组兜底,保证新增动作不会从界面消失。 */
export function buildCapabilityGroups(catalog: CapabilityCatalogItem[]): CapabilityGroup[] {
const known = new Set(catalog.map(item => item.actionId))
const claimed = new Set<string>()
const groups: CapabilityGroup[] = []
for (const group of BUSINESS_GROUPS) {
const actions = group.actions.filter(id => known.has(id))
if (actions.length === 0) continue
const linked = (group.linked ?? []).filter(id => known.has(id))
for (const id of [...actions, ...linked]) claimed.add(id)
groups.push({ id: group.id, label: group.label, description: group.description, actions, linked })
}
const rest = new Map<string, CapabilityGroup>()
for (const item of catalog) {
if (claimed.has(item.actionId)) continue
const group = rest.get(item.group) ?? { id: `catalog:${item.group}`, label: item.group, description: '', actions: [], linked: [] }
group.actions.push(item.actionId)
rest.set(item.group, group)
}
return [...groups, ...rest.values()]
}
export type GroupLevel = 'on' | 'off' | 'mixed'
function level(flags: boolean[]): GroupLevel {
if (flags.length > 0 && flags.every(Boolean)) return 'on'
return flags.some(Boolean) ? 'mixed' : 'off'
}
const OFF: RolePermissionValue = { allowed: false, capAllowed: false }
export function capabilityGroupState(values: Record<string, RolePermissionValue>, group: CapabilityGroup) {
const items = group.actions.map(id => values[id] ?? OFF)
return {
allowed: level(items.map(item => item.allowed)),
capAllowed: level(items.map(item => item.capAllowed)),
linkedReady: group.linked.every(id => (values[id] ?? OFF).capAllowed),
}
}
/** 创建能力开启(默认或上限)即补齐联动项上限;只开上限,不把管理动作默认授予所有成员项目。 */
export function ensureLinkedCapabilities(draft: RolePermissionDraft): RolePermissionDraft {
const create = draft.values[PROJECT_CREATE_ACTION]
if (!create?.allowed && !create?.capAllowed) return draft
let next = draft
for (const id of PROJECT_CREATE_LINKED_ACTIONS) {
const current = next.values[id]
if (!current || current.capAllowed) continue
next = updateRolePermission(next, id, { ...current, capAllowed: true })
}
return next
}
/** 组开关:沿用「默认不超过上限」耦合;关闭创建不反向删除已有联动资格。 */
export function setCapabilityGroup(
draft: RolePermissionDraft, group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean,
): RolePermissionDraft {
let next = draft
for (const id of group.actions) {
const current = next.values[id] ?? OFF
const value = field === 'allowed'
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
: { allowed: checked ? current.allowed : false, capAllowed: checked }
next = updateRolePermission(next, id, value)
}
return ensureLinkedCapabilities(next)
}
/** 创建能力已开但联动项上限仍关:断链,需在保存时补齐或由服务端整笔拒绝。 */
export function linkedChainBreaks(values: Record<string, RolePermissionValue>): string[] {
const create = values[PROJECT_CREATE_ACTION]
if (!create?.allowed && !create?.capAllowed) return []
return PROJECT_CREATE_LINKED_ACTIONS.filter(id => id in values && !values[id].capAllowed)
}
export interface CapabilityGroupChange {
group: CapabilityGroup
before: ReturnType<typeof capabilityGroupState>
after: ReturnType<typeof capabilityGroupState>
/** 本次联动补齐上限的受控项。 */
linkedAdded: string[]
}
export function capabilityGroupChanges(draft: RolePermissionDraft, groups: CapabilityGroup[]): CapabilityGroupChange[] {
const changed = (id: string) => {
const before = draft.baseline[id] ?? OFF
const after = draft.values[id] ?? OFF
return before.allowed !== after.allowed || before.capAllowed !== after.capAllowed
}
return groups.flatMap(group => {
if (![...group.actions, ...group.linked].some(changed)) return []
return [{
group,
before: capabilityGroupState(draft.baseline, group),
after: capabilityGroupState(draft.values, group),
linkedAdded: group.linked.filter(id => !(draft.baseline[id] ?? OFF).capAllowed && (draft.values[id] ?? OFF).capAllowed),
}]
})
}
/** 系统授权草稿冲突重放:只把本地相对旧基线的增删应用到最新服务端授权上。 */
export function rebaseGrantDraft(baseline: string[], local: string[], server: string[]): string[] {
const before = new Set(baseline)
const now = new Set(local)
const next = new Set(server)
for (const id of now) if (!before.has(id)) next.add(id)
for (const id of before) if (!now.has(id)) next.delete(id)
return [...next].sort()
}
/** 某账号 project.create 的实际来源(角色默认与明确系统授权可并存)。
* getUserRole 的角色摘要不带 permissions,缺失按「无角色默认权限」处理。 */
export function projectCreateSources(
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined, grants: string[],
): string[] {
const sources: string[] = []
if (role?.permissions?.some(item => item.actionId === PROJECT_CREATE_ACTION && item.allowed)) sources.push(`职位角色「${role.name}」`)
if (grants.includes(PROJECT_CREATE_ACTION)) sources.push('明确系统授权')
return sources
}
export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null {
for (const actionId of Object.keys(draft.values).sort()) {
const value = draft.values[actionId]
if (value.allowed && !value.capAllowed) return { actionId, reason: 'default-exceeds-cap' }
}
return null
}
+406
View File
@@ -0,0 +1,406 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { auth } from './auth'
import {
PROJECT_SYNC_INTERVAL_MS, PROJECT_SYNC_RENDER_BUDGET_MS, PROJECT_SYNC_REQUEST_BUDGET_MS,
createProjectAutoDiscover, projectSyncDelay, store, type ProjectSyncEnv, type ProjectSyncStatus,
} from './store'
import type { Conversation, Project } from './contracts'
const project = (id: string, owner = 'alice'): Project => ({ id, owner, name: id, createdAt: '', updatedAt: '' })
const task = (id: string, projectId: string): Conversation => ({ id, projectId, title: id, createdAt: '', updatedAt: '' })
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((ok, fail) => { resolve = ok; reject = fail })
return { promise, resolve, reject }
}
/** 可控的页面环境:可见性、联网与 wake/sleep 事件由测试驱动。 */
function fakeEnv() {
const env = {
visible: true,
online: true,
handlers: null as null | { wake: () => void; sleep: (status: 'paused' | 'offline') => void },
}
const port: ProjectSyncEnv = {
visible: () => env.visible,
online: () => env.online,
subscribe: handlers => { env.handlers = handlers; return () => { env.handlers = null } },
}
return {
port,
hide() { env.visible = false; env.handlers?.sleep('paused') },
show() { env.visible = true; env.handlers?.wake() },
offline() { env.online = false; env.handlers?.sleep('offline') },
online() { env.online = true; env.handlers?.wake() },
focus() { env.handlers?.wake() },
}
}
let projects: Project[]
let tasks: Conversation[]
let requests: { path: string; init?: RequestInit }[]
let route: (path: string, init?: RequestInit) => Promise<Response>
beforeEach(async () => {
projects = [project('host-a')]
tasks = [task('conv-a', 'host-a')]
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 === '/api/conversations' && !init?.method) return response(tasks.filter(t => projects.some(p => p.id === t.projectId)))
if (path === '/api/conversations/conv-a') return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 })
if (path === '/api/me') return response({ owner: 'bob', name: 'Bob', auth: true, systemActions: [] })
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([])
if (path.endsWith('/cards')) return response({ cards: [] })
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.refreshMe()
await store.refreshProjects()
await store.openTask('conv-a')
requests = []
})
afterEach(() => {
store.stopProjectSync()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
async function advanceUntil(check: () => boolean, limitMs: number, stepMs = 250): Promise<number> {
let elapsed = 0
while (!check() && elapsed < limitMs) {
await vi.advanceTimersByTimeAsync(stepMs)
elapsed += stepMs
}
return elapsed
}
describe('AutoDiscover project list', () => {
it('keeps the visible-page budget inside 5 seconds', () => {
expect(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS + PROJECT_SYNC_RENDER_BUDGET_MS).toBeLessThanOrEqual(5000)
expect(projectSyncDelay(0)).toBe(PROJECT_SYNC_INTERVAL_MS)
})
it('surfaces a project the member was added to within 5 seconds without switching the current work', async () => {
vi.useFakeTimers()
const env = fakeEnv()
store.startProjectSync(env.port)
projects = [project('host-a'), project('host-b', 'carol')]
const elapsed = await advanceUntil(() => store.getState().projects.some(p => p.id === 'host-b'), 5000)
expect(store.getState().projects.map(p => p.id)).toEqual(['host-a', 'host-b'])
expect(elapsed).toBeLessThanOrEqual(5000)
expect(store.getState()).toMatchObject({ currentProjectId: 'host-a', currentConvId: 'conv-a', projectsLoaded: true })
expect(requests.map(r => r.path)).not.toContain('/api/conversations/conv-a')
expect(store.getState().projectSync.status).toBe('ok')
})
it('clears a removed current project within 5 seconds and keeps the remaining projects', async () => {
projects = [project('host-a'), project('host-b', 'carol')]
await store.refreshProjects()
vi.useFakeTimers()
store.startProjectSync(fakeEnv().port)
projects = [project('host-b', 'carol')]
const elapsed = await advanceUntil(() => store.getState().currentProjectId !== 'host-a', 5000)
expect(elapsed).toBeLessThanOrEqual(5000)
expect(store.getState()).toMatchObject({ currentProjectId: 'host-b', currentConvId: null })
expect(store.getState().projects.map(p => p.id)).toEqual(['host-b'])
expect(store.getState().tasks.map(t => t.id)).toEqual([])
})
it('does not re-render or re-open anything when the list is unchanged', async () => {
vi.useFakeTimers()
store.startProjectSync(fakeEnv().port)
const before = store.getState()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 3)
expect(requests.filter(r => r.path === '/api/projects')).toHaveLength(3)
expect(store.getState().projects).toBe(before.projects)
expect(requests.map(r => r.path)).not.toContain('/api/conversations')
})
})
describe('RefreshSingleFlight', () => {
it('never overlaps requests and folds wake-ups during a request into one immediate follow-up', async () => {
vi.useFakeTimers()
const env = fakeEnv()
let concurrent = 0
let peak = 0
const calls: ReturnType<typeof deferred<void>>[] = []
const fetchOnce = vi.fn(() => {
concurrent++; peak = Math.max(peak, concurrent)
const call = deferred<void>()
calls.push(call)
return call.promise.finally(() => { concurrent-- })
})
const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {})
sync.start()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
expect(fetchOnce).toHaveBeenCalledTimes(1)
env.focus(); env.online(); env.show()
await vi.advanceTimersByTimeAsync(500)
expect(fetchOnce).toHaveBeenCalledTimes(1)
calls[0].resolve()
await vi.advanceTimersByTimeAsync(0)
expect(fetchOnce).toHaveBeenCalledTimes(2)
calls[1].resolve()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS - 1)
expect(fetchOnce).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(fetchOnce).toHaveBeenCalledTimes(3)
expect(peak).toBe(1)
sync.stop()
})
it('schedules the next round only after the current one finishes', async () => {
vi.useFakeTimers()
const slow = deferred<void>()
const fetchOnce = vi.fn().mockReturnValueOnce(slow.promise).mockResolvedValue(undefined)
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, () => {})
sync.start()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + 1500)
expect(fetchOnce).toHaveBeenCalledTimes(1)
slow.resolve()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
expect(fetchOnce).toHaveBeenCalledTimes(2)
sync.stop()
})
})
describe('RefreshBackoff', () => {
it('reports a sync failure, backs off exponentially and returns to the 2-second rhythm on success', async () => {
vi.useFakeTimers()
const statuses: [ProjectSyncStatus, number][] = []
let fail = true
const fetchOnce = vi.fn(async () => { if (fail) throw new Error('503: unavailable') })
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, (status, failures) => statuses.push([status, failures]))
sync.start()
await vi.advanceTimersByTimeAsync(2000)
expect(statuses.at(-1)).toEqual(['degraded', 1])
await vi.advanceTimersByTimeAsync(3999)
expect(fetchOnce).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(fetchOnce).toHaveBeenCalledTimes(2)
expect(statuses.at(-1)).toEqual(['degraded', 2])
fail = false
await vi.advanceTimersByTimeAsync(8000)
expect(fetchOnce).toHaveBeenCalledTimes(3)
expect(statuses.at(-1)).toEqual(['ok', 0])
await vi.advanceTimersByTimeAsync(2000)
expect(fetchOnce).toHaveBeenCalledTimes(4)
expect(projectSyncDelay(10)).toBe(30000)
sync.stop()
})
it('aborts a request that exceeds its 2-second budget and counts it as a failure', async () => {
vi.useFakeTimers()
let signal: AbortSignal | undefined
const fetchOnce = vi.fn((s: AbortSignal) => { signal = s; return new Promise<void>(() => {}) })
const statuses: ProjectSyncStatus[] = []
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, status => statuses.push(status))
sync.start()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS)
expect(signal?.aborted).toBe(true)
expect(statuses.at(-1)).toBe('degraded')
sync.stop()
})
it('shows the store sync state as degraded while the list endpoint fails', async () => {
vi.useFakeTimers()
const previous = route
route = (path, init) => path === '/api/projects' ? Promise.resolve(response({ error: 'down' }, 503)) : previous(path, init)
store.startProjectSync(fakeEnv().port)
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
expect(store.getState().projectSync).toEqual({ status: 'degraded', failures: 1 })
expect(store.getState().projects.map(p => p.id)).toEqual(['host-a'])
})
})
describe('RefreshHiddenPause', () => {
it('pauses on a hidden page and resumes immediately when it becomes visible again', async () => {
vi.useFakeTimers()
const env = fakeEnv()
const statuses: ProjectSyncStatus[] = []
const fetchOnce = vi.fn(async () => {})
const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status))
sync.start()
env.hide()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 10)
expect(fetchOnce).not.toHaveBeenCalled()
expect(statuses.at(-1)).toBe('paused')
env.show()
await vi.advanceTimersByTimeAsync(0)
expect(fetchOnce).toHaveBeenCalledTimes(1)
expect(statuses.at(-1)).toBe('ok')
sync.stop()
})
it('does not start when the page is already hidden', async () => {
vi.useFakeTimers()
const env = fakeEnv()
env.hide()
const fetchOnce = vi.fn(async () => {})
const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {})
sync.start()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5)
expect(fetchOnce).not.toHaveBeenCalled()
sync.stop()
})
})
describe('RefreshReconnectCompensate', () => {
it('stops while offline and re-checks immediately when the network returns', async () => {
vi.useFakeTimers()
const env = fakeEnv()
const statuses: ProjectSyncStatus[] = []
const fetchOnce = vi.fn(async () => {})
const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status))
sync.start()
env.offline()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5)
expect(fetchOnce).not.toHaveBeenCalled()
expect(statuses.at(-1)).toBe('offline')
env.online()
await vi.advanceTimersByTimeAsync(0)
expect(fetchOnce).toHaveBeenCalledTimes(1)
sync.stop()
})
it('re-checks immediately on focus instead of waiting for the next interval', async () => {
vi.useFakeTimers()
const env = fakeEnv()
store.startProjectSync(env.port)
projects = [project('host-a'), project('host-b', 'carol')]
env.focus()
await vi.advanceTimersByTimeAsync(0)
expect(store.getState().projects.map(p => p.id)).toContain('host-b')
})
it('drops a late list from the previous identity after a reconnect under another account', async () => {
vi.useFakeTimers()
const env = fakeEnv()
const late = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects' ? late.promise : previous(path, init)
store.startProjectSync(env.port)
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
auth.beginSession()
route = previous
projects = [project('host-c', 'dave')]
await store.refreshProjects()
late.resolve(response([project('host-a'), project('alice-secret')]))
await vi.advanceTimersByTimeAsync(0)
expect(store.getState().projects.map(p => p.id)).toEqual(['host-c'])
})
})
function routeScratchSession() {
const previous = route
route = (path, init) => {
if (path === '/api/scratch-sessions' && init?.method === 'POST') {
return Promise.resolve(response({ project: project('scratch-1'), conversation: { ...task('scratch-conv', 'scratch-1'), scratch: true } }))
}
if (path === '/api/conversations/scratch-conv') return Promise.resolve(response({ conversation: { ...task('scratch-conv', 'scratch-1'), scratch: true }, messages: [], runs: [], activeHitl: [], lastSeq: 0 }))
return previous(path, init)
}
}
describe('DefaultProjectDisabled', () => {
it('does not create a default project when the member has no projects yet', async () => {
projects = []
await store.refreshProjects()
routeScratchSession()
requests = []
await store.createTask()
const posts = requests.filter(r => r.init?.method === 'POST').map(r => r.path)
expect(posts).toEqual(['/api/scratch-sessions'])
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-conv', currentProjectId: 'scratch-1' })
})
it('does not treat a list that has not arrived as empty', async () => {
auth.beginSession()
const pending = deferred<Response>()
route = path => path === '/api/projects' ? pending.promise : Promise.resolve(response({}))
void store.refreshProjects()
await expect(store.createTask()).rejects.toThrow(/尚未加载/)
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
pending.resolve(response([]))
})
it('points a creator to explicit project creation instead of creating one silently', async () => {
const previous = route
route = (path, init) => path === '/api/me'
? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true, systemActions: ['project.create'],
linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } }))
: previous(path, init)
await store.refreshMe()
projects = []
await store.refreshProjects()
routeScratchSession()
requests = []
await store.createTask()
expect(requests.filter(r => r.path === '/api/projects' && r.init?.method === 'POST')).toEqual([])
expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/scratch-sessions'])
expect(store.getState().projects).toEqual([])
})
it('surfaces the stage-specific project error before any POST is sent', async () => {
projects = [{ ...project('host-archived'), settings: { 'rdm.state': 'archived' } } as Project]
await store.refreshProjects()
requests = []
await expect(store.createTask()).rejects.toThrow(/请先新建项目|归档/)
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
})
})
describe('AutoDiscover sync ownership on lifecycle events', () => {
it('surfaces sync start and stop without leaking listeners across re-starts', () => {
const env = fakeEnv()
const release: Array<() => void> = []
const port: ProjectSyncEnv = { ...env.port, subscribe: handlers => {
const releaseFn = () => {}
release.push(releaseFn)
env.port.subscribe(handlers)
return () => { releaseFn() }
} }
store.startProjectSync(port)
store.stopProjectSync()
store.startProjectSync(port)
expect(release.length).toBe(2)
store.stopProjectSync()
})
it('keeps the store sync state idle after a clean stop and ready to re-start', () => {
store.startProjectSync(fakeEnv().port)
store.stopProjectSync()
expect(store.getState().projectSync.status).toBe('idle')
expect(store.getState().projectSync.failures).toBe(0)
})
it('drops the late response when an identity reset interleaves a sync', async () => {
vi.useFakeTimers()
const env = fakeEnv()
const pending = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects' ? pending.promise : previous(path, init)
store.startProjectSync(env.port)
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
store.resetIdentity()
pending.resolve(response([project('host-late')]))
await vi.advanceTimersByTimeAsync(0)
expect(store.getState().projects.find(p => p.id === 'host-late')).toBeUndefined()
store.stopProjectSync()
})
})
+370 -57
View File
@@ -1,9 +1,9 @@
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { type AdminZoneSection, api, connectEvents } from './api'
import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api'
import { auth } from './auth'
import { projectActionError, projectWorkError } from './projectmeta'
import { noProjectError, 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, type WorkReference } from './outbox'
@@ -11,8 +11,9 @@ import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
import { rdmAuditReady, rdmExportReady } from './workspaces'
import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement'
import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation'
type FontSizeChoice = 13 | 14 | 16 | 18
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
@@ -22,6 +23,7 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
}
import type {
ActivityCard,
TaskSourceRef,
AgentProfile,
Artifact,
Conversation,
@@ -83,18 +85,37 @@ export interface UIState {
taskOrg: 'project' | 'timeline'
showArchived: boolean
lastProjectId: string | null
/** 用户上次停留在无项目对话视图;刷新后回到同一视图。 */
lastScratch: boolean
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
adminZoneOpen: boolean
changePwOpen: boolean
projectSettingsId: string | null
}
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }
/** 能否新建真实项目(无项目对话不需要此能力);服务端仍按当前身份最终判定。 */
export function canCreateProject(me: Pick<Me, 'auth' | 'systemActions'> | null | undefined): boolean {
return !!me && (!me.auth || !!me.systemActions?.includes('project.create'))
}
/** KTD9 项目自动发现:可见页每 2 秒串行重查;单次请求预算 2 秒、渲染预留 1 秒,正常链路 5 秒内可见。 */
export const PROJECT_SYNC_INTERVAL_MS = 2000
export const PROJECT_SYNC_REQUEST_BUDGET_MS = 2000
export const PROJECT_SYNC_RENDER_BUDGET_MS = 1000
const PROJECT_SYNC_MAX_BACKOFF_MS = 30000
export type ProjectSyncStatus = 'idle' | 'ok' | 'degraded' | 'paused' | 'offline'
interface AppState {
contextObs: Record<string, ContextObs>
projects: Project[]
/** 本身份的项目列表是否已到达过;未到达不等于空列表。 */
projectsLoaded: boolean
projectSync: { status: ProjectSyncStatus; failures: number }
currentProjectId: string | null
/** 无项目对话视图:currentProjectId 此时是无项目对话的内部项目,不在 projects 里。 */
scratchMode: boolean
tasks: Conversation[]
me: Me | null
currentConvId: string | null
@@ -124,6 +145,7 @@ interface AppState {
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
rdmAuditAvailable: boolean
rdmCockpitAvailable: boolean
/** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */
rdmExportAvailable: boolean
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
@@ -131,6 +153,8 @@ interface AppState {
rdmMembersAvailable: boolean
rdmDocReviewAvailable: boolean
rdmPermMatrixAvailable: boolean
/** 统一渲染(Task 11):rdm.render.* 核心命令齐备才 ready;审阅/批注/导出各自门控。 */
rdmRender: RenderCapabilities
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
adminSections: AdminZoneSection[]
ui: UIState
@@ -166,6 +190,7 @@ filesView: 'list', workspaceSurfacePin: null,
taskOrg: 'project',
showArchived: false,
lastProjectId: null,
lastScratch: false,
adminZoneOpen: false,
changePwOpen: false,
projectSettingsId: null,
@@ -194,13 +219,16 @@ const surfaceActionsInflight: Record<string, boolean> = {}
let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
projects: [], projectsLoaded: false, projectSync: { status: 'idle', failures: 0 },
currentProjectId: null, scratchMode: false, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
rdmAuditAvailable: false,
rdmCockpitAvailable: false,
rdmExportAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
rdmRender: NO_RENDER_CAPABILITIES,
adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
outbox: [], outboxDraining: false,
@@ -226,7 +254,8 @@ function syncFilesEntry(hasTabs: boolean) {
function setUI(patch: Partial<UIState>) {
const ui = { ...state.ui, ...patch }
state = { ...state, ui }
const navigation=(['rightTab','filesView','rightCollapsed','activeWorkspace'] as const).some(key=>key in patch&&patch[key]!==state.ui[key])
state = { ...state, ui,previewOpenIntent:state.previewOpenIntent+(navigation?1:0) }
try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
applyTheme(ui.theme)
applyFontSize(ui.fontSize)
@@ -292,6 +321,9 @@ let projectsRequest = 0
let tasksRequest = 0
let meRequest = 0
let streamSeq = 0
let streamHasWatermark = false
let gapReconnectPending = false
let streamPending = new Map<number, StreamEvent>()
let detailRequest = 0
let detailSeq = 0
let detailVersion = 0
@@ -311,6 +343,44 @@ const actionInflight = new Set<string>()
const actionFeedbackOwners: Record<string, symbol> = {}
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
/** 离开当前项目:关闭项目/会话流并清空项目内投影(不触碰服务端历史)。 */
function resetProjectScope() {
projectSse?.close(); projectSse = null
sse?.close(); sse = null
projectSeq = 0
projectCardsRequest++
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
artifacts: [], outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false, filesView: 'list' })
}
/** remember=false 用于没有真实项目时的自动进入:不改写用户上次选择的视图。 */
function setScratchMode(on: boolean, remember = true) {
if (state.scratchMode !== on) set({ scratchMode: on })
if (remember && state.ui.lastScratch !== on) setUI({ lastScratch: on })
}
/** 绑定会话所属项目(真实项目或无项目对话的内部项目),供上传与工件归属。 */
async function bindProject(projectId: string, remember: boolean) {
if (state.currentProjectId !== projectId) resetProjectScope()
set({ currentProjectId: projectId })
if (state.inboxScope === 'project') {
store.loadProjectCards()
store.connectProject()
}
if (remember) setUI({ lastProjectId: projectId })
await store.refreshArtifacts(projectId)
}
function latestScratchTask(): Conversation | undefined {
return state.tasks.filter(t => t.scratch && !t.archived)
.reduce<Conversation | undefined>((latest, t) => !latest || t.updatedAt > latest.updatedAt ? t : latest, undefined)
}
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
const version = ++detailVersion
observations[id] = { ...observations[id], version, seq,
@@ -347,12 +417,24 @@ function scheduleTerminalReconcile() {
// Snapshot queries may overlap events. Only rows changed after the request began
// override that snapshot; a newer request/context invalidates the old query entirely.
export function mergeTaskSnapshot<T extends {taskId:string;attemptId:string;revision:number}>(current:T,incoming:T):T {
if(current.taskId!==incoming.taskId||current.attemptId!==incoming.attemptId||!Number.isSafeInteger(incoming.revision)||incoming.revision<=current.revision)return current
return incoming
}
function mergeTaskCard(current:ActivityCard|undefined,incoming:ActivityCard):ActivityCard {
if(!current?.task||!incoming.task||current.task.schemaVersion!==1||incoming.task.schemaVersion!==1)return incoming
const a=current.task,b=incoming.task
if(a.taskId!==b.taskId||a.attemptId!==b.attemptId||a.projectId!==b.projectId||a.conversationId!==b.conversationId||a.ownerInstanceId!==b.ownerInstanceId||(a.executionEpoch??0)>(b.executionEpoch??0)||a.revision>b.revision)return current
if(['completed','failed','interrupted'].includes(a.executionState)&&!['completed','failed','interrupted'].includes(b.executionState))return current
if(a.revision!==b.revision&&mergeTaskSnapshot(a,b)===a)return current
return incoming
}
function mergeCardSnapshot(snapshot: ActivityCard[], current: ActivityCard[], versions: Record<string, number>, started: number,
patches: Record<string, Pick<ActivityCard, 'status' | 'tone'>>) {
const fresh = current.filter(c => (versions[c.id] ?? 0) > started)
const freshIds = new Set(fresh.map(c => c.id))
return [...fresh, ...snapshot.filter(c => !freshIds.has(c.id)).map(c =>
(versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c)]
const combined=fresh.map(c=>{const row=snapshot.find(r=>r.id===c.id);return row?.task&&c.task&&row.task.revision>c.task.revision?mergeTaskCard(c,row):c})
return [...combined, ...snapshot.filter(c => !freshIds.has(c.id)).map(row => {const c=mergeTaskCard(current.find(old=>old.id===row.id),row);return (versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c})]
}
function hitlSource(card: ActivityCard): string | undefined {
@@ -415,9 +497,144 @@ export function useStore<T>(selector: (s: AppState) => T): T {
return useSyncExternalStore(
(cb) => { listeners.add(cb); return () => listeners.delete(cb) },
() => selector(state),
() => selector(state),
)
}
export interface ProjectSyncEnv {
visible: () => boolean
online: () => boolean
/** wake:重新聚焦/页面恢复可见/网络恢复;sleep:页面隐藏或断网。 */
subscribe: (handlers: { wake: () => void; sleep: (status: 'paused' | 'offline') => void }) => () => void
}
export function projectSyncDelay(failures: number): number {
return failures <= 0 ? PROJECT_SYNC_INTERVAL_MS : Math.min(PROJECT_SYNC_MAX_BACKOFF_MS, PROJECT_SYNC_INTERVAL_MS * 2 ** failures)
}
/**
* 单一所有者的串行重查:同一时刻至多一个请求;进行中被唤醒只记一次补偿,完成后立即再查。
* 下一轮在本轮结束后才排期(不重叠);失败/超时按指数退避,成功或唤醒补偿后恢复 2 秒节奏。
*/
export function createProjectAutoDiscover(
fetchOnce: (signal: AbortSignal) => Promise<void>,
env: ProjectSyncEnv,
report: (status: ProjectSyncStatus, failures: number) => void,
) {
let active = false
let inflight = false
let pending = false
let failures = 0
let timer: ReturnType<typeof setTimeout> | null = null
let controller: AbortController | null = null
let unsubscribe: (() => void) | null = null
const clear = () => { if (timer !== null) { clearTimeout(timer); timer = null } }
const blocked = (): 'paused' | 'offline' | null => !env.online() ? 'offline' : !env.visible() ? 'paused' : null
const schedule = () => {
clear()
const reason = blocked()
if (reason) { report(reason, failures); return }
timer = setTimeout(() => { timer = null; void run() }, projectSyncDelay(failures))
}
const run = async (): Promise<void> => {
if (!active) return
const reason = blocked()
if (reason) { clear(); report(reason, failures); return }
if (inflight) { pending = true; return }
clear()
inflight = true
const own = new AbortController()
controller = own
const budget = setTimeout(() => own.abort(), PROJECT_SYNC_REQUEST_BUDGET_MS)
const aborted = new Promise<never>((_, reject) =>
own.signal.addEventListener('abort', () => reject(new Error('project sync timeout')), { once: true }))
try {
await Promise.race([fetchOnce(own.signal), aborted])
if (active && controller === own) { failures = 0; report('ok', 0) }
} catch {
if (active && controller === own) { failures++; report('degraded', failures) }
} finally {
clearTimeout(budget)
inflight = false
if (controller === own) controller = null
}
if (!active) return
if (pending) { pending = false; return run() }
schedule()
}
return {
start() {
if (active) return
active = true
unsubscribe = env.subscribe({
wake: () => { void run() },
sleep: status => { clear(); report(status, failures) },
})
schedule()
},
stop() {
active = false; pending = false; failures = 0
clear()
controller?.abort(); controller = null
unsubscribe?.(); unsubscribe = null
report('idle', 0)
},
trigger: () => run(),
}
}
function browserProjectSyncEnv(): ProjectSyncEnv {
return {
visible: () => typeof document === 'undefined' || document.visibilityState !== 'hidden',
online: () => typeof navigator === 'undefined' || navigator.onLine !== false,
subscribe: ({ wake, sleep }) => {
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}
const onVisibility = () => { if (document.visibilityState === 'hidden') sleep('paused'); else wake() }
const onOffline = () => sleep('offline')
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('focus', wake)
window.addEventListener('online', wake)
window.addEventListener('offline', onOffline)
return () => {
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('focus', wake)
window.removeEventListener('online', wake)
window.removeEventListener('offline', onOffline)
}
},
}
}
let projectSync: ReturnType<typeof createProjectAutoDiscover> | null = null
const projectIdsKey = (projects: Project[]) => projects.map(p => p.id).sort().join('\n')
/** 列表合并只更新投影:不重复 openProject、不切对话、不重建流;仅当前项目失效时清除其状态。 */
async function mergeProjectList(projects: Project[]) {
if (state.projectsLoaded && JSON.stringify(state.projects) === JSON.stringify(projects)) return
const membershipChanged = !state.projectsLoaded || projectIdsKey(state.projects) !== projectIdsKey(projects)
set({ projects, projectsLoaded: true, error: null })
const current = state.currentProjectId
// 无项目对话视图绑定的是不在列表里的内部项目,列表变化不切换视图。
if (state.scratchMode) { /* keep the scratch view */ }
else if (current && !projects.some(p => p.id === current)) {
const fallback = projects[0]
if (fallback) await store.openProject(fallback.id)
else {
resetProjectScope()
set({ currentProjectId: null })
setUI({ lastProjectId: null })
setScratchMode(true, false)
}
store.toast('你已不在原项目中,已清除该项目的入口')
} else if (!current) {
const next = projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
if (next) await store.openProject(next.id)
else setScratchMode(true, false)
}
if (membershipChanged) await store.refreshTasks()
}
export const store = {
getState: () => state,
getIdentityEpoch: () => identityEpoch,
@@ -437,16 +654,16 @@ export const store = {
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
convRestoreDone = true
persistConvId(null)
set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
set({ projects: [], projectsLoaded: false, currentProjectId: null, scratchMode: false, tasks: [], me: null, currentConvId: null,
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES,
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
setUI({ lastProjectId: null, lastScratch: false, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
settingsOpen: false, changePwOpen: false, trackerRunId: null, workspaceSurfacePin: null,
previewExpanded: false, filesView: 'list', showArchived: false })
},
@@ -454,16 +671,45 @@ export const store = {
async refreshProjects() {
const epoch = identityEpoch
const request = ++projectsRequest
let scratch = false
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)
// 没有真实项目(或上次停留在无项目对话)时进入无项目对话视图,而不是停在空项目上。
scratch = projects.length === 0 || state.ui.lastScratch
if (scratch) set({ projects, projectsLoaded: true, error: null })
else {
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
set({ projects, projectsLoaded: true, currentProjectId: current?.id ?? null, error: null })
if (current) await store.openProject(current.id)
}
} catch (e) {
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
}
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
if (scratch && epoch === identityEpoch && request === projectsRequest) await store.enterScratchMode(state.ui.lastScratch)
},
/** 自动发现的一次重查:与 refreshProjects 共用请求代次,迟到/旧身份响应丢弃;失败抛出供退避。 */
async syncProjects(signal?: AbortSignal) {
const epoch = identityEpoch
const request = ++projectsRequest
const projects = await api.listProjects(signal)
if (epoch !== identityEpoch || request !== projectsRequest) return
await mergeProjectList(projects)
},
startProjectSync(env: ProjectSyncEnv = browserProjectSyncEnv()) {
projectSync?.stop()
projectSync = createProjectAutoDiscover(signal => store.syncProjects(signal), env, (status, failures) => {
if (state.projectSync.status !== status || state.projectSync.failures !== failures) set({ projectSync: { status, failures } })
})
projectSync.start()
},
stopProjectSync() {
projectSync?.stop()
projectSync = null
},
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
@@ -472,14 +718,16 @@ export const store = {
const request = ++tasksRequest
try {
const tasks = await api.listTasks(includeArchived)
if (epoch !== identityEpoch || request !== tasksRequest) return
if (epoch !== identityEpoch || request !== tasksRequest || !Array.isArray(tasks)) return
set({ tasks })
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
if (!convRestoreDone) {
convRestoreDone = true
const saved = savedConvId()
if (saved && !state.currentConvId && tasks.some(t => t.id === saved && !t.archived)) {
store.openConversation(saved)
const restored = saved ? tasks.find(t => t.id === saved && !t.archived) : undefined
if (restored && !state.currentConvId) {
if (restored.scratch) await store.openTask(restored.id)
else store.openConversation(restored.id)
}
}
} catch { /* 列表竞态,静默 */ }
@@ -517,40 +765,44 @@ export const store = {
}
},
/** 打开真实项目:同时离开无项目对话视图。 */
async openProject(projectId: string) {
if (state.currentProjectId !== projectId) {
projectSse?.close(); projectSse = null
sse?.close(); sse = null
projectSeq = 0
projectCardsRequest++
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
artifacts: [], outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false, filesView: 'list' })
}
set({ currentProjectId: projectId })
if (store.getState().inboxScope === 'project') {
store.loadProjectCards()
store.connectProject()
}
store.setUI({ lastProjectId: projectId })
await store.refreshArtifacts(projectId)
setScratchMode(false)
await bindProject(projectId, true)
},
/** 进入无项目对话视图:当前会话不是无项目对话时清空会话视图,并打开最近一条未归档的无项目对话。 */
async enterScratchMode(remember = true) {
const epoch = identityEpoch
const current = state.tasks.find(t => t.id === state.currentConvId)
setScratchMode(true, remember)
if (current?.scratch) return
if (state.currentProjectId || state.currentConvId) resetProjectScope()
set({ currentProjectId: null })
const latest = latestScratchTask()
if (latest && epoch === identityEpoch) await store.openTask(latest.id)
},
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
* 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */
* 无项目对话视图、或项目列表已到达且没有真实项目时,新建无项目对话;从不代建真实项目。 */
async createTask(projectId?: string) {
const epoch = identityEpoch
const changed = () => { if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') }
try {
if (state.scratchMode || (!projectId && !state.currentProjectId && state.projectsLoaded && state.projects.length === 0)) {
const { conversation } = await api.createScratchSession()
changed()
setScratchMode(true)
await store.refreshTasks()
changed()
if (!state.tasks.some(t => t.id === conversation.id)) set({ tasks: [{ ...conversation, scratch: true }, ...state.tasks] })
await store.openTask(conversation.id)
changed()
return
}
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 (!pid) throw new Error(noProjectError(state.projectsLoaded, canCreateProject(state.me)))
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
const error = projectWorkError(state.projects.find(p => p.id === pid))
if (error) throw new Error(error)
@@ -574,12 +826,15 @@ export const store = {
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 (t?.scratch) {
if (t.projectId !== state.currentProjectId) await bindProject(t.projectId, false)
} else if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
if (epoch !== identityEpoch) return
if (t) setScratchMode(!!t.scratch)
await store.openConversation(convId)
},
@@ -615,7 +870,7 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
const epoch = ++conversationEpoch
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], workRecord: null, workReference: null, surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false })
@@ -630,7 +885,7 @@ export const store = {
}
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
// surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
if (state.currentProjectId) api.projectSurfaces(state.currentProjectId).then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
api.surfaceList(convId).then(async list => {
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
const metas = list ?? []
@@ -713,15 +968,21 @@ export const store = {
streamSeq = Math.max(streamSeq, since ?? state.lastSeq)
sse?.close()
sse = connectEvents(convId, since ?? state.lastSeq, {
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: true }) },
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) {set({ connected: true });void store.refreshCards(convId)} },
onError: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: false }) },
onEvent: e => { if (state.currentConvId === convId && conversationEpoch === epoch) store.applyEvent(e) },
})
},
applyEvent(e: StreamEvent) {
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isFinite(e.seq) || e.seq <= streamSeq) return
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isSafeInteger(e.seq) || e.seq <= streamSeq) return
if(streamHasWatermark&&e.seq>streamSeq+1){
if(streamPending.size<1000)streamPending.set(e.seq,e)
if(!gapReconnectPending){gapReconnectPending=true;const conv=state.currentConvId,epoch=conversationEpoch;queueMicrotask(()=>{gapReconnectPending=false;if(conv===state.currentConvId&&epoch===conversationEpoch)store.connectLive(streamSeq)})}
return
}
streamSeq = e.seq
if(streamPending.has(e.seq+1)){const pending=streamPending.get(e.seq+1)!;streamPending.delete(e.seq+1);queueMicrotask(()=>store.applyEvent(pending))}
const previousSeq = state.lastSeq
// Running-turn replay reconstructs transient output, but cannot regress the
// persistent card/plan/status snapshot already returned by the detail query.
@@ -872,6 +1133,7 @@ export const store = {
}
case 'artifact.created':
case 'artifact.updated': {
if(e.type==='artifact.updated'&&state.currentConvId)void store.refreshCards(state.currentConvId)
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
if (p?.artifact) {
const ref = { artifactId: p.artifact.id, name: p.artifact.name, kind: p.artifact.kind }
@@ -951,10 +1213,12 @@ export const store = {
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [], ...(taskRef ? { taskRef } : {}) }) })
return 'busy'
}
store.toast(`发送失败:${e}`)
const removed = isNotProjectMember(e)
store.toast(removed ? '无法继续发送:你已不在该项目中或项目已关闭;历史对话仍可查看' : `发送失败:${e}`)
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds,
attachmentNames: attachmentIds.map(attachmentId => state.artifacts.find(artifact => artifact.id === attachmentId)?.name ?? '已添加材料'),
...(taskRef ? { taskRef } : {}), deliveryError: '发送未成功,内容已保留;可修改后重试或移除' }) })
...(taskRef ? { taskRef } : {}),
deliveryError: removed ? '无法发送:你已不在该项目中或项目已关闭,内容已保留' : '发送未成功,内容已保留;可修改后重试或移除' }) })
await store.reconcile()
return 'rejected'
} finally {
@@ -1068,6 +1332,7 @@ export const store = {
if (card.conversationId && card.conversationId !== state.currentConvId) return
cardVersions[card.id] = ++cardVersion
delete cardPatches[card.id]
card=mergeTaskCard(state.cards.find(c=>c.id===card.id),card)
const i = state.cards.findIndex(c => c.id === card.id)
const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards]
set({ cards })
@@ -1079,8 +1344,10 @@ export const store = {
const request = ++cardsRequest
const started = cardVersion
try {
const snapshot = await api.listCards(convId)
const out = await api.listCardsSnapshot(convId)
const snapshot=out.cards??[]
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== cardsRequest) return
if(Number.isSafeInteger(out.replaySeq)&&out.replaySeq!>=0)streamHasWatermark=true
const cards = mergeCardSnapshot(snapshot, state.cards, cardVersions, started, cardPatches)
set({ cards })
settleCardFeedback(cards)
@@ -1197,16 +1464,18 @@ export const store = {
if (epoch !== identityEpoch) return
set({
rdmAuditAvailable: rdmAuditReady(cmds),
rdmCockpitAvailable: cockpitReady(cmds),
rdmExportAvailable: rdmExportReady(cmds),
rdmRolesAvailable: rdmRolesReady(cmds),
rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDutyReady(cmds),
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
rdmRender: renderCapabilities(cmds),
})
} catch {
if (epoch !== identityEpoch) return
set({ rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false })
set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES })
}
try {
const sections = await api.adminZoneSections()
@@ -1280,7 +1549,8 @@ export const store = {
applyProjectEvent(e: StreamEvent) {
if (!Number.isFinite(e.seq) || e.seq <= projectSeq) return
projectSeq = e.seq
const upsert = (card: ActivityCard) => {
const upsert = (incoming: ActivityCard) => {
const card=mergeTaskCard(state.projectCards.find(c=>c.id===incoming.id),incoming)
if (card.projectId !== state.currentProjectId) return
projectCardVersions[card.id] = ++projectCardVersion
const list = store.getState().projectCards
@@ -1393,6 +1663,41 @@ export const store = {
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
*/
async openTaskSource(cardId: string, source: TaskSourceRef) {
const conversationId = state.currentConvId
const projectId = state.currentProjectId
const epoch = conversationEpoch
const intent = state.previewOpenIntent + 1
set({ previewOpenIntent: intent })
const isCurrent = () => conversationId === state.currentConvId && projectId === state.currentProjectId
&& epoch === conversationEpoch && intent === state.previewOpenIntent
try {
const out = await api.taskSource(cardId, source.id)
if (!isCurrent()) return
if (!out.artifact) {
store.toast('来源当前不可用,正在核实工作记录')
if (conversationId) void store.refreshCards(conversationId)
return
}
store.requestTaskSourcePreview(out.artifact, {
notice: out.versionState === 'changed'
? '当前文件已更新;下方引用来自任务处理时的版本,不能作为当前文件的结论'
: '任务处理时引用的来源版本;下方是本项发现引用的片段',
quote: out.source.quote ?? '',
expectedHash: out.versionState === 'current' ? out.source.contentHash : undefined,
})
if (out.versionState === 'changed' && conversationId) void store.refreshCards(conversationId)
} catch {
if (!isCurrent()) return
store.toast('来源当前无法读取,正在核实工作记录')
if (conversationId) void store.refreshCards(conversationId)
}
},
requestTaskSourcePreview(a:Artifact,sourceContext:{notice:string;quote:string;expectedHash?:string}){
store.requestPreview(a)
const conv=previewTabsKey(state.currentConvId)
set({previewTabs:{...state.previewTabs,[conv]:(state.previewTabs[conv]??[]).map(tab=>tab.id===a.id?{...tab,sourceContext}:tab)}})
},
requestPreview(a: Artifact) {
const conv = previewTabsKey(state.currentConvId)
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
@@ -1444,7 +1749,7 @@ export const store = {
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
activatePreview(id: string) {
const conv = previewTabsKey(state.currentConvId)
set({ previewActive: { ...state.previewActive, [conv]: id } })
set({ previewActive: { ...state.previewActive, [conv]: id },previewOpenIntent:state.previewOpenIntent+1 })
},
closePreview(id: string) {
const conv = previewTabsKey(state.currentConvId)
@@ -1452,6 +1757,7 @@ export const store = {
set({
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
previewActive: { ...state.previewActive, [conv]: next.activeId },
previewOpenIntent:state.previewOpenIntent+1,
})
if (next.tabs.length === 0) setUI({ previewExpanded: false })
syncFilesEntry(next.tabs.length > 0)
@@ -1484,6 +1790,11 @@ function isConversationBusy(err: unknown): boolean {
return String(err).includes('409:')
}
/** 已被移出项目或项目已关闭:后端以 403 `conversation: not-project-member` 拒绝继续发送。 */
function isNotProjectMember(err: unknown): boolean {
return String(err).includes('not-project-member')
}
function shellPhase() {
const liveRunId = state.live?.runId ?? null
return deriveShellPhase({
@@ -1508,5 +1819,7 @@ export function mergeStartedRun(runs: Run[], run: Run): Run[] {
export function eventTime(e: { ts?: string }, now = Date.now()): number {
if (!e.ts) return now
const t = Date.parse(e.ts)
return Number.isNaN(t) ? now : t
// 后端 0 值占位(0001-01-01T00:00:00Z 等)会让状态栏读到巨大耗时,回落 now 让「已工作」保持真实时钟。
if (Number.isNaN(t) || t <= 0) return now
return t
}
+253 -5
View File
@@ -517,10 +517,33 @@ 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; }
.rp-panel { display: flex; flex-direction: column; gap: 12px; }
.rp-filters .lg-input { min-width: 140px; }
.rp-view-toggle { justify-content: flex-end; }
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); }
.rp-single { display: grid; grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
.rp-disable-confirm { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; font-size: var(--fs-ui-sm); color: var(--warn-text); background: var(--warn-soft); border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; }
.rp-role-list { display: flex; flex-direction: column; gap: 2px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); background: var(--bg-panel); }
.rp-role-list .az-nav-item { justify-content: space-between; width: 100%; }
.rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.rp-matrix-wrap { overflow-x: auto; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
.rp-matrix { min-width: 760px; }
.rp-matrix th:first-child, .rp-matrix td:first-child { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
.rp-matrix tr.rp-selected td { background: var(--bg-active); }
.rp-matrix tr.rp-selected td:first-child { background: var(--bg-active); }
.rp-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.rp-grants { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; margin-bottom: 12px; }
.rp-grants .ad-check { align-items: flex-start; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-raised); }
.rp-grants .ad-mono { margin-left: auto; }
@media (max-width: 820px) {
.rp-single { grid-template-columns: 1fr; }
.rp-role-list { max-height: 220px; overflow-y: auto; }
}
.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); }
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
.ad-chip-super { background: var(--accent); color: var(--text-on-accent); }
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
@@ -625,12 +648,17 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; }
.rdm-audit-opgroup-head:hover { background: var(--bg-hover); }
.rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); }
.rdm-audit-opgroup-action { color: var(--text-dim); }
.rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); }
.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; }
.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; }
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
.rdm-audit-event { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
.rdm-audit-event-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-dim); }
.rdm-audit-event-action { color: var(--text); }
.rdm-audit-event-summary { color: var(--text); }
.rdm-audit-event-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; }
.rdm-audit-event-fields > div { min-width: 0; padding: 8px; background: var(--bg-raised); border-radius: var(--radius-sm); }
.rdm-audit-event-fields dt { color: var(--text-dim); font-size: var(--fs-ui-xs); }
.rdm-audit-event-fields dd { margin: 3px 0 0; color: var(--text); overflow-wrap: anywhere; }
.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; }
@@ -644,7 +672,227 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
.rdm-audit-materials-side[hidden] { display: none; }
.rdm-audit-materials > .drw { flex: 1; }
.rdm-audit-tree-row { display: flex; align-items: center; gap: 4px; }
.rdm-audit-tree-row .rdm-audit-tree-node { flex: 1; min-width: 0; }
.rdm-audit-tree-open { flex: none; font-size: var(--fs-ui-sm); padding: 4px 8px; }
/* 统一渲染 Task 11:文档阅读与交付工作区(结构画布 / 阅读视图 + 规则、预览、审阅、下载) */
.drw { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.drw-header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.drw-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-lg); color: var(--text); }
.drw-view-tabs { flex: none; }
.drw-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); gap: 16px; align-items: start; }
.drw-main, .drw-side { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.drw-filters { display: flex; gap: 8px; flex-wrap: wrap; }
.drw-search { flex: 1; min-width: 140px; height: 32px; font-size: var(--fs-ui-sm); }
.drw-select { height: 32px; font-size: var(--fs-ui-sm); max-width: 100%; }
.drw-canvas { position: relative; height: min(60vh, 520px); overflow: auto; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); cursor: grab; touch-action: none; }
.drw-canvas.is-panning { cursor: grabbing; user-select: none; }
.drw-canvas-stage { position: relative; }
.drw-canvas-edges { position: absolute; inset: 0; pointer-events: none; }
.drw-canvas-edge { fill: none; stroke: var(--border-strong); stroke-width: 1.5; }
.drw-canvas-node { position: absolute; display: flex; align-items: center; gap: 6px; padding: 0 10px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-dim); font-size: var(--fs-ui-sm); text-align: left; cursor: pointer; }
.drw-canvas-node:hover { background: var(--bg-hover); color: var(--text); }
.drw-canvas-node.depth-0 { color: var(--text); font-weight: 600; border-color: var(--border-strong); }
.drw-canvas-node.sel { background: var(--bg-active); color: var(--text); box-shadow: inset 0 0 0 1px var(--accent); }
.drw-canvas-node:focus-visible, .drw-reader-title:focus-visible { outline: none; box-shadow: var(--ring); }
.drw-canvas-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drw-reader { max-height: min(60vh, 520px); overflow: auto; padding: 4px 16px 16px; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius); }
.drw-reader-node { padding: 4px 8px; border-radius: var(--radius-sm); scroll-margin-top: 8px; }
.drw-reader-node.sel { background: var(--bg-active); }
.drw-reader-heading { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; font-weight: 600; color: var(--text); }
h2.drw-reader-heading { font-size: var(--fs-ui-xl); }
h3.drw-reader-heading { font-size: var(--fs-ui-lg); }
h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); }
.drw-reader-title { background: none; border: none; padding: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: var(--radius-xs); overflow-wrap: anywhere; }
.drw-reader-title[data-anchor-node] { cursor: text; }
.drw-anchor { color: var(--text-faint); font-size: var(--fs-ui-sm); text-decoration: none; }
.drw-anchor:hover { color: var(--accent-text); }
.drw-badges { display: inline-flex; gap: 4px; flex: none; }
.drw-badge { min-width: 18px; padding: 0 5px; border-radius: var(--radius-full); font-size: var(--fs-ui-xs); font-weight: 600; line-height: 16px; text-align: center; color: var(--text-on-semantic); }
.drw-badge.sev-error { background: var(--err-text); }
.drw-badge.sev-warning { background: var(--warn-text); }
.drw-badge.review { background: var(--accent-soft); color: var(--accent-text); }
.drw-selected { padding: 12px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); }
.drw-selected-title { font-size: var(--fs-ui-base); color: var(--text); margin-bottom: 6px; overflow-wrap: anywhere; }
.drw-facts { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); }
.drw-fact { display: flex; gap: 8px; align-items: baseline; }
.drw-fact-k { flex: none; min-width: 4.5em; color: var(--text-dim); }
.drw-fact-v { flex: 1; min-width: 0; color: var(--text); overflow-wrap: anywhere; }
.drw-diff-del, .drw-diff-add { padding: 4px 8px; border-radius: var(--radius-xs); font-size: var(--fs-ui-sm); white-space: pre-wrap; overflow-wrap: anywhere; }
.drw-diff-del { background: var(--diff-remove); color: var(--diff-remove-text); text-decoration: line-through; }
.drw-diff-add { background: var(--diff-add); color: var(--diff-add-text); }
.drw-diff-del + .drw-diff-add { margin-top: 4px; }
.drw-conflict { color: var(--warn-text); }
.drw-byline { display: inline-flex; gap: 6px; align-items: baseline; min-width: 0; }
.drw-byline-name { color: var(--text); font-weight: 550; }
.drw-byline-time { color: var(--text-dim); font-size: var(--fs-ui-sm); }
.drw-panel { display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); min-width: 0; }
.drw-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
.drw-row-between { justify-content: space-between; }
.drw-muted { font-size: var(--fs-ui-sm); color: var(--text-dim); }
.drw-mono { font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
.drw-ok { font-size: var(--fs-ui-sm); color: var(--ok-text); }
.drw-op { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; font-size: var(--fs-ui-sm); }
.drw-receipt { display: flex; flex-direction: column; gap: 6px; }
.drw-filename { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
.drw-failure { display: flex; flex-direction: column; gap: 4px; }
.drw-failure-msg { font-size: var(--fs-ui-sm); color: var(--err-text); overflow-wrap: anywhere; }
.drw-notice { padding: 8px 10px; background: var(--wait-soft); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); }
.drw-profiles { display: flex; flex-direction: column; gap: 8px; }
.drw-profile-list { display: flex; flex-direction: column; gap: 4px; }
.drw-profile { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; }
.drw-profile:hover { background: var(--bg-hover); }
.drw-profile.sel { background: var(--bg-active); }
.drw-profile.off { cursor: not-allowed; }
.drw-profile input { margin-top: 3px; flex: none; }
.drw-profile-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.drw-profile-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-profile-source { color: var(--text); font-weight: 600; }
.drw-profile-id { max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
.drw-profile-ver { color: var(--text-dim); }
.drw-profile-reason { font-size: var(--fs-ui-sm); color: var(--text-dim); }
.drw-diags, .drw-annos { display: flex; flex-direction: column; gap: 6px; }
.drw-group-trigger { display: flex; width: 100%; padding: 4px 0; background: none; border: none; cursor: pointer; text-align: left; }
.drw-group-label { font-size: var(--fs-ui-sm); font-weight: 600; color: var(--text); }
.drw-group-label.sev-error { color: var(--err-text); }
.drw-group-label.sev-warning { color: var(--warn-text); }
.drw-diag-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }
.drw-diag { display: flex; flex-wrap: wrap; gap: 4px 8px; padding: 6px 8px; border-left: 2px solid var(--border); font-size: var(--fs-ui-sm); }
.drw-diag.sev-error { border-left-color: var(--err); }
.drw-diag.sev-warning { border-left-color: var(--warn); }
.drw-diag-node { max-width: 16em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; }
.drw-diag-node.unlocated { color: var(--text-dim); cursor: default; }
.drw-diag-msg { flex: 1 1 12em; min-width: 0; color: var(--text); overflow-wrap: anywhere; }
.drw-diag-code { font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
.drw-diag-hint { flex-basis: 100%; color: var(--text-dim); }
.drw-more { align-self: flex-start; font-size: var(--fs-ui-sm); }
.drw-anno-group { display: flex; flex-direction: column; gap: 4px; }
.drw-anno-node { align-self: flex-start; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); font-weight: 600; cursor: pointer; }
.drw-anno, .drw-rev { padding: 6px 8px; background: var(--bg-raised); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); }
.drw-anno.resolved { opacity: .8; }
.drw-anno-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 2px; }
.drw-anno-text { color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
.drw-anno-replies { margin-top: 6px; padding-left: 12px; border-left: 2px solid var(--border-soft); display: flex; flex-direction: column; gap: 6px; }
.drw-anno-orphan-note { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 4px; }
.drw-anno.needs-reanchor { border-color: var(--warn); }
.drw-anno-quote { margin: 2px 0 4px; padding: 0 8px; border-left: 2px solid var(--border-strong); color: var(--text-dim); white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.drw-anno-reanchor { margin-bottom: 4px; font-size: var(--fs-ui-sm); color: var(--warn-text); }
.drw-rev .drw-diff-del, .drw-rev .drw-diff-add { margin-top: 4px; }
.drw-traces { display: flex; flex-direction: column; gap: 6px; }
.drw-trace { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 6px 8px; border-left: 2px solid var(--border); font-size: var(--fs-ui-sm); }
.drw-trace.masked { border-left-style: dashed; }
.drw-trace-type { font-weight: 600; color: var(--text); }
.drw-trace-subject { min-width: 0; color: var(--text-dim); overflow-wrap: anywhere; }
.drw-trace-masked { padding: 0 6px; border: 1px dashed var(--border-strong); border-radius: var(--radius-xs); color: var(--text-dim); }
.drw-trace-body { flex-basis: 100%; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
.drw-decisions { display: flex; flex-direction: column; gap: 4px; }
.drw-decision { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-downloads { display: flex; gap: 6px; flex-wrap: wrap; }
.drw-download { font-size: var(--fs-ui-sm); padding: 4px 8px; }
.drw-bind-rules { display: flex; flex-direction: column; gap: 4px; }
.drw-bind-rule { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); }
.drw-bind-rule:hover { background: var(--bg-hover); }
.drw-bind-rule.sel { background: var(--bg-active); }
.drw-bind-row { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--border-soft); font-size: var(--fs-ui-sm); }
.drw-composer { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--border-soft); }
.drw-composer-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-composer-node { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.drw-composer-reply { display: flex; gap: 8px; align-items: center; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.drw-composer-scope { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-composer-quote { min-width: 0; color: var(--text); overflow-wrap: anywhere; }
.drw-anchor-notice { font-size: var(--fs-ui-sm); color: var(--warn-text); }
.drw-textarea { min-height: 64px; resize: vertical; font-size: var(--fs-ui-sm); }
.drw-inline-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
.drw-link { padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; }
.drw-link:disabled { color: var(--text-dim); cursor: default; }
.drw-deliveries { display: flex; flex-direction: column; gap: 6px; }
.drw-delivery { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); }
.drw-delivery.sel { background: var(--bg-active); }
@media (max-width: 860px) {
.drw-body { grid-template-columns: minmax(0, 1fr); }
.drw-canvas, .drw-reader { height: auto; max-height: 50vh; }
.drw-canvas { min-height: 240px; }
}
.duty-matrix-wrap { overflow: auto; }
.duty-matrix .duty-sticky { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
.duty-matrix thead th { position: sticky; top: 0; background: var(--bg-panel); z-index: 2; }
.duty-cell select { font-size: var(--fs-ui-sm); color: var(--text); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); }
/* ---- 文档驾驶舱(I2;令牌驱动,词汇沿用 rdm-audit 卡片体系) ---- */
.cockpit { display: flex; flex-direction: column; min-height: 320px; }
.cockpit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
.cockpit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
.cockpit-filter-chip, .cockpit-refresh { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); color: var(--text); cursor: pointer; }
.cockpit-snap { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-banner { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 6px 10px; margin-bottom: 8px; font-size: var(--fs-ui-sm); }
.cockpit-banner-err { border-color: var(--border); }
.cockpit-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; margin-bottom: 12px; }
.cockpit-stat { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; text-align: center; font: inherit; }
button.cockpit-stat { cursor: pointer; }
.cockpit-stat-active { border-color: var(--text); }
.cockpit-stat-unavail { color: var(--text-muted, var(--text)); }
.cockpit-stat-num { display: block; font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
.cockpit-stat-label { display: block; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-columns { display: grid; grid-template-columns: minmax(220px, 300px) 1fr; gap: 14px; }
.cockpit-tree { min-height: 0; overflow-y: auto; }
.cockpit-tree-row { display: flex; align-items: center; gap: 6px; padding: 3px 4px; border-radius: var(--radius-sm); }
.cockpit-tree-row-active { background: var(--bg-panel); }
.cockpit-tree-caret { border: none; background: none; cursor: pointer; padding: 0 2px; font-size: var(--fs-ui-sm); color: var(--text); }
.cockpit-tree-title { border: none; background: none; padding: 0; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; }
.cockpit-badge { font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 4px; color: var(--text-muted, var(--text)); }
.cockpit-tree-time { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-tree-empty, .cockpit-tree-more { border: none; background: none; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 2px 4px; }
button.cockpit-tree-more { cursor: pointer; text-decoration: underline; }
.cockpit-truncated { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 4px 0; }
.cockpit-center { min-height: 0; overflow-y: auto; }
.cockpit-doclist { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.cockpit-docrow { display: flex; align-items: baseline; gap: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 8px 10px; cursor: pointer; font: inherit; text-align: left; }
.cockpit-docrow-title { font-size: var(--fs-ui-base, var(--fs-ui-sm)); color: var(--text); }
.cockpit-docrow-meta { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-changes { display: flex; flex-direction: column; gap: 4px; }
.cockpit-changerow { display: flex; justify-content: space-between; gap: 10px; font-size: var(--fs-ui-sm); }
.cockpit-changesum { color: var(--text); }
.cockpit-changetime { color: var(--text-muted, var(--text)); }
.cockpit-nodehead { display: flex; align-items: baseline; gap: 10px; padding-bottom: 8px; }
.cockpit-back { border: none; background: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); padding: 0; text-decoration: underline; }
.cockpit-nodetitle { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
.cockpit-nodesum { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-childtable { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
.cockpit-childtable th, .cockpit-childtable td { border-bottom: 1px solid var(--border); padding: 4px 6px; text-align: left; color: var(--text); }
@media (max-width: 760px) { .cockpit-columns { grid-template-columns: 1fr; } .cockpit-tree { max-height: 40vh; } }
/* ---- 驾驶舱 I3:阅读器与右栏(令牌驱动) ---- */
.cockpit-columns-3 { grid-template-columns: minmax(200px, 260px) 1fr minmax(240px, 300px); }
.cockpit-right { min-height: 0; overflow-y: auto; border-left: 1px solid var(--border); padding-left: 12px; }
.cockpit-viewtoggle { display: flex; gap: 0; }
.cockpit-viewbtn { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); background: var(--bg-panel); color: var(--text); cursor: pointer; }
.cockpit-viewbtn-active { background: var(--text); color: var(--bg-panel); }
.cockpit-reader-para { white-space: pre-wrap; font-size: var(--fs-ui-base, var(--fs-ui-sm)); line-height: 1.6; color: var(--text); margin: 6px 0; }
.cockpit-density-full .cockpit-reader-para { line-height: 1.9; margin: 10px 0; }
.cockpit-reader-section { border-top: 1px solid var(--border); padding: 8px 0; }
.cockpit-reader-sechead { display: flex; align-items: baseline; gap: 8px; }
.cockpit-reader-sectitle { font-weight: 600; color: var(--text); font-size: var(--fs-ui-base, var(--fs-ui-sm)); }
.cockpit-versions { font-size: var(--fs-ui-sm); }
.cockpit-density-full .cockpit-versions { line-height: 1.7; }
.cockpit-versions-toggle { font-size: var(--fs-ui-sm); color: var(--text); text-decoration: underline; cursor: pointer; }
.cockpit-versions-head { color: var(--text-muted, var(--text)); padding: 4px 0; }
.cockpit-diff { white-space: pre-wrap; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--fs-ui-sm); }
.cockpit-diff-add { color: var(--text); }
.cockpit-diff-del { color: var(--text-muted, var(--text)); text-decoration: line-through; }
.cockpit-diff-same { color: var(--text-muted, var(--text)); }
.cockpit-panel-tabs { display: flex; gap: 4px; padding-bottom: 8px; }
.cockpit-panel-tab { border: 1px solid var(--border); background: none; padding: 3px 8px; font-size: var(--fs-ui-sm); color: var(--text); cursor: pointer; border-radius: var(--radius-sm); }
.cockpit-panel-tab-active { background: var(--bg-panel); border-color: var(--text); }
.cockpit-panel-body { display: flex; flex-direction: column; gap: 6px; }
.cockpit-panel-count { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-panel-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border); padding: 4px 0; }
.cockpit-panel-kind { color: var(--text-muted, var(--text)); }
.cockpit-panel-label { color: var(--text); }
.cockpit-panel-conflict { flex-direction: column; }
.cockpit-conflict-side { color: var(--text); }
@media (max-width: 760px) {
.rdm-audit-event-fields { grid-template-columns: 1fr; }
.rdm-audit-trace-cols { grid-template-columns: 1fr; }
}
+21
View File
@@ -54,9 +54,27 @@ export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
/** 文档驾驶舱消费的全部 rdm.cockpit.* 命令(I1 只读面;rollout I2)。 */
export const COCKPIT_COMMANDS = [
'rdm.cockpit.overview',
'rdm.cockpit.tree',
'rdm.cockpit.node',
'rdm.cockpit.node.sources',
'rdm.cockpit.node.history',
'rdm.cockpit.node.impacts',
'rdm.cockpit.node.conflicts',
] as const
/** 七命令齐备且 enabled 才展示驾驶舱入口(与 rdmAuditReady 同源语义)。 */
export function cockpitReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return COCKPIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
{ key: 'agents', title: '代理', icon: '🤖' },
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
{ key: 'rdm-cockpit', title: '文档驾驶舱', icon: '📋' },
]
function shortcutNum(raw?: string): number | null {
@@ -70,6 +88,8 @@ function shortcutNum(raw?: string): number | null {
interface BuildWorkspaceOptions {
/** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */
rdmAudit?: boolean
/** rdm.cockpit.* 命令面探测通过(store.rdmCockpitAvailable)。默认 false:能力未知/缺失不展示驾驶舱入口。 */
rdmCockpit?: boolean
}
export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] {
@@ -101,6 +121,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWork
}
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
if (opts.rdmAudit === true) ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
if (opts.rdmCockpit === true) ranked.push({ rank: takeFree(3), entry: BUILTINS[2] })
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
return ranked
.filter(item => item.rank <= 9)
+10
View File
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { credentialLabel } from '../src/components/AccountUsersPanel'
describe('account directory credential state', () => {
it('shows whether password is ready without legacy initialization labels', () => {
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待设置密码')
expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).toBe('已设置密码')
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('待设置密码')
})
})
+61
View File
@@ -0,0 +1,61 @@
// 管理端用户面板两处缺陷的回归锁定:
// (1) getUserRole 的 role 摘要不带 permissions(线上实测),projectCreateSources 不得因此抛异常;
// 展示来源时用已加载角色列表里同一 roleId 的条目补齐权限,不新增请求。
// (2) 一次性登录密码按服务端严格解码要求发送 JSON 对象体。
import { afterEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { api, type RoleRecord } from '../src/api'
import { auth } from '../src/auth'
import { ProjectCreateSourceNote, roleWithPermissions } from '../src/components/AccountUsersPanel'
import { projectCreateSources } from '../src/rolePermissions'
const listed: RoleRecord = {
roleId: 'role-pm', name: '项目经理', description: '研发', enabled: true, roleVersion: 3, bindingCount: 1,
permissions: [
{ actionId: 'project.create', allowed: true, capAllowed: true, permissionVersion: 2 },
{ actionId: 'document.read', allowed: true, capAllowed: true, permissionVersion: 1 },
],
}
/** getUserRole 的真实响应形状:有角色字段、没有 permissions。 */
const summary = { roleId: 'role-pm', name: '项目经理', description: '研发', enabled: true, roleVersion: 3, bindingCount: 1 }
afterEach(() => vi.restoreAllMocks())
describe('角色摘要缺少 permissions 时的创建能力来源', () => {
it('把缺失的 permissions 当作空集,不抛异常也不虚构角色来源', () => {
expect(projectCreateSources({ name: '项目经理' }, [])).toEqual([])
expect(projectCreateSources({ name: '项目经理' }, ['project.create'])).toEqual(['明确系统授权'])
expect(projectCreateSources({ ...summary, permissions: undefined }, [])).toEqual([])
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={{ name: '项目经理' }} grants={[]} />))
.toContain('被加入项目后由成员关系提供基础协作')
})
it('用角色列表里同一 roleId 的权限补齐来源', () => {
const resolved = roleWithPermissions(summary, [listed])
expect(resolved?.permissions?.some(item => item.actionId === 'project.create')).toBe(true)
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={resolved} grants={[]} />))
.toContain('创建项目来源:职位角色「项目经理」')
})
it('列表里没有对应条目时按无角色默认权限处理,角色自带权限时不被列表覆盖', () => {
expect(projectCreateSources(roleWithPermissions(summary, []), [])).toEqual([])
const denied = { ...listed, roleId: 'role-other', permissions: [{ actionId: 'project.create', allowed: false, capAllowed: false, permissionVersion: 1 }] }
expect(projectCreateSources(roleWithPermissions({ ...summary, permissions: denied.permissions }, [denied]), [])).toEqual([])
})
})
describe('管理员一次性登录密码请求体', () => {
it('发送 POST + JSON 对象体,避免服务端 400 EOF', async () => {
const issued = { owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true }
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify(issued)))
const out = await api.adminIssueOneTimePassword('alice')
const [url, options] = request.mock.calls[0]
expect(url).toBe('/api/admin/users/alice/one-time-password')
expect(options?.method).toBe('POST')
expect(options?.headers).toEqual({ 'Content-Type': 'application/json' })
expect(JSON.parse(String(options?.body))).toEqual({})
expect(out).toEqual(issued)
})
})
+53
View File
@@ -329,3 +329,56 @@ describe('conversation work reading', () => {
expect(render()).toContain('会覆盖旧草稿,原件保留')
})
})
describe('ProjectLifecycleConversation work record', () => {
it('opens a project lifecycle conversation with a single focused work card and a closed history', () => {
snapshot.state!.cards = [card({ title: '整理材料', status: 'active' }), card({
id: 'history_1', status: 'done', tone: 'ok', title: '旧一轮记录',
body: [{ type: 'markdown', markdown: '项目准备阶段的总结报告' }],
updatedAt: '2026-10-12T08:00:00Z',
})]
const html = renderWork()
expect(html).toContain('整理材料')
expect(html).not.toContain('项目准备阶段的总结报告')
expect(html).toContain('更多工作与成果 · 1 项')
})
it('names a project lifecycle conversation by its own work record when the right work pane is closed', () => {
snapshot.state!.workRecord = { cardId: 'card_1', runId: 'run_1', conversationId: 'cnv_1', title: '材料整理' }
snapshot.state!.cards = [card()]
const html = render()
expect(html).toContain('data-conversation-work')
expect(html).not.toContain('核对会议结论')
})
it('falls back to a project lifecycle conversation level when the auxiliary work pane already owns the detail', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.ui.rightTab = 'work'
snapshot.state!.cards = [card()]
const html = render()
expect(html).not.toContain('data-conversation-work')
expect(html).not.toContain('核对会议结论')
})
it('keeps the project lifecycle conversation summary headline visible regardless of the right pane state', () => {
snapshot.state!.cards = [card({ status: 'done', work: { result: 'project-lifecycle-summary-result', resultVersion: 1, readVersion: 0 } })]
snapshot.state!.ui.rightCollapsed = false
const html = render()
expect(html).toContain('1 项工作有新结果')
expect(html).not.toContain('project-lifecycle-summary-result')
})
it('lists only the project lifecycle conversation\'s active work alongside its completed history', () => {
snapshot.state!.cards = [
card({ status: 'active' }),
card({ id: 'other_project', projectId: 'prj_2', conversationId: 'cnv_2', title: '其它项目的工作',
body: [{ type: 'kv', kv: [['正在做', '其它项目的工作细节']] }] }),
card({ id: 'other_conv', projectId: 'prj_1', conversationId: 'cnv_9', title: '其它会话的工作',
body: [{ type: 'kv', kv: [['正在做', '其它会话的工作细节']] }] }),
]
const html = renderWork()
expect(html).toContain('核对会议结论')
expect(html).not.toContain('其它项目的工作细节')
expect(html).not.toContain('其它会话的工作细节')
})
})
+250
View File
@@ -0,0 +1,250 @@
// 统一渲染 Task 11(审签来源):整文审签先在项目对话写一条已提交的用户消息作为来源,
// 再以该消息为锚点分发命令。覆盖:调用顺序、顶层锚点字段、复用/新建记录对话、
// 每一步失败的可读拒绝(失败即不分发)、同一动作的重试幂等编号、双击只提交一次。
import { describe, expect, it, vi } from 'vitest'
import type { Conversation, Message, Project } from '../src/contracts'
import { commandRequestBody, rdmProjectCommandError } from '../src/rdmManagement'
import { createSingleFlight, describeRenderFailure, decisionPayload } from '../src/documentRenderPresentation'
import {
DECISION_RECORD_TITLE, commitDecision, decisionSourceText, hostProjectFor,
type DecisionPort, type DecisionRequest,
} from '../src/documentDecision'
const conv = (id: string, over: Partial<Conversation> = {}): Conversation => ({
id, projectId: 'host-1', title: DECISION_RECORD_TITLE, createdAt: '2026-10-17T00:00:00Z', updatedAt: '2026-10-17T00:00:00Z', ...over,
})
const userMsg = (id: string, conversationId: string): Message => ({
id, conversationId, role: 'user', parts: [], createdAt: '2026-10-17T00:00:01Z',
})
const hostProject = (over: Partial<Project> = {}): Project => ({
id: 'host-1', name: '宿主项目', createdAt: '2026-10-17T00:00:00Z', updatedAt: '2026-10-17T00:00:00Z',
settings: { 'rdm.state': 'ready', 'rdm.projectId': 'rdm-1' }, ...over,
})
function harness(over: Partial<DecisionPort> = {}) {
const calls: string[] = []
const port: DecisionPort = {
listConversations: vi.fn(async () => { calls.push('list'); return [conv('c-1')] }),
createConversation: vi.fn(async (_pid: string, title?: string) => { calls.push('create'); return conv('c-new', { title: title ?? '' }) }),
sendMessage: vi.fn(async (convId: string) => { calls.push('send'); return { message: userMsg('m-1', convId) } }),
rdmCommand: vi.fn(async () => { calls.push('command'); return { receipt: { state: 'committed' } } }),
...over,
}
return { port, calls }
}
/** 断言并取出拒绝原因;若意外成功则失败。 */
async function failureOf(p: Promise<unknown>): Promise<Error> {
try { await p } catch (e) { return e as Error }
throw new Error('expected the decision to be refused')
}
function request(over: Partial<DecisionRequest> = {}): DecisionRequest {
const payload = decisionPayload('approve', 'rdm-1', 'doc-9', '同意发布', 2)
return {
host: hostProject(), operationId: 'op_a', decision: 'approve',
doc: { id: 'doc-9', title: '采购流程说明' }, round: 2, note: '同意发布',
command: payload.command, params: payload.params, ...over,
}
}
describe('decisionSourceText', () => {
it('states the decision, document, round and note in Chinese without identity fields', () => {
const text = decisionSourceText({ decision: 'approve', doc: { id: 'doc-9', title: '采购流程说明' }, round: 2, note: '同意发布' })
expect(text).toContain('审签决定:审核通过')
expect(text).toContain('文档:采购流程说明(doc-9)')
expect(text).toContain('轮次:第 2 轮')
expect(text).toContain('说明:同意发布')
expect(text.startsWith('/')).toBe(false)
expect(text).not.toMatch(/owner|actor|author|identity/i)
})
it('labels a rejection reason and writes 无 when the note is empty', () => {
const reject = decisionSourceText({ decision: 'reject', doc: { id: 'd', title: '' }, round: 1, note: '依据不足' })
expect(reject).toContain('审签决定:驳回')
expect(reject).toContain('驳回原因:依据不足')
expect(reject).toContain('文档:d')
const final = decisionSourceText({ decision: 'final', doc: { id: 'd', title: 'T' }, round: 1, note: ' ' })
expect(final).toContain('审签决定:终批')
expect(final).toContain('说明:无')
})
})
describe('commitDecision', () => {
it('posts the committed source message first, then dispatches with it as the top-level anchor', async () => {
const { port, calls } = harness()
const req = request()
await commitDecision(port, req)
expect(calls).toEqual(['list', 'send', 'command'])
expect(port.sendMessage).toHaveBeenCalledWith('c-1', decisionSourceText(req), [], 'decision:op_a')
expect(port.rdmCommand).toHaveBeenCalledWith(req.command, req.params, { conversationId: 'c-1', anchorMessageId: 'm-1', entrance: 'button' })
})
it('keeps the command params free of conversation fields and identity', async () => {
const { port } = harness()
await commitDecision(port, request())
const [, params] = vi.mocked(port.rdmCommand).mock.calls[0]
expect(params).toEqual({ projectId: 'rdm-1', docId: 'doc-9', note: '同意发布' })
expect(params).not.toHaveProperty('conversationId')
expect(params).not.toHaveProperty('anchorMessageId')
expect(params).not.toHaveProperty('entrance')
})
it('reuses the titled, non-archived decision record conversation of the host project', async () => {
const { port } = harness({
listConversations: vi.fn(async () => [
conv('c-other', { title: '闲聊' }),
conv('c-archived', { archived: true }),
conv('c-record'),
]),
})
await commitDecision(port, request())
expect(port.listConversations).toHaveBeenCalledWith('host-1')
expect(port.createConversation).not.toHaveBeenCalled()
expect(port.sendMessage).toHaveBeenCalledWith('c-record', expect.any(String), [], 'decision:op_a')
})
it('creates the decision record conversation when the host project has none', async () => {
const { port } = harness({ listConversations: vi.fn(async () => []) })
await commitDecision(port, request())
expect(port.createConversation).toHaveBeenCalledWith('host-1', DECISION_RECORD_TITLE)
expect(port.sendMessage).toHaveBeenCalledWith('c-new', expect.any(String), [], 'decision:op_a')
})
it('anchors on the message id returned by the committed post, never on a different conversation', async () => {
const { port } = harness({
sendMessage: vi.fn(async () => ({ message: userMsg('m-9', 'other-conv') })),
})
await expect(commitDecision(port, request())).rejects.toThrow('未能提交,审签未执行')
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('refuses when the response is not a committed user message', async () => {
const assistant = { ...userMsg('m-2', 'c-1'), role: 'assistant' as const }
const { port } = harness({ sendMessage: vi.fn(async () => ({ message: assistant })) })
await expect(commitDecision(port, request())).rejects.toThrow('审签未执行')
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('refuses when the post was treated as a command rather than recorded as source text', async () => {
const { port } = harness({
sendMessage: vi.fn(async (convId: string) => ({ message: userMsg('m-1', convId), command: 'system.intent' })),
})
await expect(commitDecision(port, request())).rejects.toThrow('审签未执行')
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('refuses without dispatching when the host project is not found', async () => {
const { port } = harness()
await expect(commitDecision(port, request({ host: undefined }))).rejects.toThrow('未找到该研发项目对应的对话项目')
expect(port.listConversations).not.toHaveBeenCalled()
expect(port.sendMessage).not.toHaveBeenCalled()
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('refuses without any write when the host project is not ready', async () => {
const { port } = harness()
const host = hostProject({ settings: { 'rdm.state': 'provisioning' } })
await expect(commitDecision(port, request({ host }))).rejects.toThrow('项目正在准备')
expect(port.listConversations).not.toHaveBeenCalled()
expect(port.sendMessage).not.toHaveBeenCalled()
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('shows a readable refusal and does not dispatch when the session has lapsed', async () => {
const { port } = harness({ listConversations: vi.fn(async () => { throw new Error('401: unauthorized') }) })
const err = await failureOf(commitDecision(port, request()))
expect(err.message).toContain('登录已失效')
expect(port.sendMessage).not.toHaveBeenCalled()
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('reports a busy conversation as retryable and does not dispatch', async () => {
const { port } = harness({ sendMessage: vi.fn(async () => { throw new Error('409: conversation busy') }) })
const err = await failureOf(commitDecision(port, request()))
expect(err.message).toContain('会话正在处理其他任务,请稍后重试')
expect(err.message).toContain('审签未执行')
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('reports a forbidden source post as a refusal and does not dispatch', async () => {
const { port } = harness({ sendMessage: vi.fn(async () => { throw new Error('403: forbidden') }) })
const err = await failureOf(commitDecision(port, request()))
expect(err.message).toContain('当前账号无权在该对话中记录审签')
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('surfaces a source-preflight rejection from the command step as a readable refusal', async () => {
const { port } = harness({
rdmCommand: vi.fn(async () => { throw new Error('project source preflight: formal project command requires an authenticated conversation source') }),
})
const err = await failureOf(commitDecision(port, request()))
const view = describeRenderFailure(err)
expect(view.message).toContain('审签来源尚未通过校验')
expect(view.message.startsWith('{')).toBe(false)
})
it('retries the same operation with the same request id so the server returns the same source message', async () => {
let fail = true
const { port } = harness({
sendMessage: vi.fn(async (convId: string) => {
if (fail) { fail = false; throw new Error('503: unavailable') }
return { message: userMsg('m-1', convId) }
}),
})
await expect(commitDecision(port, request())).rejects.toThrow()
await commitDecision(port, request())
const ids = vi.mocked(port.sendMessage).mock.calls.map(c => c[3])
expect(ids).toEqual(['decision:op_a', 'decision:op_a'])
expect(port.rdmCommand).toHaveBeenCalledTimes(1)
})
})
describe('double click', () => {
it('runs one source post and one command when the action fires twice before settling', async () => {
const { port } = harness()
const flight = createSingleFlight()
const click = () => {
if (flight.busy()) return
void flight.run(() => commitDecision(port, request())).catch(() => undefined)
}
click()
click()
await vi.waitFor(() => expect(port.rdmCommand).toHaveBeenCalledTimes(1))
expect(port.sendMessage).toHaveBeenCalledTimes(1)
expect(flight.busy()).toBe(false)
})
it('releases the flight after a failure so the readable refusal can be retried', async () => {
const flight = createSingleFlight()
await expect(flight.run(() => Promise.reject(new Error('x')))).rejects.toThrow('x')
expect(flight.busy()).toBe(false)
})
})
describe('hostProjectFor', () => {
it('finds the host project bound to the RDM project id and ignores unbound projects', () => {
const bound = hostProject()
const unbound = hostProject({ id: 'host-2', settings: {} })
expect(hostProjectFor([unbound, bound], 'rdm-1')).toBe(bound)
expect(hostProjectFor([unbound], 'rdm-1')).toBeUndefined()
})
})
describe('command request body', () => {
it('keeps the plain body without a source', () => {
expect(commandRequestBody({ a: 1 })).toEqual({ params: { a: 1 } })
})
it('places the source fields at the top level and leaves params untouched', () => {
expect(commandRequestBody({ a: 1 }, { conversationId: 'c', anchorMessageId: 'm', entrance: 'button' }))
.toEqual({ params: { a: 1 }, conversationId: 'c', anchorMessageId: 'm', entrance: 'button' })
})
})
describe('rdmProjectCommandError for decisions', () => {
it('gates decision commands on project work readiness', () => {
expect(rdmProjectCommandError(hostProject(), 'rdm.review.doc.approve')).toBeNull()
expect(rdmProjectCommandError(hostProject({ settings: { 'rdm.state': 'closed' } }), 'rdm.review.doc.approve')).toContain('项目已关闭')
})
})
+267
View File
@@ -0,0 +1,267 @@
// 统一渲染 Task 11(续):整文审签入口、完整包下载、规则绑定与解绑、诊断代码分组。
// 入口只在服务端冻结职责允许时出现;载荷只含 projectId/docId 与 note|reason|round;
// 拒绝以可读文案显示,原始回执只进折叠区。
import { describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import {
RDM_RENDER_COMMANDS, NO_RENDER_CAPABILITIES, renderCapabilities,
normalizeDocSubmission, decisionEntries, decisionPayload,
normalizeDeliveries, downloadOptions,
describeRenderFailure, diagnosticCodeLabel, normalizeDiagnostics,
normalizeCandidateRules, bindableRules, currentBindings, profileBindPayload, profileUnbindPayload,
projectSettingsAllowed, createRequestGuard, normalizeProfiles,
type DecisionEntries,
} from '../src/documentRenderPresentation'
import { ReviewDecisionPanel, ProfileBindingPanel } from '../src/components/DocumentRenderActions'
import { DiagnosticsList } from '../src/components/DocumentRenderWorkspace'
const cmd = (id: string, enabled = true) => ({ id, enabled })
const idle = { op: null, status: 'idle' as const, error: '' }
const statusPayload = {
docId: 'd1',
submission: {
submissionId: 'ds_d1_2', round: 2, state: 'submitted', createdBy: 'author-1',
// Go 结构体 domain.Roles 无 json 标签:字段名原样输出为 Author/Reviewers/Approver。
duties: { Author: 'author-1', Reviewers: ['rev-1', 'rev-2'], Approver: 'appr-1' },
reviews: { 'rev-1': true, 'rev-2': false },
},
}
const sub = normalizeDocSubmission(statusPayload)!
const allowedCaps = { approve: true, reject: true, final: true }
const none: DecisionEntries = { approve: false, reject: false, final: false }
describe('审签与绑定能力门控(命令清单逐项判定)', () => {
it('每项审签/绑定命令独立门控,核心渲染能力不受影响', () => {
const all = RDM_RENDER_COMMANDS.map(id => cmd(id))
expect(renderCapabilities(all)).toMatchObject({ ready: true, approve: true, reject: true, final: true, bind: true, unbind: true })
const noFinal = renderCapabilities(all.filter(c => c.id !== 'rdm.review.doc.final'))
expect(noFinal).toMatchObject({ ready: true, approve: true, reject: true, final: false })
expect(renderCapabilities(all.filter(c => c.id !== 'rdm.render.profile.unbind'))).toMatchObject({ bind: true, unbind: false })
expect(NO_RENDER_CAPABILITIES).toMatchObject({ approve: false, reject: false, final: false, bind: false, unbind: false })
expect(renderCapabilities(RDM_RENDER_COMMANDS.map(id => cmd(id, false))).final).toBe(false)
})
})
describe('整文提交归一化(rdm.review.doc.status)', () => {
it('兼容服务端 Go 字段名 Author/Reviewers/Approver,并保留冻结的审核进度', () => {
expect(sub).toEqual({
submissionId: 'ds_d1_2', round: 2, state: 'submitted', author: 'author-1',
reviewers: ['rev-1', 'rev-2'], approver: 'appr-1', reviews: { 'rev-1': true, 'rev-2': false },
})
})
it('无提交或轮次非法即无记录,不补造', () => {
expect(normalizeDocSubmission({ docId: 'd1', submission: null })).toBeNull()
expect(normalizeDocSubmission({ submission: { round: 0, submissionId: 'x' } })).toBeNull()
expect(normalizeDocSubmission({ submission: { round: 1 } })).toBeNull()
})
})
describe('审签入口:只在服务端冻结职责允许时出现', () => {
it('审核人未审时可通过;已通过的审核人仅可驳回;编制人不可自审', () => {
expect(decisionEntries(sub, 'rev-2', allowedCaps)).toEqual({ approve: true, reject: true, final: false })
expect(decisionEntries(sub, 'rev-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
expect(decisionEntries(sub, 'author-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
})
it('批准人仅在全员审核通过后可终批;批准人本身可驳回', () => {
expect(decisionEntries(sub, 'appr-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
const allDone = { ...sub, reviews: { 'rev-1': true, 'rev-2': true } }
expect(decisionEntries(allDone, 'appr-1', allowedCaps)).toEqual({ approve: false, reject: true, final: true })
})
it('非冻结名单成员、无身份、非在途状态、能力未开一律无入口', () => {
expect(decisionEntries(sub, 'outsider', allowedCaps)).toEqual(none)
expect(decisionEntries(sub, undefined, allowedCaps)).toEqual(none)
expect(decisionEntries({ ...sub, state: 'final' }, 'rev-2', allowedCaps)).toEqual(none)
expect(decisionEntries(sub, 'rev-2', { approve: false, reject: true, final: true })).toEqual({ approve: false, reject: true, final: false })
expect(decisionEntries(null, 'rev-2', allowedCaps)).toEqual(none)
})
})
describe('审签载荷:只含 projectId/docId 与 note|reason|round', () => {
it('approve 的 note、reject 的 reason、final 的 round/reason;空说明不携带', () => {
expect(decisionPayload('approve', 'p1', 'd1', ' 通过 ', 2)).toEqual({ command: 'rdm.review.doc.approve', params: { projectId: 'p1', docId: 'd1', note: '通过' } })
expect(decisionPayload('approve', 'p1', 'd1', ' ', 2)).toEqual({ command: 'rdm.review.doc.approve', params: { projectId: 'p1', docId: 'd1' } })
expect(decisionPayload('reject', 'p1', 'd1', '依据不足', 2)).toEqual({ command: 'rdm.review.doc.reject', params: { projectId: 'p1', docId: 'd1', reason: '依据不足' } })
expect(decisionPayload('final', 'p1', 'd1', '', 2)).toEqual({ command: 'rdm.review.doc.final', params: { projectId: 'p1', docId: 'd1', round: 2 } })
expect(decisionPayload('final', 'p1', 'd1', '终批', 2)).toEqual({ command: 'rdm.review.doc.final', params: { projectId: 'p1', docId: 'd1', round: 2, reason: '终批' } })
})
it('任何审签载荷都不携带身份字段(身份只认会话)', () => {
const identityKey = /actor|user|owner|identity|approvedBy|reviewer|approver|author|signature/i
for (const kind of ['approve', 'reject', 'final'] as const) {
const keys = Object.keys(decisionPayload(kind, 'p1', 'd1', 'x', 2).params)
expect(keys.filter(k => identityKey.test(k))).toEqual([])
}
})
})
describe('完整交付包(rdm.render.download kind=package)', () => {
const ready = normalizeDeliveries({
items: [{ deliveryId: 'd9', mode: 'approved', status: 'ready', profileId: 'x', profileVersion: 1, createdAt: '', availableKinds: ['manifest', 'docx', 'review-docx', 'markdown'] }],
}).items[0]
it('交付已生成(ready)时完整包位于单件下载之后', () => {
expect(downloadOptions(ready).map(o => o.kind)).toEqual(['markdown', 'docx', 'review-docx', 'manifest', 'package'])
expect(downloadOptions(ready).at(-1)?.label).toBe('完整交付包(ZIP)')
})
it('非 ready 的交付不提供完整包', () => {
expect(downloadOptions({ ...ready, status: 'generation-failed' }).map(o => o.kind)).not.toContain('package')
expect(downloadOptions({ ...ready, status: 'frozen' }).map(o => o.kind)).not.toContain('package')
})
})
describe('服务端拒绝码:翻译为可读文案,原始回执保留', () => {
it('受控、未发布、绑定变化三类拒绝', () => {
const controlled = describeRenderFailure(new Error('400: controlled_requires_exact_profile: controlled document d1 requires its exact template profile'))
expect(controlled.message).toContain('受控文档')
expect(controlled.raw).toContain('controlled_requires_exact_profile')
expect(describeRenderFailure(new Error('400: profile_not_published: x@1 is not published (missing)')).message).toContain('尚未发布')
expect(describeRenderFailure(new Error('400: binding_mismatch: document d1 is bound to a@1')).message).toContain('刷新')
})
it('审签拒绝保持服务端原文(去掉状态码前缀)', () => {
expect(describeRenderFailure(new Error('400: 仅本轮(第 2 轮)冻结名单内的审核人可审批')))
.toMatchObject({ message: '仅本轮(第 2 轮)冻结名单内的审核人可审批' })
})
})
describe('诊断:八类代码的中文标签与严重度分组', () => {
const codes = ['required_content_missing', 'intentionally_empty', 'template_placeholder_left', 'mapping_fallback',
'signature_data_missing', 'mapping_unresolved', 'mapping_ambiguous', 'approval_invalid']
it('已知代码显示中文标签,未知代码原样保留', () => {
for (const code of codes) expect(diagnosticCodeLabel(code)).not.toBe(code)
expect(diagnosticCodeLabel('custom_x')).toBe('custom_x')
})
it('错误在前、警告在后;节点按标题定位,代码标签可见', () => {
const diags = normalizeDiagnostics([
{ code: 'mapping_unresolved', severity: 'error', message: '映射未解析', nodeId: 'n1', suggestion: '补充映射' },
{ code: 'template_placeholder_left', severity: 'warning', message: '占位符未替换', nodeId: 'n2' },
])
const html = renderToStaticMarkup(createElement(DiagnosticsList, {
diagnostics: diags, nodeTitle: id => `章节 ${id}`, onSelectNode: () => {}, idPrefix: 'p',
}))
expect(html.indexOf('错误(1)')).toBeGreaterThanOrEqual(0)
expect(html.indexOf('错误(1)')).toBeLessThan(html.indexOf('警告(1)'))
expect(html).toContain(diagnosticCodeLabel('mapping_unresolved'))
expect(html).toContain('章节 n1')
})
})
describe('规则绑定:候选与服务端选项合并、当前绑定、载荷', () => {
const profiles = normalizeProfiles({
projectId: 'p1', docId: 'd1', controlled: false, fallbackAllowed: true,
options: [
{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true },
{ source: 'document', profileId: 'doc', version: '2', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true },
{ source: 'project-default', profileId: 'prj', version: '5', state: 'blocked', publicationAllowed: true, controlled: false, fallbackAllowed: true, blockedReason: '资产校验未通过' },
],
})
const candidates = normalizeCandidateRules({
projectId: 'p1',
candidates: [
{ candidateId: 'c1', state: 'published', publication: { profileId: 'prj.std', version: '2', decision: 'ok' } },
{ candidateId: 'c2', state: 'draft' },
{ candidateId: 'c3', state: 'published', publication: { profileId: 'prj.std', version: '2' } },
],
})
it('可绑定规则 = 服务端选项中已发布项 ∪ 项目已发布候选(去重,保序)', () => {
expect(bindableRules(profiles, candidates).map(r => `${r.profileId}@${r.version}`)).toEqual(['org@3', 'doc@2', 'prj.std@2'])
})
it('当前绑定只取服务端选项中的绑定来源', () => {
expect(currentBindings(profiles)).toEqual({
document: { profileId: 'doc', version: '2' },
'project-default': { profileId: 'prj', version: '5' },
})
})
it('绑定载荷:key 按作用域取值;文档类型缺失则不生成载荷', () => {
const rule = { profileId: 'prj.std', version: '2' }
expect(profileBindPayload({ projectId: 'p1', source: 'document', docId: 'd1', typeKey: 'SDD', rule }))
.toEqual({ projectId: 'p1', source: 'document', key: 'd1', profileId: 'prj.std', version: '2' })
expect(profileBindPayload({ projectId: 'p1', source: 'document-type', docId: 'd1', typeKey: 'SDD', rule }))
.toEqual({ projectId: 'p1', source: 'document-type', key: 'SDD', profileId: 'prj.std', version: '2' })
expect(profileBindPayload({ projectId: 'p1', source: 'project-default', docId: 'd1', typeKey: undefined, rule }))
.toEqual({ projectId: 'p1', source: 'project-default', key: 'p1', profileId: 'prj.std', version: '2' })
expect(profileBindPayload({ projectId: 'p1', source: 'document-type', docId: 'd1', typeKey: undefined, rule })).toBeUndefined()
})
it('解绑带前置条件(profileId+version 成对);无绑定时不带前置条件', () => {
expect(profileUnbindPayload({ projectId: 'p1', source: 'document', docId: 'd1', typeKey: 'SDD', precondition: { profileId: 'doc', version: '2' } }))
.toEqual({ projectId: 'p1', source: 'document', key: 'd1', profileId: 'doc', version: '2' })
expect(profileUnbindPayload({ projectId: 'p1', source: 'project-default', docId: 'd1', typeKey: 'SDD' }))
.toEqual({ projectId: 'p1', source: 'project-default', key: 'p1' })
})
})
describe('项目设置权限:以服务端 rdm.perm.check 的 project.settings 为准', () => {
it('仅 allowed=true 的 project.settings 才放行', () => {
expect(projectSettingsAllowed({ projectId: 'p1', member: 'u', role: 'member', profRole: null, actions: [{ action: 'project.settings', allowed: true, source: 'override' }] })).toBe(true)
expect(projectSettingsAllowed({ actions: [{ action: 'project.settings', allowed: false, source: 'x' }] })).toBe(false)
expect(projectSettingsAllowed({ actions: [{ action: 'doc.read', allowed: true, source: 'x' }] })).toBe(false)
expect(projectSettingsAllowed(null)).toBe(false)
})
})
describe('请求守卫:过期的审签/绑定结果被丢弃', () => {
it('同一作用域的新请求使旧结果失效;取消后全部失效', () => {
const g = createRequestGuard()
const first = g.begin('scope')
const second = g.begin('scope')
expect(g.current(first, 'scope')).toBe(false)
expect(g.current(second, 'scope')).toBe(true)
g.cancel()
expect(g.current(second, 'scope')).toBe(false)
})
})
describe('审签面板:入口门控与拒绝显示', () => {
const noop = () => {}
it('无可用入口时不出现任何动作按钮,只显示轮次与进度', () => {
const html = renderToStaticMarkup(createElement(ReviewDecisionPanel, {
sub, entries: none, busy: false, status: idle, onRetry: noop, onDecide: noop,
}))
expect(html).toContain('第 2 轮')
expect(html).not.toContain('审核通过')
expect(html).not.toContain('驳回')
expect(html).not.toContain('终批')
})
it('只渲染服务端允许的入口;失败以可读文案显示并保留原始回执', () => {
const entries: DecisionEntries = { approve: true, reject: true, final: false }
const html = renderToStaticMarkup(createElement(ReviewDecisionPanel, {
sub, entries, busy: false,
status: { op: { kind: 'reject', operationId: 'op_1' }, status: 'failed', error: '400: 当前身份无权驳回' },
onRetry: noop, onDecide: noop,
}))
expect(html).toContain('审核通过')
expect(html).toContain('驳回')
expect(html).not.toContain('终批')
expect(html).toContain('当前身份无权驳回')
expect(html).toContain('原始回执')
})
})
describe('规则绑定面板:当前绑定、解绑入口与文档类型禁用', () => {
const noop = () => {}
const rules = [{ profileId: 'prj.std', version: '2', label: 'prj.std v2' }]
it('显示三个作用域的当前绑定;可解绑已绑定项;未选规则时绑定按钮禁用', () => {
const html = renderToStaticMarkup(createElement(ProfileBindingPanel, {
rules, bindings: { document: { profileId: 'doc', version: '2' } }, typeKey: undefined,
busy: false, status: idle, onRetry: noop, onBind: noop, onUnbind: noop,
}))
expect(html).toContain('本文档')
expect(html).toContain('文档类型')
expect(html).toContain('项目默认')
expect(html).toContain('doc@2')
expect(html).toContain('解绑')
expect(html).toContain('disabled')
})
it('文档类型未知时,文档类型行提示无法绑定', () => {
const html = renderToStaticMarkup(createElement(ProfileBindingPanel, {
rules, bindings: {}, typeKey: undefined, busy: false, status: idle, onRetry: noop, onBind: noop, onUnbind: noop,
}))
expect(html).toContain('文档类型未就绪')
})
})
describe('能力表与类型守卫', () => {
it('未知 source 的绑定不进入当前绑定', () => {
const p = normalizeProfiles({ options: [{ source: 'template', profileId: 'company.TPL', version: '1', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false }] })
expect(currentBindings(p)).toEqual({})
})
})
+916
View File
@@ -0,0 +1,916 @@
// 统一渲染 Task 11:规则选择、预览诊断、双视图文档树、审阅与下载的纯展示逻辑。
// 组件只消费这里的函数;服务端字段一律视为可能被裁剪,缺失即不显示,不补造。
import { describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import {
RDM_RENDER_COMMANDS, renderCapabilities,
normalizeProfiles, profileOptionViews, controlledNotice, defaultProfileKey, profileKey,
describeRenderFailure, displayText,
normalizeDiagnostics, groupDiagnostics, diagnosticsSummaryText, diagnosticCountsByNode, locateDiagnostic,
nextOperation, createRequestGuard, renderScope,
buildDocTree, visibleNodeIds, visibleOrder, layoutMindMap, moveSelection, nodeAnchorId,
nodeFacts, hasFacts,
normalizeReviewView, partitionAnnotations, partitionRevisions, annotatePayload, revisePayload, validateReviewText,
locateNodeId, reviewTargetNode, normalizeRenderTrace, traceAnchor, traceTypeLabel, traceEndpointLabel,
normalizeDeliveries, mergeDeliveries, downloadOptions, normalizeDownload, decodeBase64,
truncateMiddle, latestSubmissionId, formatRenderTime,
submissionIdOf, currentSubmissionId, loadDeliveryDiagnostics, receiptStateView, walkDocTree, DOC_TREE_LIMITS,
type RenderProfiles, type DocTree,
} from '../src/documentRenderPresentation'
import {
ProfileOptionList, DiagnosticsList, AnnotationGroups, RevisionGroups, RenderTraceList, DownloadButtons, RenderReceipt,
} from '../src/components/DocumentRenderWorkspace'
import { RenderFailure } from '../src/components/renderFeedback'
const cmd = (id: string, enabled = true) => ({ id, enabled })
describe('渲染能力门控(fail-closed)', () => {
it('核心命令齐备才 ready;审阅/批注各自独立门控', () => {
const all = RDM_RENDER_COMMANDS.map(id => cmd(id))
const caps = renderCapabilities(all)
expect(caps).toEqual({
ready: true, submit: true, review: true, annotate: true, revise: true, reviewExport: true, approvedExport: true,
approve: true, reject: true, final: true, bind: true, unbind: true,
})
const noReview = renderCapabilities(all.filter(c => c.id !== 'rdm.render.review.view'))
expect(noReview.ready).toBe(true)
expect(noReview.review).toBe(false)
expect(renderCapabilities(all.filter(c => c.id !== 'rdm.render.profiles')).ready).toBe(false)
})
it('null / 非数组 / disabled 一律未就绪', () => {
expect(renderCapabilities(null).ready).toBe(false)
expect(renderCapabilities('x' as never).ready).toBe(false)
expect(renderCapabilities(RDM_RENDER_COMMANDS.map(id => cmd(id, false))).ready).toBe(false)
})
})
const profiles = (over: Partial<RenderProfiles> = {}): RenderProfiles => ({
options: [
{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false },
{ source: 'template', profileId: 'tpl', version: '1', state: 'draft', publicationAllowed: false, controlled: true, fallbackAllowed: false },
{ source: 'document', profileId: 'doc', version: '2', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false, snapshotHash: 'h1' },
{ source: 'project-default', profileId: 'prj', version: '5', state: 'blocked', publicationAllowed: true, controlled: true, fallbackAllowed: false, blockedReason: '资产校验未通过' },
],
...over,
})
describe('规则选择(五级来源)', () => {
it('按 文档 > 模板 > 项目默认 > 公司默认 排序并给出来源/版本/状态', () => {
const views = profileOptionViews(profiles())
expect(views.map(v => v.option.source)).toEqual(['document', 'template', 'project-default', 'organization-default'])
expect(views.map(v => v.sourceLabel)).toEqual(['文档规则', '模板规则', '项目默认规则', '公司默认规则'])
expect(views[0]).toMatchObject({ stateLabel: '已发布', previewAllowed: true, formalAllowed: true })
expect(views[0].reason).toBeUndefined()
})
it('停用规则不可用,给出服务端原因;草稿只能预览', () => {
const [, draft, blocked] = profileOptionViews(profiles())
expect(blocked).toMatchObject({ previewAllowed: false, formalAllowed: false, reason: '资产校验未通过' })
expect(draft).toMatchObject({ previewAllowed: true, formalAllowed: false })
expect(draft.reason).toContain('草稿')
})
it('受控文档不回退到项目或公司默认,并给出明确说明', () => {
const org = profileOptionViews(profiles())[3]
expect(org).toMatchObject({ previewAllowed: false, formalAllowed: false })
expect(org.reason).toContain('不回退到项目或公司默认')
expect(controlledNotice(profiles())).toContain('不会回退到项目或公司默认规则')
const free = profiles({ options: [{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true }] })
expect(controlledNotice(free)).toBeUndefined()
expect(profileOptionViews(free)[0].previewAllowed).toBe(true)
})
it('未获发布许可的已发布规则不能用于正式交付', () => {
const p = profiles({ options: [{ source: 'project-default', profileId: 'p', version: '1', state: 'published', publicationAllowed: false, controlled: false, fallbackAllowed: true }] })
expect(profileOptionViews(p)[0]).toMatchObject({ previewAllowed: true, formalAllowed: false })
})
it('默认选中服务端 selected;否则第一个可预览项', () => {
const p = profiles({ selected: { source: 'template', profileId: 'tpl', version: '1', hash: 'x' } })
expect(defaultProfileKey(p)).toBe(profileKey({ source: 'template', profileId: 'tpl', version: '1' }))
expect(defaultProfileKey(profiles())).toBe('document:doc@2')
expect(defaultProfileKey(profiles({ options: [] }))).toBe('')
})
it('归一化丢弃不完整或未知来源的选项', () => {
const p = normalizeProfiles({ options: [{ source: 'galaxy', profileId: 'x', version: '1' }, { source: 'project', profileId: 'x', version: '1' }, { source: 'document', profileId: 'd' }, { source: 'document', profileId: 'd', version: 2, state: 'published' }] })
expect(p.options).toHaveLength(1)
expect(p.options[0]).toMatchObject({ profileId: 'd', version: '2', publicationAllowed: false, controlled: false, fallbackAllowed: false })
expect(p.refusal).toBeUndefined()
expect(normalizeProfiles(null).options).toEqual([])
})
})
// 以下夹具逐字段对照服务端 JSON(Agent-System-RDM internal/rdmapp/render_workspace.go
// RenderProfileOptions / render_delivery.go RenderDeliveryReceipt / docsubmission.go
// DocSubmissionRec / plugins/rdm/plugin.go rdmRenderDownload / inspection.go InspectTree)。
describe('服务端 rdm.render.profiles 真实载荷', () => {
const uncontrolled = {
projectId: 'p1', docId: 'd1', controlled: false, fallbackAllowed: true,
options: [
{ source: 'document-type', profileId: 'type.design', version: '2', state: 'published', publicationAllowed: true, snapshotHash: 'h2', controlled: false, fallbackAllowed: true },
{ source: 'project-default', profileId: 'prj.std', version: '1', state: 'published', publicationAllowed: true, snapshotHash: 'h3', controlled: false, fallbackAllowed: true },
{ source: 'organization-default', profileId: 'org.std', version: '4', state: 'published', publicationAllowed: true, snapshotHash: 'h4', controlled: false, fallbackAllowed: true },
],
selected: { source: 'document-type', profileId: 'type.design', version: '2', hash: 'h2' },
}
it('document-type / project-default 选项与 selected 都被保留,selected 为默认选择', () => {
const p = normalizeProfiles(uncontrolled)
expect(p.options.map(o => o.source)).toEqual(['document-type', 'project-default', 'organization-default'])
expect(p.selected).toEqual({ source: 'document-type', profileId: 'type.design', version: '2', hash: 'h2' })
expect(profileOptionViews(p).map(v => v.sourceLabel)).toEqual(['文档类型规则', '项目默认规则', '公司默认规则'])
expect(defaultProfileKey(p)).toBe('document-type:type.design@2')
})
it('只有 project-default 命中时以它为默认选择', () => {
const p = normalizeProfiles({ ...uncontrolled, options: uncontrolled.options.slice(1), selected: { source: 'project-default', profileId: 'prj.std', version: '1', hash: 'h3' } })
expect(defaultProfileKey(p)).toBe('project-default:prj.std@1')
expect(profileOptionViews(p)[0]).toMatchObject({ previewAllowed: true, formalAllowed: true, sourceLabel: '项目默认规则' })
})
it('五级来源按 文档 > 文档类型 > 模板 > 项目默认 > 公司默认 排序', () => {
const opt = (source: string, profileId: string) => ({ source, profileId, version: '1', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true })
const p = normalizeProfiles({ options: [opt('organization-default', 'o'), opt('project-default', 'p'), opt('template', 't'), opt('document-type', 'dt'), opt('document', 'd')] })
expect(profileOptionViews(p).map(v => [v.option.source, v.sourceLabel])).toEqual([
['document', '文档规则'], ['document-type', '文档类型规则'], ['template', '模板规则'], ['project-default', '项目默认规则'], ['organization-default', '公司默认规则'],
])
})
it('受控文档:模板规则可选,项目默认与公司默认带服务端原因且不可用', () => {
const reason = '受控文档必须使用精确绑定的规则,不得回退到项目或公司默认规则'
const p = normalizeProfiles({
projectId: 'p1', docId: 'd1', controlled: true, fallbackAllowed: false,
options: [
{ source: 'template', profileId: 'company.TPL-SDD', version: '3', state: 'published', publicationAllowed: true, snapshotHash: 'ht', controlled: true, fallbackAllowed: false },
{ source: 'project-default', profileId: 'prj.std', version: '1', state: 'published', publicationAllowed: true, snapshotHash: 'h3', controlled: true, fallbackAllowed: false, blockedReason: reason },
{ source: 'organization-default', profileId: 'org.std', version: '4', state: 'published', publicationAllowed: true, snapshotHash: 'h4', controlled: true, fallbackAllowed: false, blockedReason: reason },
],
refusal: { code: 'controlled_requires_exact_profile', message: '受控文档必须使用文档或文档类型精确绑定的规则,不得回退到项目或公司默认规则' },
})
const views = profileOptionViews(p)
expect(views.map(v => [v.sourceLabel, v.previewAllowed])).toEqual([['模板规则', true], ['项目默认规则', false], ['公司默认规则', false]])
expect(views[1].reason).toBe(reason)
expect(defaultProfileKey(p)).toBe('template:company.TPL-SDD@3')
})
it('已验证未发布的模板规则:服务端 blockedReason 只挡正式交付,仍可预览且为默认选择', () => {
const blockedReason = '规则 company.dhf.2750fbaf1f67d10c@1.0.0 处于 validated 状态,未发布,不能用于正式审阅或批准交付'
const p = normalizeProfiles({
projectId: 'p1', docId: 'doc-33', controlled: true, fallbackAllowed: false,
options: [{ source: 'template', profileId: 'company.dhf.2750fbaf1f67d10c', version: '1.0.0', state: 'validated', publicationAllowed: false, snapshotHash: '5fba', controlled: true, fallbackAllowed: false, blockedReason }],
refusal: { code: 'controlled_requires_exact_profile', message: '受控文档必须使用文档或文档类型精确绑定的规则,不得回退到项目或公司默认规则' },
})
expect(profileOptionViews(p)[0]).toMatchObject({ stateLabel: '已验证', previewAllowed: true, formalAllowed: false, reason: blockedReason })
expect(defaultProfileKey(p)).toBe('template:company.dhf.2750fbaf1f67d10c@1.0.0')
})
it('没有已验证快照的规则(missing)不可预览也不可正式交付', () => {
const blockedReason = '规则 company.dhf.x@2.0.0 尚无已验证的快照,不能用于正式审阅或批准交付'
const p = normalizeProfiles({ options: [{ source: 'template', profileId: 'company.dhf.x', version: '2.0.0', state: 'missing', publicationAllowed: false, controlled: true, fallbackAllowed: false, blockedReason }] })
expect(profileOptionViews(p)[0]).toMatchObject({ stateLabel: '无快照', previewAllowed: false, formalAllowed: false, reason: blockedReason })
expect(defaultProfileKey(p)).toBe('')
})
it('refusal 对象 {code,message} 显示 message', () => {
const message = '受控文档必须使用文档或文档类型精确绑定的规则,不得回退到项目或公司默认规则'
const p = normalizeProfiles({ projectId: 'p1', docId: 'd1', controlled: true, fallbackAllowed: false, options: [], refusal: { code: 'controlled_requires_exact_profile', message } })
expect(p.refusal).toBe(message)
const html = renderToStaticMarkup(createElement(ProfileOptionList, { profiles: p, selectedKey: '', onSelect: () => {} }))
expect(html).toContain(message)
expect(html).toContain('drw-failure')
expect(html).not.toContain('没有可用的渲染规则')
})
it('refusal 缺 message 时退到 code', () => {
const p = normalizeProfiles({ options: [], refusal: { code: 'profile_not_found', message: '' } })
expect(p.refusal).toBe('profile_not_found')
expect(normalizeProfiles({ options: [], refusal: {} }).refusal).toBeUndefined()
})
})
describe('服务端 rdm.render.submit 真实回执', () => {
it('从 DocSubmissionRec 取 submissionId', () => {
const rec = {
submissionId: 'sub-9', projectId: 'p1', docId: 'd1', round: 2, sections: { s1: 3 },
duties: { author: 'amy', reviewer: 'bob', approver: 'carol' }, digest: 'dg', state: 'pending', reviews: {},
createdBy: 'amy', createdAt: '2026-10-15T00:00:00Z',
}
expect(submissionIdOf(rec)).toBe('sub-9')
expect(submissionIdOf({ id: 'legacy' })).toBeUndefined()
expect(submissionIdOf(null)).toBeUndefined()
})
it('当前提交:本会话选择 > rdm.review.doc.status 最新提交 > 交付记录推断', () => {
const delivered = [{ submissionId: 'sub-old', createdAt: '2026-10-15T00:00:00Z' }] as Parameters<typeof latestSubmissionId>[0]
const status = { docId: 'd1', submission: { submissionId: 'sub-new', round: 2, state: 'pending' } }
expect(currentSubmissionId('sub-mine', status, delivered)).toBe('sub-mine')
expect(currentSubmissionId(null, status, delivered)).toBe('sub-new')
expect(currentSubmissionId(null, { docId: 'd1', submission: null }, delivered)).toBe('sub-old')
expect(currentSubmissionId(null, null, [])).toBeNull()
})
})
const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64')
const readyReceipt = {
jobId: 'rjob-1', deliveryId: 'rdlv-1', projectId: 'p1', docId: 'd1', operationId: 'op_1', mode: 'preview', state: 'ready',
sourceVersionId: 'sv1', profileSnapshotHash: 'ph', reviewSnapshotHash: '', artifactSha256: 'a', manifestSha256: 'm',
fileName: 'd1.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', createdAt: '2026-10-15T00:00:00Z',
}
describe('预览/导出后读取诊断(rdm.render.download kind=diagnostics)', () => {
const diagnosticsJSON = JSON.stringify([
{ code: 'heading.skip', severity: 'warning', message: '标题层级跳跃', nodeId: 's2', rulePath: 'headings' },
{ code: 'section.required', severity: 'error', message: '缺少必填章节', nodeId: 's1', suggestion: '补写范围' },
])
const download = (data: string) => ({
deliveryId: 'rdlv-1', kind: 'diagnostics', fileName: 'd1-diagnostics.json', mime: 'application/json', sha256: 'x', dataBase64: b64(data),
})
it('按回执 deliveryId 请求诊断并解码 UTF-8 JSON', async () => {
const calls: [string, Record<string, unknown>][] = []
const out = await loadDeliveryDiagnostics(async (command, params) => { calls.push([command, params]); return download(diagnosticsJSON) }, 'p1', readyReceipt, () => true)
expect(calls).toEqual([['rdm.render.download', { projectId: 'p1', deliveryId: 'rdlv-1', kind: 'diagnostics' }]])
expect(out).toMatchObject({ deliveryId: 'rdlv-1' })
const list = out && 'diagnostics' in out ? out.diagnostics : []
expect(list.map(d => [d.code, d.severity, d.nodeId])).toEqual([['heading.skip', 'warning', 's2'], ['section.required', 'error', 's1']])
expect(list[1].message).toBe('缺少必填章节')
})
it('服务端 nil 诊断(JSON null)视为无问题', async () => {
const out = await loadDeliveryDiagnostics(async () => download('null'), 'p1', readyReceipt, () => true)
expect(out).toEqual({ deliveryId: 'rdlv-1', diagnostics: [] })
})
it('请求失败或内容无法解析给出可读文案,不抛出', async () => {
const failed = await loadDeliveryDiagnostics(async () => { throw new Error('400: delivery blob is not available: diagnostics') }, 'p1', readyReceipt, () => true)
expect(failed).toEqual({ deliveryId: 'rdlv-1', error: 'delivery blob is not available: diagnostics' })
const garbled = await loadDeliveryDiagnostics(async () => download('{not json'), 'p1', readyReceipt, () => true)
expect(garbled).toEqual({ deliveryId: 'rdlv-1', error: '诊断报告无法解析' })
const incomplete = await loadDeliveryDiagnostics(async () => ({ deliveryId: 'rdlv-1' }), 'p1', readyReceipt, () => true)
expect(incomplete).toEqual({ deliveryId: 'rdlv-1', error: '下载内容不完整' })
})
it('回执缺 deliveryId 不发请求', async () => {
let called = false
const out = await loadDeliveryDiagnostics(async () => { called = true }, 'p1', { state: 'ready' }, () => true)
expect(called).toBe(false)
expect(out).toEqual({ deliveryId: '', error: '回执缺少交付编号,无法读取诊断' })
})
it('过期下载(守卫失效)被丢弃', async () => {
let live = true
const out = await loadDeliveryDiagnostics(async () => { live = false; return download(diagnosticsJSON) }, 'p1', readyReceipt, () => live)
expect(out).toBeNull()
const failedStale = await loadDeliveryDiagnostics(async () => { live = false; throw new Error('boom') }, 'p1', readyReceipt, () => live)
expect(failedStale).toBeNull()
})
it('回执面板:中文状态、诊断加载中/失败可读/节点定位', () => {
const render = (diagnostics: Parameters<typeof RenderReceipt>[0]['diagnostics']) => renderToStaticMarkup(createElement(RenderReceipt, {
receipt: readyReceipt, diagnostics, nodeTitle: (id: string) => (id === 's1' ? '范围' : undefined), onSelectNode: () => {},
}))
const loading = render({ status: 'loading' })
expect(loading).toContain('已生成')
expect(loading).not.toContain('>ready<')
expect(loading).toContain('诊断读取中')
const failed = render({ status: 'error', error: '400: {"receipt":{"error":"诊断不可用"}}' })
expect(failed).toContain('诊断不可用')
expect(failed).toContain('drw-failure')
const ready = render({ status: 'ready', items: normalizeDiagnostics([{ code: 'E1', severity: 'error', message: '缺少必填章节', nodeId: 's1' }]) })
expect(ready).toContain('1 个错误')
expect(ready).toContain('drw-diag-node')
expect(ready).toContain('title="范围"')
})
it('回执 state 映射为中文标签', () => {
expect(receiptStateView('ready')).toEqual({ label: '已生成', tone: 'ok' })
expect(receiptStateView('running')).toEqual({ label: '生成中', tone: 'info' })
expect(receiptStateView('frozen')).toEqual({ label: '排队中', tone: 'plain' })
expect(receiptStateView('generation-failed')).toEqual({ label: '生成失败', tone: 'danger' })
expect(receiptStateView('cancelled')).toEqual({ label: '已取消', tone: 'warn' })
expect(receiptStateView('weird')).toEqual({ label: '状态未知', tone: 'plain' })
expect(receiptStateView(undefined)).toEqual({ label: '状态未知', tone: 'plain' })
})
})
describe('文档树逐层展开(rdm.inspect.tree 每次只返回一层)', () => {
type Node = { id: string; title: string; children?: Node[] }
const shellItem = (docId: string, parentId: string, n: Node, order: number) => ({
kind: 'section', id: n.id, docId, parentId, order, title: n.title, templateBlockId: '', state: 'draft',
hasChildren: (n.children?.length ?? 0) > 0, structureRevision: 1, contentRevision: 1,
})
/** 模拟 InspectTree:parent=docId 返回顶层(parentId 为空串),parent=nodeId 返回其直接子节点;按 limit 分页。 */
function fakeServer(docId: string, roots: Node[], limit = 50) {
const byParent = new Map<string, Node[]>([[docId, roots]])
const visit = (list: Node[]) => { for (const n of list) { byParent.set(n.id, n.children ?? []); visit(n.children ?? []) } }
visit(roots)
const calls: string[] = []
let inflight = 0
let maxInflight = 0
const fetchLevel = async (parent: string, cursor: string) => {
calls.push(parent)
inflight++
maxInflight = Math.max(maxInflight, inflight)
await new Promise(r => setTimeout(r, 1))
inflight--
const list = byParent.get(parent) ?? []
const start = cursor ? Number(cursor) : 0
const page = list.slice(start, start + limit)
const hasMore = start + limit < list.length
return {
items: page.map((n, i) => shellItem(docId, parent === docId ? '' : parent, n, start + i)),
hasMore, ...(hasMore ? { nextCursor: String(start + limit) } : {}),
}
}
return { fetchLevel, calls, maxInflight: () => maxInflight }
}
const roots: Node[] = [
{ id: 'n1', title: '范围', children: [{ id: 'n11', title: '目的', children: [{ id: 'n111', title: '背景' }] }, { id: 'n12', title: '适用' }] },
{ id: 'n2', title: '设计', children: [{ id: 'n21', title: '架构' }] },
]
it('逐层请求子节点并按文档顺序构建完整嵌套树(非编号标题)', async () => {
const s = fakeServer('doc1', roots)
const out = await walkDocTree(s.fetchLevel, 'doc1', () => true)
expect(out?.truncated).toBe(false)
const t = buildDocTree({ id: 'doc1', title: '设计说明书' }, out!.items)
expect(t.order).toEqual(['doc1', 'n1', 'n11', 'n111', 'n12', 'n2', 'n21'])
expect(t.nodes.n1.children).toEqual(['n11', 'n12'])
expect(t.nodes.n11.children).toEqual(['n111'])
expect(t.nodes.n111.depth).toBe(3)
expect(t.nodes.n21.parentId).toBe('n2')
expect(s.calls).not.toContain('n12')
expect(new Set(s.calls)).toEqual(new Set(['doc1', 'n1', 'n2', 'n11']))
})
it('同层分页读完', async () => {
const many: Node[] = Array.from({ length: 7 }, (_, i) => ({ id: `m${i}`, title: `节${i}` }))
const s = fakeServer('doc1', [{ id: 'root', title: '根', children: many }], 3)
const out = await walkDocTree(s.fetchLevel, 'doc1', () => true)
expect(out!.items.map(i => i.id)).toEqual(['root', ...many.map(n => n.id)])
})
it('并发不超过 4', async () => {
const wide: Node[] = Array.from({ length: 12 }, (_, i) => ({ id: `w${i}`, title: `宽${i}`, children: [{ id: `w${i}c`, title: '子' }] }))
const s = fakeServer('doc1', wide)
const out = await walkDocTree(s.fetchLevel, 'doc1', () => true)
expect(out!.items).toHaveLength(24)
expect(s.maxInflight()).toBeLessThanOrEqual(4)
expect(s.maxInflight()).toBeGreaterThan(1)
})
it('深度超过 12 截断并标记', async () => {
let chain: Node = { id: 'c20', title: '底' }
for (let i = 19; i >= 1; i--) chain = { id: `c${i}`, title: `层${i}`, children: [chain] }
const out = await walkDocTree(fakeServer('doc1', [chain]).fetchLevel, 'doc1', () => true)
expect(out!.items).toHaveLength(12)
expect(out!.truncated).toBe(true)
expect(DOC_TREE_LIMITS).toEqual({ maxDepth: 12, maxNodes: 2000, concurrency: 4, maxPages: 20 })
})
it('节点数超过上限截断并标记', async () => {
const wide: Node[] = Array.from({ length: 30 }, (_, i) => ({ id: `x${i}`, title: `节${i}` }))
const out = await walkDocTree(fakeServer('doc1', wide).fetchLevel, 'doc1', () => true, { ...DOC_TREE_LIMITS, maxNodes: 10 })
expect(out!.items).toHaveLength(10)
expect(out!.truncated).toBe(true)
})
it('守卫失效后停止继续请求并丢弃结果', async () => {
const s = fakeServer('doc1', roots)
let live = true
const out = await walkDocTree(async (parent, cursor) => { const r = await s.fetchLevel(parent, cursor); live = false; return r }, 'doc1', () => live)
expect(out).toBeNull()
expect(s.calls).toEqual(['doc1'])
})
it('环与重复 id 不重复展开', async () => {
const out = await walkDocTree(async parent => ({
items: parent === 'doc1'
? [{ kind: 'section', id: 'a', parentId: '', title: '甲', hasChildren: true }]
: [{ kind: 'section', id: 'a', parentId: 'a', title: '甲', hasChildren: true }, { kind: 'section', id: 'doc1', parentId: 'a', title: '根', hasChildren: true }],
}), 'doc1', () => true)
expect(out!.items.map(i => i.id)).toEqual(['a'])
})
it('模板实例中无标题的块(封面、签署表、字段)以块类型与模板块编号标识,不渲染空白节点', async () => {
const out = await walkDocTree(async parent => ({
items: parent === 'doc-33'
? [
{ kind: 'section', id: 'dn-cover', parentId: '', title: '', templateBlockId: 'pp.cover', hasChildren: false },
{ kind: 'table', id: 'dn-sign', parentId: '', title: ' ', templateBlockId: 'pp.identity-signoff.table', hasChildren: false },
{ kind: 'field', id: 'dn-x', parentId: '', title: '', hasChildren: false },
{ kind: 'section', id: 'dn-ctx', parentId: '', title: '项目内容、目标及意义', templateBlockId: 'pp.context', hasChildren: false },
]
: [],
}), 'doc-33', () => true)
expect(out!.items.map(i => i.title)).toEqual(['章节(pp.cover)', '表格(pp.identity-signoff.table)', '字段(dn-x)', '项目内容、目标及意义'])
})
})
describe('拒绝与失败转为可读文案(原始回执另行折叠)', () => {
it('非 2xx JSON 回执取 error 文本,不直出 JSON', () => {
const f = describeRenderFailure(new Error('400: {"receipt":{"state":"rejected","error":"规则未发布,不能用于正式交付"}}'))
expect(f.message).toBe('规则未发布,不能用于正式交付')
expect(f.raw).toContain('"receipt"')
})
it('登录失效 / 能力缺失 / 无法解析', () => {
expect(describeRenderFailure('401: x').message).toContain('登录已失效')
expect(describeRenderFailure('400: unknown or disabled command: rdm.render.profiles').message).toContain('未提供文档渲染能力')
expect(describeRenderFailure('500: {"weird":1}').message).toBe('操作失败,请稍后重试')
expect(describeRenderFailure('{"a":1}').message).toBe('操作失败,请稍后重试')
})
it('纯文本拒绝原样(截断)显示;OOXML 不显示', () => {
expect(describeRenderFailure(new Error('当前编制人不是你')).message).toBe('当前编制人不是你')
expect(describeRenderFailure('x'.repeat(500)).message.length).toBeLessThanOrEqual(241)
expect(describeRenderFailure('<w:document><w:body/></w:document>').message).toBe('操作失败,请稍后重试')
})
it('displayText 屏蔽原始文档格式', () => {
expect(displayText('<?xml version="1.0"?><w:p/>')).toBe('(原始文档格式内容不显示)')
expect(displayText(' ')).toBeUndefined()
expect(displayText(12)).toBeUndefined()
expect(displayText('正文')).toBe('正文')
})
it('RenderFailure 只在 Collapsible 内出现原始回执', () => {
const html = renderToStaticMarkup(createElement(RenderFailure, { error: '400: {"receipt":{"error":"拒绝"}}', id: 't' }))
expect(html).toContain('拒绝')
expect(html).toContain('原始回执')
expect(html).toContain('mk-col')
expect(html).not.toContain('<details')
})
})
describe('预览诊断', () => {
const raw = [
{ code: 'W1', severity: 'warning', message: '标题层级跳跃', nodeId: 's2' },
{ code: 'E1', severity: 'error', message: '缺少必填章节', nodeId: 's1', suggestion: '补写' },
{ code: 'I1', severity: 'info', message: '提示' },
{ severity: 'error' },
{ code: 'E2', severity: 'fatal', message: '模板缺失', nodeId: 's1' },
]
it('归一化严重级并丢弃无消息条目', () => {
const list = normalizeDiagnostics(raw)
expect(list).toHaveLength(4)
expect(list.find(d => d.code === 'E2')?.severity).toBe('error')
expect(normalizeDiagnostics('nope')).toEqual([])
})
it('按 错误 > 警告 > 提示 分组,省略空组', () => {
const groups = groupDiagnostics(normalizeDiagnostics(raw))
expect(groups.map(g => [g.severity, g.items.length])).toEqual([['error', 2], ['warning', 1], ['info', 1]])
expect(groups[0].label).toBe('错误')
expect(groupDiagnostics([])).toEqual([])
})
it('摘要与节点计数', () => {
expect(diagnosticsSummaryText({ errors: 2, warnings: 1 })).toBe('2 个错误 · 1 个警告')
expect(diagnosticsSummaryText({ errors: 0, warnings: 0 })).toBe('无错误或警告')
const counts = diagnosticCountsByNode(normalizeDiagnostics(raw))
expect(counts.s1).toEqual({ errors: 2, warnings: 0 })
expect(counts.s2).toEqual({ errors: 0, warnings: 1 })
})
it('诊断列表:可定位的可点击,未定位的标明且不可点;长列表分组折叠', () => {
const list = normalizeDiagnostics(Array.from({ length: 120 }, (_, i) => ({ code: 'W', severity: 'warning', message: `问题 ${i}`, nodeId: i % 2 ? 's1' : 'ghost' })))
const html = renderToStaticMarkup(createElement(DiagnosticsList, {
diagnostics: list, nodeTitle: (id: string) => (id === 's1' ? '第一章' : undefined), onSelectNode: () => {}, idPrefix: 't',
}))
expect(html).toContain('警告(120)')
expect(html).toContain('第一章')
expect(html).toContain('未定位节点')
expect(html).toContain('mk-col')
})
})
describe('诊断定位:treeNodeId 优先,nodeId 回退', () => {
// 渲染器 nodeId 是 canonical/映射节点 id;RDM 另给工作区树节点 id treeNodeId。
const known = new Set(['dn-cover', 's1'])
const titles: Record<string, string> = { 'dn-cover': '封面', s1: '第一章' }
const nodeTitle = (id: string) => (known.has(id) ? titles[id] : undefined)
const raw = [
{ code: 'required_content_missing', severity: 'warning', message: '缺少文档编号', nodeId: 'pp.cover.document-number.value', treeNodeId: 'dn-cover' },
{ code: 'W', severity: 'warning', message: '旧载荷:只有树节点 nodeId', nodeId: 's1' },
{ code: 'W', severity: 'error', message: 'treeNodeId 不在树中时回退 nodeId', nodeId: 's1', treeNodeId: 'dn-gone' },
{ code: 'W', severity: 'warning', message: '只有映射 id', nodeId: 'pp.revision.entry.c01' },
{ code: 'W', severity: 'warning', message: '两者都不在树中', nodeId: 'pp.x', treeNodeId: 'dn-gone' },
{ code: 'W', severity: 'info', message: '无节点' },
]
const list = normalizeDiagnostics(raw)
it('归一化保留 treeNodeId,空串与非字符串丢弃', () => {
expect(list[0]).toMatchObject({ nodeId: 'pp.cover.document-number.value', treeNodeId: 'dn-cover' })
expect(list[1].treeNodeId).toBeUndefined()
const odd = normalizeDiagnostics([{ message: 'm', treeNodeId: '' }, { message: 'm', treeNodeId: 7 }])
expect(odd.every(d => !('treeNodeId' in d))).toBe(true)
})
it('locateDiagnostic:树节点 id 优先,缺省或未知时回退 nodeId,都未知则未定位', () => {
expect(list.map(d => locateDiagnostic(d, nodeTitle))).toEqual([
{ id: 'dn-cover', title: '封面' },
{ id: 's1', title: '第一章' },
{ id: 's1', title: '第一章' },
undefined,
undefined,
undefined,
])
})
it('节点计数按定位到的树节点归档;不在树中的不计入任何节点', () => {
const counts = diagnosticCountsByNode(list, id => known.has(id))
expect(counts).toEqual({ 'dn-cover': { errors: 0, warnings: 1 }, s1: { errors: 1, warnings: 1 } })
})
it('诊断列表:两种 id 形式都可点击定位,未定位的明确标出', () => {
// 只有首组默认展开(SSR 不渲染折叠组内容),因此只取警告组。
const warnings = list.filter(d => d.severity === 'warning')
const html = renderToStaticMarkup(createElement(DiagnosticsList, { diagnostics: warnings, nodeTitle, onSelectNode: () => {}, idPrefix: 'loc' }))
expect(html).toContain('title="封面"')
expect(html).toContain('title="第一章"')
expect(html.match(/<button[^>]*class="drw-diag-node"/g)).toHaveLength(2)
expect(html.match(/drw-diag-node unlocated/g)).toHaveLength(2)
expect(html).not.toContain('pp.cover.document-number.value')
})
})
describe('操作编号:每次动作新编号,重试复用', () => {
it('新动作铸新 id;同类重试复用;不同类不复用', () => {
let n = 0
const mint = () => `op_${++n}`
const a = nextOperation('preview', mint, null)
expect(a).toEqual({ kind: 'preview', operationId: 'op_1' })
expect(nextOperation('preview', mint, a, true)).toBe(a)
expect(nextOperation('preview', mint, a).operationId).toBe('op_2')
expect(nextOperation('submit', mint, a, true).operationId).toBe('op_3')
})
it('默认铸造器生成非空唯一编号', () => {
const x = nextOperation('preview', undefined, null)
const y = nextOperation('preview', undefined, null)
expect(x.operationId).toMatch(/^op_/)
expect(x.operationId).not.toBe(y.operationId)
})
})
describe('请求守卫:过期响应丢弃', () => {
it('更新的请求使旧票据失效', () => {
const g = createRequestGuard()
const scope = renderScope(1, 'p', 'd')
const t1 = g.begin(scope)
const t2 = g.begin(scope)
expect(g.current(t1, scope)).toBe(false)
expect(g.current(t2, scope)).toBe(true)
})
it('项目/文档/身份切换使票据失效;cancel 使全部失效', () => {
const g = createRequestGuard()
const t = g.begin(renderScope(1, 'p', 'd'))
expect(g.current(t, renderScope(1, 'p', 'other'))).toBe(false)
expect(g.current(t, renderScope(2, 'p', 'd'))).toBe(false)
expect(g.current(t, renderScope(1, 'q', 'd'))).toBe(false)
expect(g.current(t, renderScope(1, 'p', 'd'))).toBe(true)
g.cancel()
expect(g.current(t, renderScope(1, 'p', 'd'))).toBe(false)
})
it('作用域键不因分隔符拼接而碰撞', () => {
expect(renderScope(1, 'a|b', 'c')).not.toBe(renderScope(1, 'a', 'b|c'))
})
})
const doc = { id: 'doc1', title: '设计说明书' }
const items = [
{ kind: 'section', id: 's1', title: '1 范围' },
{ kind: 'section', id: 's11', title: '1.1 目的' },
{ kind: 'section', id: 's12', title: '1.2 适用', state: 'baselined' },
{ kind: 'section', id: 's2', title: '2 设计' },
{ kind: 'section', id: 's21', title: '细节', level: 2 },
{ kind: 'section', id: 's1', title: '重复' },
]
function tree(): DocTree { return buildDocTree(doc, items) }
describe('文档树:构建、过滤、布局与键盘导航', () => {
it('按编号或 level 推导层级,忽略重复 id', () => {
const t = tree()
expect(t.rootId).toBe('doc1')
expect(t.order).toEqual(['doc1', 's1', 's11', 's12', 's2', 's21'])
expect(t.nodes.doc1.children).toEqual(['s1', 's2'])
expect(t.nodes.s1.children).toEqual(['s11', 's12'])
expect(t.nodes.s21.parentId).toBe('s2')
expect(t.nodes.s12.depth).toBe(2)
expect(t.nodes.s1.title).toBe('1 范围')
})
it('显式 parentId 优先', () => {
const t = buildDocTree(doc, [{ kind: 'section', id: 'a', title: '甲' }, { kind: 'section', id: 'b', title: '乙', parentId: 'a' }])
expect(t.nodes.a.children).toEqual(['b'])
})
it('过滤保留命中节点及其祖先;问题/审阅模式按标记过滤', () => {
const t = tree()
const marks = { issues: new Set(['s21']), review: new Set(['s11']) }
expect([...visibleNodeIds(t, { text: '适用', mode: 'all' }, marks)].sort()).toEqual(['doc1', 's1', 's12'])
expect([...visibleNodeIds(t, { text: '', mode: 'issues' }, marks)].sort()).toEqual(['doc1', 's2', 's21'])
expect([...visibleNodeIds(t, { text: '', mode: 'review' }, marks)].sort()).toEqual(['doc1', 's1', 's11'])
expect(visibleNodeIds(t, { text: '', mode: 'all' }, marks).size).toBe(6)
expect(visibleOrder(t, new Set(['doc1', 's2', 's21']))).toEqual(['doc1', 's2', 's21'])
})
it('思维导图布局:父节点居中于子节点,边连接父子,尺寸覆盖全部节点', () => {
const t = tree()
const layout = layoutMindMap(t, visibleNodeIds(t, { text: '', mode: 'all' }, { issues: new Set(), review: new Set() }))
const at = (id: string) => layout.nodes.find(n => n.id === id)!
expect(at('s11').x).toBeGreaterThan(at('s1').x)
expect(at('s1').y).toBe((at('s11').y + at('s12').y) / 2)
expect(layout.edges).toHaveLength(5)
expect(layout.edges[0].d).toMatch(/^M[\d.]+ [\d.]+ C/)
for (const n of layout.nodes) {
expect(n.x + 180).toBeLessThanOrEqual(layout.width)
expect(n.y + 32).toBeLessThanOrEqual(layout.height)
}
})
it('键盘:上下按可见顺序、左到父、右到首子、Home/End,未处理返回 null', () => {
const t = tree()
const order = t.order
expect(moveSelection(t, order, 's1', 'ArrowDown')).toBe('s11')
expect(moveSelection(t, order, 's1', 'ArrowUp')).toBe('doc1')
expect(moveSelection(t, order, 's12', 'ArrowLeft')).toBe('s1')
expect(moveSelection(t, order, 's1', 'ArrowRight')).toBe('s11')
expect(moveSelection(t, order, 's1', 'End')).toBe('s21')
expect(moveSelection(t, order, 's21', 'Home')).toBe('doc1')
expect(moveSelection(t, order, null, 'ArrowDown')).toBe('doc1')
expect(moveSelection(t, order, 's21', 'ArrowDown')).toBe('s21')
expect(moveSelection(t, order, 's1', 'x')).toBeNull()
expect(moveSelection(t, ['doc1', 's2'], 's2', 'ArrowRight')).toBe('s2')
})
it('锚点 id 只含安全字符', () => {
expect(nodeAnchorId('sec/1 a')).toBe('drw-node-sec_1_a')
})
})
describe('节点事实:只显示服务端给出的字段', () => {
it('空详情不产出任何事实', () => {
const f = nodeFacts({ kind: 'section', id: 's1', relations: [], recentEvents: [] })
expect(hasFacts(f)).toBe(false)
expect(f).toEqual({ sources: [], impacts: [] })
expect(hasFacts(nodeFacts(null))).toBe(false)
})
it('来源/影响/置信/冲突/作者时间/变更前后', () => {
const f = nodeFacts({
kind: 'section', id: 's1', confidence: 0.82, conflict: '与需求 R3 表述冲突',
versions: [{ seq: 1, actor: 'amy', at: '2026-10-01T00:00:00Z' }, { seq: 3, actor: 'bob', at: '2026-10-03T00:00:00Z' }],
change: { before: '旧文', after: '新文', impact: '影响验证计划' },
relations: [
{ layer: 'trace', state: 'active', from: { kind: 'req', id: 'r1' }, to: { kind: 'section', id: 's1' }, conf: 0.9 },
{ layer: 'trace', state: 'active', from: { kind: 'note', id: 'secret', disclosure: 'private' }, to: { kind: 'section', id: 's1' } },
{ layer: 'trace', state: 'active', from: { kind: 'section', id: 's1' }, to: { kind: 'test', id: 't1' } },
],
})
expect(f.sources).toEqual(['req:r1(置信 90%)'])
expect(f.impacts).toEqual(['test:t1'])
expect(f.confidence).toBe('82%')
expect(f.conflict).toBe('与需求 R3 表述冲突')
expect(f.author).toBe('bob')
expect(f.at).toBe('2026-10-03T00:00:00Z')
expect(f.change).toEqual({ before: '旧文', after: '新文', impact: '影响验证计划' })
expect(hasFacts(f)).toBe(true)
})
it('无版本时退到正式版作者;OOXML 变更不显示原文', () => {
const f = nodeFacts({ id: 's1', formal: { actor: 'carol', at: '2026-09-01T00:00:00Z' }, change: { after: '<w:p>x</w:p>' } })
expect(f.author).toBe('carol')
expect(f.change?.after).toBe('(原始文档格式内容不显示)')
expect(f.change?.before).toBeUndefined()
})
})
describe('审阅:批注、修订与载荷', () => {
const view = normalizeReviewView({
annotations: [
{ id: 'a1', nodeId: 's1', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z', text: '请补充', replies: [{ id: 'a1r', nodeId: 's1', author: { id: 'u2', displayName: '李四' }, createdAt: '2026-10-02T00:00:00Z', text: '已补', resolved: false, orphaned: false }], resolved: false, orphaned: false },
{ id: 'a2', nodeId: 's1', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z', text: '原位置已删除', replies: [], resolved: false, orphaned: true },
{ id: 'a3', nodeId: 'gone', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z', text: '找不到节点', replies: [], resolved: true, orphaned: false },
{ id: 'bad' },
],
revisions: [{ id: 'v1', nodeId: 's1', kind: 'replace', before: '旧', after: '新', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z' }, { id: 'v2', kind: 'explode' }],
decisions: [{ actor: { id: 'u3', displayName: '王五' }, duty: 'reviewer', decision: 'approve', at: '2026-10-03T00:00:00Z' }],
})
it('归一化丢弃不完整条目', () => {
expect(view.annotations.map(a => a.id)).toEqual(['a1', 'a2', 'a3'])
expect(view.annotations[0].replies).toHaveLength(1)
expect(view.revisions.map(r => r.id)).toEqual(['v1'])
expect(view.decisions).toHaveLength(1)
expect(normalizeReviewView(undefined)).toEqual({ annotations: [], revisions: [], decisions: [] })
})
it('孤立或节点不存在的批注进入「无法定位」,绝不挂到其他节点', () => {
const { byNode, orphaned } = partitionAnnotations(view.annotations, new Set(['s1', 's2']))
expect(byNode.get('s1')?.map(a => a.id)).toEqual(['a1'])
expect(orphaned.map(a => a.id)).toEqual(['a2', 'a3'])
})
it('批注分组渲染:回复缩进、无法定位单独成组', () => {
const { byNode, orphaned } = partitionAnnotations(view.annotations, new Set(['s1']))
const html = renderToStaticMarkup(createElement(AnnotationGroups, { byNode, orphaned, nodeTitle: () => '第一章', onSelectNode: () => {} }))
expect(html).toContain('无法定位(2)')
expect(html).toContain('已补')
expect(html).toContain('李四')
expect(html.indexOf('第一章')).toBeLessThan(html.indexOf('无法定位'))
})
it('载荷只含业务字段,身份与时间由服务端填写', () => {
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', text: ' 好 ', operationId: 'op_1' }))
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '好', operationId: 'op_1' })
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', text: '回', operationId: 'op_2', replyTo: 'a1' }))
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '回', operationId: 'op_2', replyTo: 'a1' })
expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', kind: 'insert', after: '新增', operationId: 'op_3' }))
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'insert', after: '新增', operationId: 'op_3' })
expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', kind: 'replace', before: 'a', after: 'b', operationId: 'op_4' }))
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'replace', before: 'a', after: 'b', operationId: 'op_4' })
})
it('文本校验', () => {
expect(validateReviewText(' ')).toBe('请输入内容')
expect(validateReviewText('x'.repeat(4001))).toContain('4000')
expect(validateReviewText('好')).toBeUndefined()
})
})
describe('审阅按工作区树节点分组与定位(treeNodeId 优先,nodeId 回退)', () => {
// RDM 契约:nodeId 恒为渲染器规范节点(pp.…),treeNodeId 为审阅人所选树节点(dn-/sec-/legacy-)。
const wsTree = buildDocTree({ id: 'doc1', title: '文档' }, [
{ kind: 'section', id: 'dn-head', title: '项目内容、目标及意义' },
{ kind: 'field', id: 'dn-leaf', title: '目标', parentId: 'dn-head' },
{ kind: 'section', id: 'sec-2', title: '第二章' },
])
const known = new Set(wsTree.order)
const person = { id: 'u1', displayName: '审核人' }
const view = normalizeReviewView({
annotations: [
{ id: 'a1', nodeId: 'pp.goal.value', treeNodeId: 'dn-head', author: person, createdAt: '', text: '按所选标题', replies: [] },
{ id: 'a2', nodeId: 'pp.goal.leaf', treeNodeId: 'dn-head', author: person, createdAt: '', text: '同一树节点', replies: [] },
{ id: 'a3', nodeId: 'sec-2', author: person, createdAt: '', text: '旧条目只有 nodeId', replies: [] },
{ id: 'a4', nodeId: 'dn-leaf', treeNodeId: 'dn-gone', author: person, createdAt: '', text: 'treeNodeId 不在树中回退 nodeId', replies: [] },
{ id: 'a5', nodeId: 'pp.x', treeNodeId: 'dn-gone', author: person, createdAt: '', text: '都不在树中', replies: [] },
{ id: 'a6', nodeId: 'pp.y', treeNodeId: 'dn-head', author: person, createdAt: '', text: '服务端标孤立', replies: [], orphaned: true },
{ id: 'a7', nodeId: 'pp.z', treeNodeId: '', author: person, createdAt: '', text: '空 treeNodeId 不保留', replies: [] },
],
revisions: [
{ id: 'v1', nodeId: 'pp.goal.value', treeNodeId: 'dn-leaf', kind: 'replace', before: '旧', after: '新', author: person, createdAt: '' },
{ id: 'v2', nodeId: 'pp.unknown', kind: 'insert', after: '无处可挂', author: person, createdAt: '' },
],
})
it('归一化保留 treeNodeId,nodeId 仍为规范节点', () => {
expect(view.annotations[0]).toMatchObject({ nodeId: 'pp.goal.value', treeNodeId: 'dn-head' })
expect(view.annotations.find(a => a.id === 'a7')).not.toHaveProperty('treeNodeId')
expect(view.revisions[0]).toMatchObject({ nodeId: 'pp.goal.value', treeNodeId: 'dn-leaf' })
})
it('批注按 treeNodeId 分组,缺省或不在树中回退 nodeId,都不在或孤立进入无法定位', () => {
const { byNode, orphaned } = partitionAnnotations(view.annotations, known)
expect([...byNode.keys()]).toEqual(['dn-head', 'sec-2', 'dn-leaf'])
expect(byNode.get('dn-head')?.map(a => a.id)).toEqual(['a1', 'a2'])
expect(byNode.get('dn-leaf')?.map(a => a.id)).toEqual(['a4'])
expect(orphaned.map(a => a.id)).toEqual(['a5', 'a6', 'a7'])
})
it('修订同规则分组', () => {
const { byNode, orphaned } = partitionRevisions(view.revisions, known)
expect([...byNode.keys()]).toEqual(['dn-leaf'])
expect(orphaned.map(r => r.id)).toEqual(['v2'])
})
it('分组标题是树节点名并带定位目标(点击由工作区滚动到该节点)', () => {
const nodeTitle = (id: string) => wsTree.nodes[id]?.title
const anno = partitionAnnotations(view.annotations, known)
const html = renderToStaticMarkup(createElement(AnnotationGroups, { ...anno, nodeTitle, onSelectNode: () => {}, onReply: () => {} }))
expect(html).toContain('data-locate="dn-head"')
expect(html).toContain('项目内容、目标及意义')
expect(html).not.toContain('pp.goal.value')
const rev = partitionRevisions(view.revisions, known)
const revHtml = renderToStaticMarkup(createElement(RevisionGroups, { ...rev, total: view.revisions.length, nodeTitle, onSelectNode: () => {} }))
expect(revHtml).toContain('data-locate="dn-leaf"')
expect(revHtml).toContain('修订(2)')
expect(revHtml).toContain('无法定位(1)')
})
it('从树上批注/修订发送所选树节点 id;文档根不是审阅目标', () => {
const target = reviewTargetNode(wsTree, 'dn-leaf')
expect(target?.id).toBe('dn-leaf')
expect(reviewTargetNode(wsTree, 'doc1')).toBeUndefined()
expect(reviewTargetNode(wsTree, null)).toBeUndefined()
expect(reviewTargetNode(null, 'dn-leaf')).toBeUndefined()
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: target!.id, text: '批', operationId: 'op' }).nodeId).toBe('dn-leaf')
expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 'sec-2', kind: 'delete', before: '删', operationId: 'op' }).nodeId).toBe('sec-2')
})
it('locateNodeId 与诊断定位同规则', () => {
expect(locateNodeId({ treeNodeId: 'dn-head', nodeId: 'sec-2' }, id => known.has(id))).toBe('dn-head')
expect(locateNodeId({ treeNodeId: 'dn-gone', nodeId: 'sec-2' }, id => known.has(id))).toBe('sec-2')
expect(locateNodeId({ nodeId: 'pp.x' }, id => known.has(id))).toBeUndefined()
})
})
describe('渲染链条(rdm.inspect.relations layer=render)', () => {
const wsTree = buildDocTree({ id: 'doc1', title: '文档' }, [
{ kind: 'section', id: 'dn-head', title: '项目内容、目标及意义' },
{ kind: 'section', id: 'sec-2', title: '第二章' },
])
const nodeTitle = (id: string) => wsTree.nodes[id]?.title
const sub = { kind: 'render-submission', id: 'sub-1' }
const full = normalizeRenderTrace({
items: [
{ layer: 'general', type: 'derives', from: { kind: 'section', id: 'a' }, to: { kind: 'section', id: 'b' } },
{ layer: 'render', type: 'selected-profile', from: { kind: 'doc', id: 'doc1' }, to: { kind: 'render-profile', id: 'company.dhf@1', source: 'template' } },
{ layer: 'render', type: 'annotated', from: sub, to: { kind: 'review-annotation', id: 'ann-1', nodeId: 'pp.goal.value', treeNodeId: 'dn-head', displayName: '审核人', at: '2026-10-17T00:00:00Z', text: '请补充' } },
{ layer: 'render', type: 'anchored-to', from: { kind: 'review-annotation', id: 'ann-1' }, to: { kind: 'document-node', id: 'dn-head' } },
{ layer: 'render', type: 'anchored-to', from: { kind: 'review-annotation', id: 'ann-2' }, to: { kind: 'document-node', id: 'dn-gone' } },
{ layer: 'render', type: 'revised', from: sub, to: { kind: 'review-revision', id: 'rev-1', nodeId: 'sec-2', revisionKind: 'replace' } },
{ layer: 'render', type: 'restricted-basis', from: { kind: 'render-delivery', id: 'd1' }, to: { kind: 'restricted-source', id: 'restricted' } },
{ layer: 'render', type: 'future-edge', from: { kind: 'render-delivery', id: 'd1' }, to: { kind: 'x', id: 'y' } },
{ layer: 'render', type: 'delivered', from: { kind: 'doc', id: 'doc1' } },
],
truncated: true,
})
it('只取 layer=render 且端点完整的条目,未知类型保留原名', () => {
expect(full.entries.map(e => e.rawType)).toEqual(['selected-profile', 'annotated', 'anchored-to', 'anchored-to', 'revised', 'restricted-basis', 'future-edge'])
expect(full.entries[6].type).toBe('other')
expect(traceTypeLabel(full.entries[6])).toBe('future-edge')
expect(full.truncated).toBe(true)
expect(full.masked).toBe(false)
expect(normalizeRenderTrace(null)).toEqual({ entries: [], masked: false, truncated: false })
})
it('anchored-to 的目标是树节点 id(不回退规范 id);批注/修订 treeNodeId 优先、nodeId 回退', () => {
const [, annotated, anchored, anchoredGone, revised, restricted] = full.entries
expect(traceAnchor(anchored)).toMatchObject({ subject: { kind: 'review-annotation', id: 'ann-1' }, target: { treeNodeId: 'dn-head' } })
expect(traceAnchor(anchored).target).not.toHaveProperty('nodeId')
expect(traceAnchor(anchoredGone).target).toEqual({ treeNodeId: 'dn-gone' })
expect(traceAnchor(annotated).target).toEqual({ treeNodeId: 'dn-head', nodeId: 'pp.goal.value' })
expect(traceAnchor(revised).target).toEqual({ treeNodeId: undefined, nodeId: 'sec-2' })
expect(traceAnchor(restricted).target).toBeUndefined()
expect(traceEndpointLabel(restricted.to)).toBe('存在受限依据')
})
it('渲染:已定位的树节点可点击定位,未知树节点显示未定位', () => {
const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: full, nodeTitle, onSelectNode: () => {} }))
expect(html).toContain('批注锚定')
expect(html.match(/data-locate="dn-head"/g)).toHaveLength(2)
expect(html).toContain('data-locate="sec-2"')
expect(html).toContain('未定位节点')
expect(html).not.toContain('dn-gone"')
expect(html).toContain('模板规则')
expect(html).toContain('替换')
expect(html).toContain('请补充')
expect(html).toContain('已截断')
expect(html).not.toContain('内容已遮蔽')
})
it('遮蔽条目只显示元数据:标「内容已遮蔽」,正文字段即使误发也不读取', () => {
const masked = normalizeRenderTrace({
items: [
{ layer: 'render', type: 'annotated', contentMasked: true, from: sub, to: { kind: 'review-annotation', id: 'ann-1', treeNodeId: 'dn-head', displayName: '审核人', text: 'SECRET-TEXT', excerpt: 'SECRET-EXCERPT' } },
{ layer: 'render', type: 'revised', contentMasked: true, from: sub, to: { kind: 'review-revision', id: 'rev-1', treeNodeId: 'sec-2', revisionKind: 'replace', before: 'SECRET-BEFORE', after: 'SECRET-AFTER' } },
{ layer: 'render', type: 'anchored-to', contentMasked: true, from: { kind: 'review-annotation', id: 'ann-1' }, to: { kind: 'document-node', id: 'dn-head' } },
],
})
expect(masked.masked).toBe(true)
expect(masked.entries.every(e => e.masked && e.body === undefined)).toBe(true)
const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: masked, nodeTitle, onSelectNode: () => {} }))
expect(html.match(/内容已遮蔽/g)).toHaveLength(3)
expect(html).toContain('正文、批注与修订文字已遮蔽')
expect(html).toContain('审核人')
expect(html).toContain('data-locate="dn-head"')
expect(html).not.toContain('SECRET')
})
it('空链条有明确空态', () => {
const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: normalizeRenderTrace({ items: [] }), nodeTitle, onSelectNode: () => {} }))
expect(html).toContain('暂无渲染链条记录')
})
})
describe('交付记录与下载', () => {
const page = normalizeDeliveries({
items: [
{ deliveryId: 'd2', mode: 'approved', submissionId: 'sub1', profileId: 'doc', profileVersion: '2', createdAt: '2026-10-03T00:00:00Z', requestedBy: { id: 'u1', displayName: '张三' }, status: 'succeeded', availableKinds: ['manifest', 'docx', 'review-docx', 'markdown', 'evil'], diagnosticsSummary: { errors: 0, warnings: 1 } },
{ deliveryId: 'd1', mode: 'preview', profileId: 'doc', profileVersion: '2', createdAt: '2026-10-01T00:00:00Z', status: 'succeeded', availableKinds: ['docx', 'diagnostics'] },
{ mode: 'preview' },
],
nextCursor: 'c2',
})
it('归一化与分页合并去重', () => {
expect(page.items.map(d => d.deliveryId)).toEqual(['d2', 'd1'])
expect(page.nextCursor).toBe('c2')
expect(page.items[1].diagnosticsSummary).toEqual({ errors: 0, warnings: 0 })
const merged = mergeDeliveries(page.items, [{ ...page.items[1] }, { ...page.items[0], deliveryId: 'd0' }])
expect(merged.map(d => d.deliveryId)).toEqual(['d2', 'd1', 'd0'])
expect(latestSubmissionId(page.items)).toBe('sub1')
expect(latestSubmissionId([page.items[1]])).toBeUndefined()
})
it('下载选项按固定顺序,批准模式标注批准稿,忽略未知类型', () => {
expect(downloadOptions(page.items[0]).map(o => [o.kind, o.label])).toEqual([
['markdown', 'Markdown'], ['docx', '批准稿 DOCX'], ['review-docx', '审阅留痕 DOCX'], ['manifest', '清单(manifest)'],
])
expect(downloadOptions(page.items[1]).map(o => o.label)).toEqual(['预览 DOCX', '诊断报告'])
})
it('下载载荷接受 contentType/filename 或 mime/fileName;缺数据报错', () => {
expect(normalizeDownload({ filename: 'a.md', contentType: 'text/markdown', dataBase64: 'QQ==', sha256: 'x' }))
.toEqual({ fileName: 'a.md', contentType: 'text/markdown', dataBase64: 'QQ==', sha256: 'x' })
expect(normalizeDownload({ fileName: 'b.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', dataBase64: 'QQ==', sha256: 'y' }).contentType)
.toContain('wordprocessingml')
expect(() => normalizeDownload({ fileName: 'c', sha256: 'z' })).toThrow('下载内容不完整')
expect(() => normalizeDownload({ dataBase64: 'QQ==', sha256: 'z' })).toThrow('下载内容不完整')
expect(normalizeDownload({ fileName: 'c', dataBase64: 'QQ==', sha256: 'z' }).contentType).toBe('application/octet-stream')
})
it('base64 解码', () => {
expect([...decodeBase64('QUJD')]).toEqual([65, 66, 67])
})
it('长文件名中间截断并保留扩展名', () => {
const long = '研发项目设计说明书-第三轮审阅留痕-包含全部批注与修订记录-最终版本.docx'
const short = truncateMiddle(long, 24)
expect(short.length).toBeLessThanOrEqual(24)
expect(short.endsWith('.docx')).toBe(true)
expect(short).toContain('…')
expect(truncateMiddle('a.md', 24)).toBe('a.md')
})
it('下载按钮:截断名 + title 全名', () => {
const html = renderToStaticMarkup(createElement(DownloadButtons, {
delivery: { ...page.items[0], deliveryId: 'd2' }, busyKind: null, onDownload: () => {},
}))
expect(html).toContain('批准稿 DOCX')
expect(html).toContain('审阅留痕 DOCX')
expect(html).not.toContain('evil')
})
it('时间格式化:非法时间为空串', () => {
expect(formatRenderTime('not-a-date')).toBe('')
expect(formatRenderTime('2026-10-01T00:00:00Z')).not.toBe('')
})
})
describe('规则列表渲染', () => {
it('显示来源、版本、状态与不可用原因;受控说明可见', () => {
const html = renderToStaticMarkup(createElement(ProfileOptionList, {
profiles: profiles(), selectedKey: 'document:doc@2', onSelect: () => {},
}))
for (const s of ['文档规则', '项目默认规则', '模板规则', '公司默认规则', 'v2', '已发布', '草稿', '资产校验未通过', '不会回退到项目或公司默认规则']) {
expect(html).toContain(s)
}
expect(html).toContain('disabled')
})
it('refusal 以可读失败显示', () => {
const html = renderToStaticMarkup(createElement(ProfileOptionList, {
profiles: profiles({ options: [], refusal: '{"weird":true}' }), selectedKey: '', onSelect: () => {},
}))
expect(html).toContain('操作失败,请稍后重试')
expect(html).toContain('原始回执')
})
})
+11
View File
@@ -69,5 +69,16 @@ describe('文档职责草稿', () => {
docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'carol',
expectedDutyVersion: 3, confirmInvalidation: true,
})
expect(one.docs[0]).not.toHaveProperty('accountIds')
})
it('候选检索选中的 userId 随预览/批量保存以 accountIds{owner:userId} 携带,只含本文档职责人', () => {
const known = { bob: 'u-bob', erin: 'u-erin' }
const out = savePayload([
doc({ dirty: true }),
doc({ docId: 'd2', dirty: true, author: 'dave', reviewers: ['frank'], approver: 'gina' }),
], false, known)
expect(out.docs[0].accountIds).toEqual({ bob: 'u-bob' })
expect(out.docs[1]).not.toHaveProperty('accountIds')
})
})
+33
View File
@@ -0,0 +1,33 @@
// eventTime 取事件上的服务端时间;后端 0 值占位(0001-01-01T00:00:00Z)
// 不能让状态栏把「已工作」读成几百年的耗时——非正 epoch 一律回落到墙上时钟。
import { describe, expect, it } from 'vitest'
import { eventTime } from '../src/store'
describe('eventTime', () => {
it('returns now when the timestamp is missing', () => {
const now = 1_700_000_000_000
expect(eventTime({}, now)).toBe(now)
expect(eventTime({ ts: '' }, now)).toBe(now)
expect(eventTime({ ts: undefined }, now)).toBe(now)
})
it('returns now for unparseable timestamps', () => {
const now = 1_700_000_000_000
expect(eventTime({ ts: 'not-a-date' }, now)).toBe(now)
expect(eventTime({ ts: '2026-13-40T99:99:99Z' }, now)).toBe(now)
})
it('returns now when the parsed time is not a positive epoch (zero-value placeholder)', () => {
// 0001-01-01T00:00:00Z is the Go/Unix zero time;过去不能解释为有效事件时间。
const now = 1_700_000_000_000
expect(eventTime({ ts: '0001-01-01T00:00:00Z' }, now)).toBe(now)
// Date.parse('1970-01-01T00:00:00Z') === 0,应被一并回落,否则状态栏会显示从 epoch 至今。
expect(eventTime({ ts: '1970-01-01T00:00:00Z' }, now)).toBe(now)
})
it('returns the parsed epoch for a real, parseable timestamp', () => {
const now = 1_700_000_000_000
expect(eventTime({ ts: '2024-01-02T03:04:05Z' }, now)).toBe(Date.parse('2024-01-02T03:04:05Z'))
expect(eventTime({ ts: '2026-10-12T10:00:00Z' }, now)).toBe(Date.parse('2026-10-12T10:00:00Z'))
})
})
+53 -13
View File
@@ -1,7 +1,17 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
import {
ForcedPasswordModeSection,
isForcedPasswordMode,
requiresCurrentPassword,
submitForcedPassword,
} from '../src/components/passwordChangeMode'
import { humanChangePasswordError, humanLoginError } from '../src/components/loginErrors'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
@@ -31,19 +41,49 @@ describe('humanChangePasswordError(改密门)', () => {
})
})
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
})
it('未知错误剥状态码前缀透传', () => {
expect(humanRedeemError('400: bad')).toBe('bad')
})
})
describe('humanChangePasswordError(登录恢复新增文案)', () => {
it('并发凭据变化给出重新登录指引', () => {
expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录')
})
})
describe('one_time_password 强制改密流程', () => {
afterEach(() => {
auth.clear()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('识别 one_time_password 模式且无需当前密码字段', () => {
expect(isForcedPasswordMode('one_time_password')).toBe(true)
expect(requiresCurrentPassword('one_time_password')).toBe(false)
const html = renderToStaticMarkup(createElement(ForcedPasswordModeSection, {
mode: 'one_time_password',
current: '',
onCurrentChange: () => undefined,
}))
expect(html).not.toContain('forced-current')
expect(html).toContain('无需输入当前密码')
})
it('提交 one_time_password 改密时走 /api/auth/password', async () => {
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({
owner: 'alice',
name: 'Alice',
isAdmin: false,
mustChangePassword: false,
reloginRequired: true,
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
vi.stubGlobal('fetch', fetchSpy)
const changeSpy = vi.spyOn(api, 'changePassword')
await submitForcedPassword('one_time_password', 'should-be-ignored', 'new-password-123')
expect(changeSpy).toHaveBeenCalledWith('', 'new-password-123')
expect(fetchSpy).toHaveBeenCalledWith('/api/auth/password', expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current: '', next: 'new-password-123' }),
}))
})
})
+40
View File
@@ -0,0 +1,40 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
import { GeneratedCredentialPanel, oneTimePasswordCredential } from '../src/components/oneTimePasswordUi'
describe('管理员一次性密码发放', () => {
afterEach(() => {
auth.clear()
vi.unstubAllGlobals()
})
it('调用精确 one-time-password 端点并使用 POST', async () => {
const response = { owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true }
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify(response), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
vi.stubGlobal('fetch', fetchSpy)
const out = await api.adminIssueOneTimePassword('alice')
expect(fetchSpy).toHaveBeenCalledTimes(1)
// 服务端严格解码 JSON 对象体:无体无 Content-Type 会被拒为 400 EOF。
expect(fetchSpy).toHaveBeenCalledWith('/api/admin/users/alice/one-time-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
})
expect(out).toEqual(response)
})
it('展示后端返回的 oneTimePassword', () => {
const credential = oneTimePasswordCredential({ owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true })
const html = renderToStaticMarkup(createElement(GeneratedCredentialPanel, {
credential,
onClose: () => undefined,
}))
expect(html).toContain('otp-0001')
expect(html).toContain('一次性登录密码仅显示一次')
})
})
+64
View File
@@ -0,0 +1,64 @@
// ps-frontend-no-project-surfaces-r2:openConversation 在 state.currentProjectId 为空时
// 不再触发 /api/projects/<id>/surfaces 请求(避免被移除项目成员打开历史会话时的 404 噪音)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { store } from '../src/store'
import type { Conversation, Message, SurfaceMeta } from '../src/contracts'
function detail(id: string) {
return {
conversation: { id, projectId: '', title: id, createdAt: '', updatedAt: '' } as Conversation,
messages: [] as Message[], runs: [], activeHitl: [], lastSeq: 0, resumeSeq: 0,
}
}
class FakeEventSource {
onopen = null
onerror = null
onmessage = null
addEventListener() {}
close() {}
}
let projectSurfacesSpy: ReturnType<typeof vi.spyOn>
beforeEach(async () => {
vi.stubGlobal('EventSource', FakeEventSource)
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id))
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
vi.spyOn(api, 'listCards').mockResolvedValue([])
vi.spyOn(api, 'listTasks').mockResolvedValue([])
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
projectSurfacesSpy = vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
vi.spyOn(api, 'listProjectCards').mockResolvedValue([])
// 还原至 currentProjectId = null 的干净投影,避免别的套件残留影响本次断言。
store.resetIdentity()
})
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('openConversation projectSurfaces gating', () => {
it('skips the /api/projects/.../surfaces request and keeps projectSurfaces empty when no project is current', async () => {
expect(store.getState().currentProjectId).toBeNull()
await store.openConversation('cnv_no_project')
// 让微任务(包含 surfaceList/Stream 等链)落定,但不改变当前结论。
await new Promise(r => setTimeout(r, 0))
expect(projectSurfacesSpy).not.toHaveBeenCalled()
expect(store.getState().projectSurfaces).toEqual([])
expect(store.getState().currentProjectId).toBeNull()
})
it('still requests /api/projects/<id>/surfaces when a project is current', async () => {
const metas: SurfaceMeta[] = [{ surfaceId: 'surf_1', title: '页面一', pluginId: 'plg_1', revision: 1, updatedAt: '2026-10-12T00:00:00Z' }]
projectSurfacesSpy.mockResolvedValueOnce(metas)
await store.openProject('prj_with_surfaces')
expect(store.getState().currentProjectId).toBe('prj_with_surfaces')
projectSurfacesSpy.mockClear()
await store.openConversation('cnv_with_project')
expect(projectSurfacesSpy).toHaveBeenCalledTimes(1)
expect(projectSurfacesSpy.mock.calls[0][0]).toBe('prj_with_surfaces')
// 微任务处理完 .then 后,projectSurfaces 投影被填进服务端的元数据列表。
await new Promise(r => setTimeout(r, 0))
expect(store.getState().projectSurfaces).toEqual(metas)
})
})
+15
View File
@@ -59,6 +59,21 @@ describe('命令载荷构造(身份只经认证会话——载荷不含任何
expect(memberAddPayload('p1', 'carol', 'owner')).toEqual({ projectId: 'p1', member: 'carol', role: 'owner' })
expect(memberRemovePayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol' })
})
it('成员添加携带候选检索选中的 userId(缺省不携带)', () => {
expect(memberAddPayload('p1', 'carol', 'member', 'u-carol'))
.toEqual({ projectId: 'p1', member: 'carol', role: 'member', userId: 'u-carol' })
expect(memberAddPayload('p1', 'carol', 'owner', '')).not.toHaveProperty('userId')
})
it('文档职责分配/预览携带 accountIds{owner:userId},只含本条职责涉及的人', () => {
const known = { alice: 'u-a', bob: 'u-b', erin: 'u-e' }
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob'], 'dave', { expectedDutyVersion: 2, accountIds: known }))
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave',
expectedDutyVersion: 2, accountIds: { alice: 'u-a', bob: 'u-b' } })
expect(assignDocDutiesPayload('p1', 'd1', 'dave', ['carol'], 'frank', { accountIds: known })).not.toHaveProperty('accountIds')
const doc = { docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2,
confirmInvalidation: false, accountIds: { alice: 'u-a' } }
expect(dutyPreviewPayload('p1', [doc])).toEqual({ projectId: 'p1', docs: [doc] })
})
it('文档职责与整文审签载荷', () => {
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave'))
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave' })

Some files were not shown because too many files have changed in this diff Show More