Compare commits

..
Author SHA1 Message Date
agent-exec-transport 7cb17d015d agent-exec transport e419faabfdc444e5a0d64b8a9303d89c/07524a02a023 2026-10-18 04:25:19 +00:00
agent-exec-transport 1719803a92 agent-exec transport 241d1cd7cbb999de18b756d962af8b78/2eebeb938f56 2026-10-18 04:22:38 +00:00
agent-exec-transport a1e7e4680f agent-exec transport d6268c248ca4bf77b9c9974ad0aa0162/63feb6000719 2026-10-18 04:17:46 +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 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
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
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
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
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
weichen 10635387e0 Gate project override settings to owners and admins
Why: S9 acceptance finding 3 — the project settings modal exposed the
host override settings (including the model/key group, masked) to plain
project members; the duties data plane was already permission-enforced,
but the host settings surface had no gate.

Evidence: vitest 34 files / 293 tests pass with the change; tsc reports
no errors in the modified file. Ruling per the acceptance plan P1-B:
the modal entry stays (granted duty managers need the RDM tabs), while
the host override settings group renders only for the project owner or
a system admin; RDM tabs keep their own fail-closed gating.

Impact: ProjectSettingsModal project tab renders an explanatory empty
state for non-privileged viewers; members/duties/matrix tabs unchanged.
Refs: S9 acceptance report P1-B
2026-10-16 05:09:58 +00:00
weichen a81e94a4f2 Merge pull request 'Show the layered Python environment in admin'
Reviewed against the layered environment plan. Required CI on the current head passed where this repository runs CI.
2026-10-15 14:24:46 +00:00
weichen 781cde6667 Merge remote-tracking branch 'origin/main' into feature/layered-python-env 2026-10-15 14:01:27 +00:00
weichenandCursor e33d17e1a6 Show the layered Python environment in the admin zone.
Why: Approving an extra package, rebuilding the base layer, reclaiming unused layers, and uploading a wheel need an admin surface that uses the existing admin controls.
Evidence: SELF tsc of the new panel reported no errors in frontend sources. UI package type errors come from missing node_modules in this worktree, not from this panel.
Impact: Administrators get a Python 环境 section. Other admin sections are unchanged.
Refs: .task/feature-layered-python-env.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 13:52:55 +00:00
weichen 5c0e9f2711 Merge pull request 'Show document duties in three shared views' (#90) from feature/rdm-duty-matrix into main 2026-10-15 09:15:09 +00:00
weichen 04269eed25 Merge remote-tracking branch 'origin/main' into feature/rdm-duty-matrix 2026-10-15 09:14:32 +00:00
weichenandCursor 985fc0f931 Adopt the server duty draft when a save result is unknown
Why: a lost write response was keeping the old duty version, so the next save replayed it, and a busy save could stay locked if another request advanced the sequence.
Evidence: SELF vitest duty-draft 6 passed, tsc passed, knip 43, Playwright duty flows 3 passed.
Impact: an unknown save reloads server roles, confirmation resets, and busy clears even if a later read is discarded.
Refs: docs/proposals/2026-10-15-rdm-duty-matrix-rework.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 09:06:24 +00:00
weichen 4835c45b8d Merge pull request 'Stabilize the nonblocking choice browser baseline' (#91) from fix/workbench-choice-browser-wait into main 2026-10-15 08:38:20 +00:00
weichenandCursor 6093beb27e Merge origin/main into the duty matrix branch
Why: project settings must keep the shared duty draft and must not regain template document creation from main.
Evidence: SELF vitest 292 passed, tsc --noEmit passed, knip unused exports 43.
Impact: the duties tab stays assignment-only while conversation ordering from main is included.
Refs: docs/proposals/2026-10-15-rdm-duty-matrix-rework.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 08:37:53 +00:00
weichen c2ff4fcc8e Stabilize the nonblocking choice browser baseline
Why: Wait for the unrelated foreground reply before measuring duplicate replies.

Evidence: All 24 scenarios passed against deployed 8788; independent replay passed the targeted scenario.

Impact: Test synchronization only; the duplicate-reply assertion is unchanged.

Refs: Agent-System-Frontend #89; Agent-System-Dev #565
2026-10-15 08:37:23 +00:00
weichen 03db4b81be Merge pull request 'Keep chat ordered while background results update independently' (#89) from feature/workbench-flow-completion into main 2026-10-15 08:18:46 +00:00
weichen 3555f815af Keep late background progress out of a newer conversation turn
Why: Completion or progress from an earlier task could appear in the process of an unrelated newer answer.

Evidence: The new ownership regression fails before the guard and passes afterward; the frontend suite, TypeScript and unchanged export gate pass.

Impact: Project child progress only into its originating live turn while still reconciling background results.

Refs: feature/workbench-flow-completion chronological display requirement
2026-10-15 07:51:41 +00:00
weichen 11f66aee9d Verify complete saved records beyond one event page
Why: Full execution history must retain outputs after the 500-event API page boundary.

Evidence: Ordered 502-event replay and nonadvancing-cursor rejection tests pass.

Impact: Add regression coverage only; production source is unchanged.

Refs: feature/workbench-flow-completion review testing gap
2026-10-15 07:45:31 +00:00
weichenandCursor 06249dd83f Keep the duty draft exports inside the knip baseline
Why: the settings page only needs the draft operations the views call. Extra aliases and unused helpers pushed the unused-export count over the baseline.
Evidence: SELF node scripts/check-knip.mjs reported 43 <= 43, and duty-draft vitest passed.
Impact: chapter-review command aliases are gone from the settings gate. The draft behavior is unchanged.
Refs: modules/frontend/knip-baseline.json
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 07:34:49 +00:00
weichen 83c22681f9 Preserve rejected message drafts and keep later chat unblocked
Why: A rejected result-reference request could discard the user text or uploads after the composer cleared.

Evidence: 286 frontend tests, TypeScript and the unchanged unused-export gate pass; mixed-input browser regression preserves the draft.

Impact: Validate incompatible inputs before clearing, retain failed pending input with its request identity, and require explicit retry while other messages can continue.

Refs: feature/workbench-flow-completion review frontend-races finding
2026-10-15 07:24:13 +00:00
weichenandCursor 44205e1262 Cover the shared duty draft in the browser
Why: the three views, the blocked empty-reviewer save, and the narrow-screen matrix need a browser check that does not depend on a live account.
Evidence: SELF playwright e2e/rdm-duties.playwright.ts, 3 passed against the local Vite server.
Impact: the duties end-to-end file now drives assignment, stale responses, and the narrow-screen guidance.
Refs: feature/rdm-duty-matrix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 07:23:02 +00:00
weichenandCursor 1b7ba779f1 Show document duties in three shared views
Why: the settings page was mixing duty assignment with whole-document review and chapter objects. The three views need one draft and one save check.
Evidence: SELF vitest run of the frontend suite, 278 tests passed, including duty-draft and design-guard.
Impact: the duties tab assigns existing documents only. Submit, approve, reject, and final stay out of this page. Commands that are not ready keep the tab hidden.
Refs: feature/rdm-duty-matrix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 07:20:48 +00:00
weichen 0cd1f9954a Keep the replay progress helper private
Why: The frontend unused-export gate rejected a helper used only within its module.

Evidence: The unchanged knip baseline and TypeScript checks pass.

Impact: Remove an unnecessary export without changing replay behavior.

Refs: feature/workbench-flow-completion
2026-10-15 07:15:05 +00:00
weichen 92c390a08a Synchronize guarded work dispatch state from Core
Why: Keep persisted task decisions aligned across Go and TypeScript.

Evidence: make contracts generated both consumers from Core 1a5ef6b.

Impact: Include the reserved run identity and typed dispatching state.

Refs: feature/workbench-flow-completion
2026-10-15 07:06:10 +00:00
weichen 06fa1a40a6 Keep conversations ordered while background work updates independently
Why: Repeated progress and coupled scrolling obscured chat, and task choices lacked a reliable independent interaction path.

Evidence: 285 frontend unit tests and 23 browser scenarios pass locally before the shared event-loader extraction; final combination checks follow in Dev.

Impact: Coalesce cumulative progress, retain execution records, show timestamps and quiet notifications, support versioned work actions and explicit result references without automatic chat backfill.

Refs: feature/workbench-flow-completion
2026-10-15 07:05:57 +00:00
weichen 9242cee89d Synchronize the work card contract
Why: Consume the Core work result and optional decision snapshot.

Evidence: Generated with make contracts from Core 00f1826.

Impact: Add optional types without changing existing cards.

Refs: feature/workbench-flow-completion
2026-10-15 06:46:40 +00:00
weichen 8a9d129a94 Merge pull request 'Add template-driven document creation to the RDM duties tab' (#88) from feature/frontend-u4-docs-instantiate into main 2026-10-15 03:42:29 +00:00
weichen f7d9197011 Add template-driven document creation to the RDM duties tab
Why: the duties tab only listed existing documents — rdm.docs.instantiate
had no UI entry, so a fresh project showed an empty duties tab with no
setup path (owner trial feedback 2026-10-15).

Evidence: new docs-instantiate-params tests green; full vitest run shows
only the 12 pre-existing conversation-work failures also present at clean
HEAD; design-guard green.

Impact: duties tab gains a template picker + title input wired to
rdm.docs.instantiate (backend command rdm.docs.templates lists published
identities); instantiation refreshes the catalog and selects the new doc.

Refs: Agent-System-RDM docs/records/dhf/u4-header-footer-slots.md
2026-10-15 03:42:05 +00:00
weichen 5ab81432d1 Merge pull request 'Redesign the workbench visual hierarchy' (#87) from feature/workbench-visual-redesign into main 2026-10-14 16:53:45 +00:00
weichen 9b9f24b7be Enforce tokenized workbench visuals across themes
Why: Independent review found component-local color derivation, a private transition, and missing dark/molv coverage in the visual candidate.
Evidence: Frontend design guard passes and 19 Playwright scenarios cover light, dark, molv, and 320-1920px layouts.
Impact: Keeps the approved visual direction while moving color ownership to @ui tokens and removing private motion.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:50:17 +00:00
weichen 8dc68fd1e3 Keep the create action legible on dark navigation
Why: The inherited light-theme divider and hover treatment disappeared inside the new inverse create button.
Evidence: Focused browser layout test and production build pass locally.
Impact: Changes only the create action divider and hover surface.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:40:50 +00:00
weichen ddf3135c3f Establish a distinct workbench visual frame
Why: Spacing-only refinements still looked like the original flat gray workspace and did not satisfy the approved visual redesign.
Evidence: 272 unit tests, 16 browser scenarios across 320-1920px, and the production build pass locally.
Impact: Gives navigation, authored messages, assistant results, and work details clearly different visual surfaces while preserving behavior.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:38:26 +00:00
weichen b04f4ded95 Redesign the workbench visual hierarchy
Why: The three-column workspace read as flat and visually scattered, making conversation, current work, and navigation difficult to distinguish.
Evidence: 272 unit tests, 16 focused browser scenarios across 320-1920px, and the production build pass locally.
Impact: Refines the existing workbench presentation without changing its data flow or fast/slow task behavior.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:35:17 +00:00
weichen 710ef836b5 Merge pull request 'Separate conversation history from work details' (#85) from feature/work-companion-visibility into main 2026-10-14 14:40:27 +00:00
weichen e10e447dab Keep collaboration events distinct from partial text
Why: Status words shared with a final reply could suppress a child completion record.

Evidence: 272 frontend unit tests, build, and 15 isolated browser scenarios.

Impact: Tag client-side collaboration replay so partial-delta deduplication preserves agent notes.

Refs: Agent-System-Dev feature/work-companion-visibility UI1
2026-10-14 14:27:28 +00:00
weichen b175506ebd Deduplicate partial streamed narration in process history
Why: A dropped delta batch can leave a short event prefix next to the complete saved message.

Evidence: 271 frontend unit tests, build, and 14 isolated browser scenarios.

Impact: Suppress overlapping partial replay text and thinking while retaining saved content.

Refs: Agent-System-Dev feature/work-companion-visibility UI1
2026-10-14 14:25:04 +00:00
weichen 7afae7f20b Preserve saved process details during event replay
Why: Event replay could hide saved retry notes and leave completed cards outside the active work filter.

Evidence: 270 frontend unit tests, build, and 14 isolated browser scenarios.

Impact: Merge replay into saved history, retain child progress, and locate cards under the right scope.

Refs: Agent-System-Dev feature/work-companion-visibility UI1 UI2
2026-10-14 14:17:33 +00:00
weichen 099a92022d Separate conversation history from work details
Why: Fast and slow channel cards duplicate across panes and completed replies hide process output.

Evidence: npm test; npm run build; 12 isolated Playwright scenarios with reload and responsive widths.

Impact: Preserve ordered folded process history, load durable run events, and give work cards one detailed pane.

Refs: Agent-System-Dev feature/work-companion-visibility UI1 UI2 UI3
2026-10-14 14:07:43 +00:00
70 changed files with 4997 additions and 2349 deletions
+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')
})
+25 -29
View File
@@ -1,7 +1,6 @@
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
// 新密码登录进工作台;重复兑换同一 Key 被拒。
// 忘记密码唯一处置回归:管理员生成一次性登录密码 → 用户密码登录 → 强制改密
//(one_time_password 门只要新密码 + 确认);
// 用户行不再有恢复提案入口,登录页只保留用户名 + 密码,不提供一次性 Key 登录 tab。
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
@@ -53,60 +52,57 @@ async function adminLogin(page: Page) {
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
}
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
test('管理员生成一次性登录密码:登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
test.setTimeout(120_000)
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
// 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。
await adminLogin(page)
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
const zone = page.getByRole('dialog', { name: '系统管理' })
await expect(zone).toBeVisible()
// 每个用户行都有同款按钮:限定目标用户所在行。
await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click()
await zone.getByRole('button', { name: '生成 Key', exact: true }).click()
const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]')
await expect(keyInput).toBeVisible({ timeout: 10_000 })
const key = await keyInput.inputValue()
expect(key).toMatch(/^alk_/)
await expect(zone.getByText(/单次有效/)).toBeVisible()
const row = zone.locator('tr', { hasText: USER })
await expect(row.getByRole('button', { name: '恢复提案' })).toHaveCount(0)
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
const oneTimePassword = zone.locator('input[aria-label="一次性密码"]')
await expect(oneTimePassword).toBeVisible({ timeout: 10_000 })
await expect(zone.getByText('使用后必须强制改密')).toBeVisible()
const password = await oneTimePassword.inputValue()
expect(password.length).toBeGreaterThan(0)
// 2) 管理员退出系统管理与登录态,回到登录页。
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
//(一次性密码展示框内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
await zone.getByRole('button', { name: '返回工作台' }).click()
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
// 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
await page.getByPlaceholder('alk_…').fill(key)
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
// 4) 受限设密门:服务端确认用户名;无当前密码输入框。
// 3) 用户使用一次性登录密码登录后进入 one_time_password 强制改密门:
// 只填新密码 + 确认,不再要求当前密码。
await passwordLogin(page, USER, password)
const gate = page.locator('.lg-card')
await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 })
await expect(gate.getByText(`账号 ${USER}`)).toBeVisible({ timeout: 10_000 })
await expect(gate.getByText('无需再次输入一次性密码')).toBeVisible()
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
const pw = gate.locator('input[type="password"]')
await expect(pw).toHaveCount(2)
await pw.nth(0).fill(USER_PW)
await pw.nth(1).fill(USER_PW)
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
// 4) 成功页:提示用新密码登录,唯一出口回登录页。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
// 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。
// 5) 新密码登录进工作台。
await passwordLogin(page, USER, USER_PW)
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
// 6) 登录页不再提供一次性 Key 登录入口。
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
await page.getByPlaceholder('alk_…').fill(key)
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('tab', { name: '一次性登录 Key' })).toHaveCount(0)
await expect(page.getByPlaceholder('alk_…')).toHaveCount(0)
})
+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)
})
})
+59 -230
View File
@@ -1,4 +1,4 @@
// Real React/Picker interaction with deterministic HTTP responses; no live account or database.
// 文档职责三视图:确定性 HTTP,不连接真实账号或数据库。
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
@@ -9,32 +9,18 @@ function deferred() {
return { promise, resolve }
}
async function dutiesPage(page: Page, options: { canAssign?: boolean; missingVersion?: boolean } = {}) {
async function dutiesPage(page: Page) {
const writes: { command: string; params: Record<string, unknown> }[] = []
const calls: { command: string; params: Record<string, unknown> }[] = []
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
const active = new Set<string>()
const submissions: Record<string, unknown> = { 'doc-A': null, 'doc-B': null }
const canAssign = options.canAssign !== false
const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove',
'rdm.project.member.set-role', 'rdm.project.member.impact', 'rdm.account.list', 'rdm.role.options',
'rdm.duty.catalog', 'rdm.duty.tree', 'rdm.duty.get', 'rdm.review.assign', 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch',
'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final', 'rdm.review.doc.status']
const docs = [
{ docId: 'doc-A', title: '文档A', typeKey: 'design', typeLabel: '设计方案', sections: 1, dutyVersion: 3, assigned: true, author: 'alice', reviewers: ['carol'], approver: 'dave' },
{ docId: 'doc-B', title: '文档B', typeKey: 'design', typeLabel: '设计方案', sections: 1, dutyVersion: 7, assigned: true, author: 'bob', reviewers: ['carol'], approver: 'dave' },
]
const members = ['alice', 'bob', 'carol', 'dave'].map(owner => ({ owner, role: owner === 'alice' ? 'owner' : 'member', addedBy: '', addedAt: '', valid: true,
profRole: { roleId: owner === 'bob' ? 'systems' : 'software', name: owner === 'bob' ? '系统工程师' : '软件工程师' },
dutyEligibility: { author: { allowed: true }, reviewer: { allowed: true }, approver: { allowed: true } } }))
const filteredDocs = (params: Record<string, unknown>) => docs.filter(document => !params.query || (params.match === 'exact'
? document.docId === params.query || document.title === params.query : `${document.docId} ${document.title}`.includes(String(params.query))))
let generation = 0
const commands = ['rdm.project.member.list', 'rdm.duty.matrix', 'rdm.duty.preview',
'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.scan', 'rdm.duty.legacy.apply']
await page.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
let body: unknown = []
if (path === '/api/health') body = { ok: true }
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: false }
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: true }
else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} }
@@ -43,230 +29,73 @@ async function dutiesPage(page: Page, options: { canAssign?: boolean; missingVer
const command = decodeURIComponent(path.substring('/api/commands/'.length))
const params = request.postDataJSON().params as Record<string, unknown>
expect(params.projectId).toBe('domain-7')
calls.push({ command, params })
const hold = holds.get(`${command}:${params.docId ?? ''}`)
const hold = holds.get(command)
if (hold) { hold.started.resolve(); await hold.release.promise }
let payload: unknown = {}
if (command === 'rdm.duty.catalog') payload = { docs: filteredDocs(params), next: '', count: filteredDocs(params).length, total: docs.length, canAssignDuties: canAssign,
filters: { stages: [], typeKeys: [{ value: 'design', label: '设计方案' }], assignments: [{ value: 'assigned', label: '已分配' }, { value: 'unassigned', label: '未分配' }], statuses: [] } }
else if (command === 'rdm.duty.tree') payload = { nodes: filteredDocs(params).map(document => ({ id: document.docId, kind: 'doc', label: document.title, selectable: true, docId: document.docId,
children: [{ id: document.docId + '-section', kind: 'section', label: '方案章节', selectable: false, children: [] }] })), next: '', canAssignDuties: canAssign }
else if (command === 'rdm.role.options') payload = { roles: [{ roleId: 'systems', name: '系统工程师' }, { roleId: 'software', name: '软件工程师' }] }
else if (command === 'rdm.project.member.list') payload = { members: members.filter(person => (!params.query || person.owner.includes(String(params.query))) && (!params.roleId || person.profRole.roleId === params.roleId)),
next: '', accountDirectoryAvailable: true, canManageMembers: true, canAssignDuties: canAssign, canConfigurePermissions: false }
else if (command === 'rdm.duty.get') {
const doc = docs.find(d => d.docId === params.docId)!
payload = { docId: doc.docId, title: doc.title, assigned: doc.assigned,
roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [], canAssignDuties: canAssign,
...(options.missingVersion ? {} : { dutyVersion: doc.dutyVersion }),
active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice', duties: { Author: doc.author, Reviewers: doc.reviewers, Approver: doc.approver } } : null }
} else if (command === 'rdm.review.doc.status') payload = { submission: submissions[String(params.docId)] }
else {
const author = generation === 0 ? 'alice' : 'bob'
if (command === 'rdm.duty.matrix') {
body = { payload: {
canAssign: true,
docs: [{ docId: 'doc-A', code: 'A', title: '文档A', typeKey: 'design', stage: 'BD', dutyVersion: 1, authors: 1, reviewers: 1, approvers: 1, conflicts: 0, legacySectionOverrides: 0 }],
members: ['alice', 'bob', 'carol'].map(owner => ({ owner, role: 'member', valid: true, canAuthor: true, canReview: true, canApprove: true })),
cells: [
{ docId: 'doc-A', owner: 'alice', duty: author === 'alice' ? 'author' : 'none' },
{ docId: 'doc-A', owner: 'bob', duty: author === 'bob' ? 'author' : 'reviewer' },
{ docId: 'doc-A', owner: 'carol', duty: 'approver' },
],
} }
} else if (command === 'rdm.duty.preview') {
writes.push({ command, params })
if (command === 'rdm.review.doc.submit') {
const document = docs.find(document => document.docId === params.docId)!
active.add(document.docId)
submissions[document.docId] = { round: 1, state: 'submitted', createdBy: 'alice',
duties: { Author: document.author, Reviewers: document.reviewers, Approver: document.approver }, reviews: {} }
}
if (command === 'rdm.review.assign.doc' || command === 'rdm.review.assign.doc.batch') {
const entries = command === 'rdm.review.assign.doc' ? [params] : params.docs as Record<string, unknown>[]
for (const entry of entries) {
const document = docs.find(document => document.docId === entry.docId)!
expect(entry.expectedDutyVersion).toBe(document.dutyVersion)
const changed = entry.author !== document.author || entry.approver !== document.approver
|| JSON.stringify(entry.reviewers) !== JSON.stringify(document.reviewers)
if (changed && active.has(document.docId)) expect(entry.confirmInvalidation || params.confirmInvalidation).toBe(true)
if (changed) {
document.author = String(entry.author); document.reviewers = entry.reviewers as string[]; document.approver = String(entry.approver); document.dutyVersion++
active.delete(document.docId)
}
}
}
body = { payload: { ok: true, docs: [{ docId: 'doc-A', title: '文档A', replacedAuthor: 'alice' }] } }
} else if (command === 'rdm.duty.legacy.scan') {
body = { payload: { findings: [{ docId: 'doc-A', title: '文档A', sectionTitle: '范围', kind: 'ambiguous' }] } }
} else {
writes.push({ command, params })
generation += 1
body = { payload: { applied: 1 } }
}
body = { payload }
} else if (path.endsWith('/cards')) body = { cards: [] }
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/')
await page.getByRole('button', { name: '项目设置', exact: true }).click()
await page.getByRole('tab', { name: /文档职责/ }).click()
await expect(page.getByRole('button', { name: '文档A', exact: true })).toBeVisible()
return { writes, calls, active, submissions,
hold(command: string, docId: string) {
const pending = { started: deferred(), release: deferred() }
holds.set(`${command}:${docId}`, pending)
return pending
} }
await page.getByRole('tab', { name: '文档职责', exact: true }).click()
await expect(page.getByText('本页只分配已有文档的职责,不创建或生成文档。')).toBeVisible()
return { writes, hold(command: string) {
const pending = { started: deferred(), release: deferred() }
holds.set(command, pending)
return pending
} }
}
async function chooseMember(page: Page, label: string, owner: string) {
await page.locator(`.picker[aria-label="${label}"]`).getByRole('combobox').fill(owner)
await page.getByRole('option', { name: new RegExp(`^${owner}`) }).click()
}
async function settleResponse(page: Page, command: string, release: () => void) {
const response = page.waitForResponse(r => r.url().endsWith(`/api/commands/${command}`))
release()
await response
await page.evaluate(() => new Promise<void>(done => requestAnimationFrame(() => requestAnimationFrame(() => done()))))
}
test('a newly assigned document can submit its first whole-document review', async ({ page }) => {
test('three views share one draft and do not offer review actions', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByRole('button', { name: '文档A', exact: true }).click()
await expect(page.getByText('尚无整文提交记录', { exact: true })).toBeVisible()
const submit = page.getByRole('button', { name: '整文提交(编制人)', exact: true })
await expect(submit).toBeEnabled()
await submit.click()
await expect(page.getByText('整文已提交审核', { exact: true })).toBeVisible()
await expect(submit).toBeDisabled()
await expect(page.getByRole('heading', { name: '整文审签 · 第 1 轮 · 审核中', exact: true })).toBeVisible()
expect(fixture.writes).toEqual([{ command: 'rdm.review.doc.submit', params: { projectId: 'domain-7', docId: 'doc-A' } }])
await page.getByLabel('选择成员').selectOption('bob')
await page.getByLabel('bob 在 文档A 的职责').selectOption({ label: '编制(将替换alice)' })
await page.getByRole('tab', { name: '按文档' }).click()
await page.getByLabel('选择文档').selectOption('doc-A')
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
await page.getByRole('tab', { name: '矩阵' }).click()
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
await expect(page.getByRole('button', { name: /提交|审核通过|驳回|终批|生成|新建文档/ })).toHaveCount(0)
await page.getByRole('button', { name: '保存职责' }).click()
await expect.poll(() => fixture.writes.map(item => item.command)).toEqual(['rdm.duty.preview', 'rdm.review.assign.doc.batch'])
})
test('viewing active duties is passive; only a dirty save asks for confirmation', async ({ page }) => {
test('a stale matrix response does not replace the current draft', async ({ page }) => {
const fixture = await dutiesPage(page)
fixture.active.add('doc-A')
const dialogs: string[] = []
let accept = false
page.on('dialog', async dialog => { dialogs.push(dialog.message()); accept ? await dialog.accept() : await dialog.dismiss() })
await page.getByRole('button', { name: '文档A', exact: true }).click()
await expect(page.getByRole('heading', { name: /文档A · 当前职责/ })).toBeVisible()
const save = page.getByRole('button', { name: '保存当前职责', exact: true })
await expect(save).toBeDisabled()
expect(dialogs).toEqual([])
await chooseMember(page, '编制人', 'bob')
await expect(save).toBeEnabled()
expect(dialogs).toEqual([])
await save.click()
expect(dialogs).toHaveLength(1)
expect(fixture.writes).toEqual([])
accept = true
await save.click()
await expect(page.getByText('职责已保存,已重新读取当前状态', { exact: true })).toBeVisible()
expect(dialogs).toHaveLength(2)
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: {
projectId: 'domain-7', docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave', expectedDutyVersion: 3, confirmInvalidation: true,
} }])
await page.getByLabel('选择成员').selectOption('alice')
const held = fixture.hold('rdm.duty.matrix')
await page.getByLabel('筛选文档名称或编号').fill('文档A')
await held.started.promise
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
held.release.resolve()
await expect(page.getByLabel('alice 在 文档A 的职责')).toHaveValue('reviewer')
})
test('a late document detail cannot overwrite another selection or its saved roles', async ({ page }) => {
const fixture = await dutiesPage(page)
const old = fixture.hold('rdm.duty.get', 'doc-A')
await page.getByRole('button', { name: '文档A', exact: true }).click()
await old.started.promise
await page.getByRole('button', { name: '文档B', exact: true }).click()
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
await settleResponse(page, 'rdm.duty.get', old.release.resolve)
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([])
await chooseMember(page, '批准人', 'alice')
await page.getByRole('button', { name: '保存当前职责', exact: true }).click()
await expect(page.getByText('职责已保存,已重新读取当前状态', { exact: true })).toBeVisible()
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: {
projectId: 'domain-7', docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'alice', expectedDutyVersion: 7, confirmInvalidation: false,
} }])
})
test('document ID and title searches share list and tree selection without making chapters selectable', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByLabel('文档匹配方式', { exact: true }).selectOption('exact')
await page.getByRole('textbox', { name: '搜索文档', exact: true }).fill('doc-B')
await expect(page.getByRole('button', { name: '文档A', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '文档B', exact: true })).toBeVisible()
await page.getByLabel('文档浏览方式', { exact: true }).selectOption('tree')
await page.getByRole('button', { name: '文档B', exact: true }).click()
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
await page.getByRole('button', { name: '展开 文档B', exact: true }).click()
await expect(page.getByText('方案章节 · 章节', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '方案章节', exact: true })).toHaveCount(0)
expect(fixture.writes).toEqual([])
expect(fixture.calls.some(call => call.command === 'rdm.duty.tree' && call.params.query === 'doc-B' && call.params.match === 'exact')).toBe(true)
})
test('professional role filtering keeps duty candidates within project membership and selected labels stay visible', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByRole('button', { name: '文档A', exact: true }).click()
await page.getByLabel('专业角色筛选', { exact: true }).selectOption('systems')
await page.locator('.picker[aria-label="编制人"]').getByRole('combobox').focus()
await expect(page.getByRole('option', { name: /^bob/ })).toBeVisible()
await expect(page.getByRole('option', { name: /^carol/ })).toHaveCount(0)
await page.getByRole('option', { name: /^bob/ }).click()
await expect(page.locator('.picker[aria-label="编制人"] .picker-selected')).toContainText('bob')
expect(fixture.calls.some(call => call.command === 'rdm.project.member.list' && call.params.roleId === 'systems')).toBe(true)
expect(fixture.calls.some(call => call.command === 'rdm.account.list')).toBe(false)
})
test('missing version or caller assignment capability keeps duty editors read only', async ({ page }) => {
const fixture = await dutiesPage(page, { canAssign: false, missingVersion: true })
await page.getByRole('button', { name: '文档A', exact: true }).click()
await expect(page.getByRole('button', { name: '保存当前职责', exact: true })).toBeDisabled()
await expect(page.locator('.picker[aria-label="编制人"]').getByRole('combobox')).toBeDisabled()
await expect(page.getByText('职责版本尚未就绪,只能查看;请升级项目服务后重新读取', { exact: true })).toBeVisible()
expect(fixture.writes).toEqual([])
})
test('batch preview carries each document version and explicitly confirms active review invalidation', async ({ page }) => {
const fixture = await dutiesPage(page)
fixture.active.add('doc-A')
const prompts: string[] = []
page.on('dialog', async dialog => { prompts.push(dialog.message()); await dialog.accept() })
await page.getByRole('checkbox', { name: '选择 文档A', exact: true }).check()
await page.getByRole('checkbox', { name: '选择 文档B', exact: true }).check()
await chooseMember(page, '批量编制人', 'bob')
await chooseMember(page, '批量审核人', 'carol')
await chooseMember(page, '批量批准人', 'dave')
await page.getByRole('button', { name: '预览并分配 2 份文档', exact: true }).click()
await expect(page.getByText('批量职责已保存,已重新读取目录', { exact: true })).toBeVisible()
expect(prompts).toHaveLength(1)
expect(prompts[0]).toContain('第 1 轮将失效')
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc.batch', params: { projectId: 'domain-7', confirmInvalidation: true,
docs: [
{ docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave', expectedDutyVersion: 3, confirmInvalidation: true },
{ docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'dave', expectedDutyVersion: 7, confirmInvalidation: true },
] } }])
})
test('a batch keeping existing duties does not claim its active review will be invalidated', async ({ page }) => {
const fixture = await dutiesPage(page)
fixture.active.add('doc-A')
const prompts: string[] = []
page.on('dialog', async dialog => { prompts.push(dialog.message()); await dialog.accept() })
await page.getByRole('checkbox', { name: '选择 文档A', exact: true }).check()
await chooseMember(page, '批量编制人', 'alice')
await chooseMember(page, '批量审核人', 'carol')
await chooseMember(page, '批量批准人', 'dave')
await page.getByRole('button', { name: '预览并分配 1 份文档', exact: true }).click()
await expect(page.getByText('批量职责已保存,已重新读取目录', { exact: true })).toBeVisible()
expect(prompts[0]).not.toContain('失效')
expect(fixture.writes[0].params.confirmInvalidation).toBe(false)
expect(fixture.active.has('doc-A')).toBe(true)
})
test('a late review status cannot replace the selected document frozen review', async ({ page }) => {
const fixture = await dutiesPage(page)
const submission = (round: number, author: string) => ({ round, state: 'submitted', digest: '', createdBy: author,
createdAt: '', duties: { author, reviewers: ['carol'], approver: 'dave' }, reviews: {}, sections: {} })
fixture.submissions['doc-A'] = submission(1, 'alice')
fixture.submissions['doc-B'] = submission(2, 'bob')
const old = fixture.hold('rdm.review.doc.status', 'doc-A')
await page.getByRole('button', { name: '文档A', exact: true }).click()
await old.started.promise
await page.getByRole('button', { name: '文档B', exact: true }).click()
await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible()
await settleResponse(page, 'rdm.review.doc.status', old.release.resolve)
await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible()
await expect(page.getByRole('heading', { name: /整文审签 · 第 1 轮/ })).toHaveCount(0)
})
test('closing the project panel invalidates a pending detail before its follow-up status read', async ({ page }) => {
const fixture = await dutiesPage(page)
const old = fixture.hold('rdm.duty.get', 'doc-A')
await page.getByRole('button', { name: '文档A', exact: true }).click()
await old.started.promise
await page.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click()
await expect(page.getByRole('tab', { name: /文档职责/ })).toHaveCount(0)
await settleResponse(page, 'rdm.duty.get', old.release.resolve)
expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([])
test('a narrow viewport points away from the full matrix', async ({ page }) => {
await dutiesPage(page)
await page.setViewportSize({ width: 390, height: 800 })
await page.getByRole('tab', { name: '矩阵' }).click()
await expect(page.getByText('当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。')).toBeVisible()
})
-155
View File
@@ -1,155 +0,0 @@
// Deterministic HTTP contract QA of the real React settings surfaces. Live domain QA is separate.
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
async function settingsPage(page: Page, canManage = true) {
const calls: { command: string; params: Record<string, unknown> }[] = []
const writes: { command: string; params: Record<string, unknown> }[] = []
const settingWrites: { path: string; body: unknown }[] = []
const people = [
{ owner: 'alice', name: '项目负责人', valid: true, joined: true, role: 'owner', projectRole: 'owner', profRole: { roleId: 'systems', name: '系统工程师' } },
{ owner: 'bob', name: '李工', valid: true, joined: false, role: 'member', projectRole: 'member', profRole: { roleId: 'systems', name: '系统工程师' } },
{ owner: 'carol', name: '陈工', valid: true, joined: true, role: 'member', projectRole: 'member', profRole: { roleId: 'software', name: '软件工程师' } },
{ owner: 'unknown', name: '待核验', joined: false, profRole: null },
]
const schema = [
{ key: 'deliverables.style', group: 'deliverables', label: '项目交付风格', type: 'enum', options: ['modern', 'formal', 'minimal'], default: 'modern', effect: 'new-run', scope: 'project' },
{ key: 'models.chat.model', group: 'models', label: '主模型', type: 'string', effect: 'new-run', scope: 'system' },
]
let projectRevision = 4
let projectValues: Record<string, unknown> = { deliverables: { style: 'formal' } }
const overrides = new Map<string, boolean | null>([['alice:doc.read', false], ['alice:doc.write', true]])
const permissions = (member: string) => [
{ action: 'project.create', label: '创建项目', scope: 'project', allowed: true, defaultAllowed: true, capAllowed: true, override: null, source: 'default' },
{ action: 'doc.read', label: '读取文档', scope: 'project', allowed: overrides.get(member + ':doc.read') ?? true,
defaultAllowed: true, capAllowed: true, override: overrides.get(member + ':doc.read') ?? null, source: overrides.has(member + ':doc.read') ? 'override' : 'prof-role-default' },
{ action: 'doc.write', label: '编制与编辑文档', scope: 'project', allowed: false, defaultAllowed: true, capAllowed: false, override: overrides.get(member + ':doc.write') ?? null, source: 'cap', reason: '管理员上限禁止' },
]
const matches = (person: typeof people[number], params: Record<string, unknown>) => {
const query = String(params.query || '')
return (!params.roleId || person.profRole?.roleId === params.roleId) && (!params.projectRole || person.projectRole === params.projectRole)
&& (!query || (params.match === 'exact' ? person.owner === query || person.name === query : `${person.owner} ${person.name}`.includes(query)))
}
const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.set-role', 'rdm.project.member.impact',
'rdm.account.list', 'rdm.role.options', 'rdm.perm.check', 'rdm.perm.matrix', 'rdm.perm.set', 'rdm.perm.unset']
await page.route('**/api/**', async route => {
const request = route.request()
const url = new URL(request.url())
const path = url.pathname
let body: unknown = []
if (path === '/api/me') body = { owner: 'alice', name: '项目负责人', auth: true, isAdmin: false }
else if (path === '/api/health') body = { ok: true }
else if (path === '/api/projects') body = [{ id: 'host-1', name: '项目配置验收', createdAt: '', updatedAt: '', settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
else if (path === '/api/settings') body = url.searchParams.has('project') ? { schema, defaults: {}, user: {}, project: projectValues, projectRevision, projectCanConfigure: canManage,
personalSchema: [{ key: 'crossSession.read', group: 'crossSession', label: '跨会话读取', type: 'boolean', default: false, effect: 'new-run', scope: 'project-personal' }], personalDefaults: {}, personalUser: {}, personalProject: {} }
: { schema: [{ key: 'ui.instructions', group: 'ui', label: '个人偏好', type: 'string', effect: 'new-run', scope: 'user' }], defaults: {}, user: {}, project: {} }
else if (path.startsWith('/api/settings/project/') && request.method() === 'PUT') {
const patch = request.postDataJSON()
settingWrites.push({ path, body: patch })
expect(patch.expectedRevision).toBe(projectRevision)
if (patch.values.deliverables) projectValues = patch.values
if (patch.unset?.includes('deliverables.style')) projectValues = {}
projectRevision++
body = { ok: true, projectRevision }
} else if (path.startsWith('/api/commands/')) {
const command = path.substring('/api/commands/'.length)
const params = request.postDataJSON().params as Record<string, unknown>
expect(params.projectId).toBe('domain-7')
expect(params).not.toHaveProperty('actor')
calls.push({ command, params })
let payload: unknown = {}
if (command === 'rdm.role.options') payload = { roles: [{ roleId: 'systems', name: '系统工程师' }, { roleId: 'software', name: '软件工程师' }] }
else if (command === 'rdm.account.list') payload = { accounts: people.filter(person => matches(person, params)), next: '' }
else if (command === 'rdm.project.member.list') payload = { members: people.filter(person => person.joined && matches(person, params)), next: '',
accountDirectoryAvailable: true, canManageMembers: canManage, canAssignDuties: false, canConfigurePermissions: canManage }
else if (command === 'rdm.project.member.impact') payload = { member: params.member, canRemove: params.member !== 'carol', lastOwner: false,
documents: params.member === 'carol' ? [{ docId: 'private-doc', label: '受控文档', sectionId: 'chapter-1', activeRound: 2 }] : [], reasons: params.member === 'carol' ? ['请先交接文档职责'] : [] }
else if (command === 'rdm.perm.check') {
const person = people.find(person => person.owner === params.member)!
payload = { projectId: 'domain-7', member: person.owner, name: person.name, role: person.role, profRole: person.profRole, actions: permissions(person.owner), canConfigurePermissions: canManage }
} else if (command === 'rdm.perm.matrix') payload = { projectId: 'domain-7', canConfigurePermissions: canManage,
rows: people.filter(person => person.joined && matches(person, params)).map(person => ({ member: person.owner, name: person.name, role: person.role, profRole: person.profRole, actions: permissions(person.owner) })), next: '' }
else {
writes.push({ command, params })
if (command === 'rdm.project.member.add') { const person = people.find(person => person.owner === params.member)!; person.joined = true; person.role = String(params.role); person.projectRole = person.role }
if (command === 'rdm.project.member.set-role') { const person = people.find(person => person.owner === params.member)!; person.role = String(params.role); person.projectRole = person.role }
if (command === 'rdm.perm.unset') overrides.delete(`${params.member}:${params.action}`)
if (command === 'rdm.perm.set') overrides.set(`${params.member}:${params.action}`, params.allow === true)
}
body = { payload }
} else if (path.endsWith('/cards')) body = { cards: [] }
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/')
await page.getByRole('button', { name: '项目设置', exact: true }).click()
return { calls, writes, settingWrites }
}
test('shared settings exclude system controls and restore inheritance with an explicit versioned removal', async ({ page }) => {
const fixture = await settingsPage(page)
await expect(page.getByLabel('项目交付风格', { exact: true })).toHaveValue('formal')
await expect(page.getByLabel('主模型', { exact: true })).toHaveCount(0)
await page.getByLabel('项目交付风格', { exact: true }).selectOption('')
await page.getByRole('button', { name: '保存项目设置', exact: true }).click()
await expect(page.getByText('已保存共享项目配置;按各项标注的生效时机应用', { exact: true })).toBeVisible()
expect(fixture.settingWrites).toEqual([{ path: '/api/settings/project/host-1', body: { values: {}, expectedRevision: 4, unset: ['deliverables.style'] } }])
})
test('members can be found by name and professional role; joined accounts cannot silently change identity', async ({ page }) => {
const fixture = await settingsPage(page)
await page.getByRole('tab', { name: '项目成员', exact: true }).click()
await page.getByLabel('专业角色筛选', { exact: true }).selectOption('systems')
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').fill('李工')
await page.getByRole('option', { name: /^李工/ }).click()
await page.getByRole('button', { name: '添加成员', exact: true }).click()
await expect(page.getByRole('row').filter({ hasText: '李工' })).toContainText('项目成员')
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').fill('李工')
await expect(page.getByRole('option', { name: /^李工/ })).toHaveAttribute('aria-disabled', 'true')
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').press('ArrowDown')
await page.locator('.picker[aria-label="选择人员"]').getByRole('combobox').press('Enter')
await expect(page.getByRole('button', { name: '添加成员', exact: true })).toBeDisabled()
expect(fixture.writes).toEqual([{ command: 'rdm.project.member.add', params: { projectId: 'domain-7', member: 'bob', role: 'member' } }])
expect(fixture.calls.some(call => call.command === 'rdm.account.list' && call.params.query === '李工' && call.params.roleId === 'systems')).toBe(true)
})
test('member removal shows scoped duty impacts and blocks affected members before any write', async ({ page }) => {
const fixture = await settingsPage(page)
await page.getByRole('tab', { name: '项目成员', exact: true }).click()
await page.getByRole('row').filter({ hasText: '陈工' }).getByRole('button', { name: '移除', exact: true }).click()
await expect(page.getByText('受控文档', { exact: false })).toBeVisible()
await expect(page.getByRole('button', { name: '确认移除', exact: true })).toBeDisabled()
expect(fixture.calls.some(call => call.command === 'rdm.project.member.impact' && call.params.member === 'carol')).toBe(true)
expect(fixture.writes).toEqual([])
})
test('ordinary members see their own permissions immediately and cannot open management views', async ({ page }) => {
const fixture = await settingsPage(page, false)
await page.getByRole('tab', { name: '项目权限', exact: true }).click()
await expect(page.getByRole('cell', { name: '读取文档', exact: true })).toBeVisible()
await expect(page.locator('option[value="matrix"]')).toBeDisabled()
await expect(page.locator('.picker[aria-label="选择权限成员"]').getByRole('combobox')).toBeDisabled()
await expect(page.getByLabel('读取文档项目覆盖', { exact: true })).toHaveCount(0)
await expect(page.getByText('创建项目', { exact: true })).toHaveCount(0)
expect(fixture.calls.some(call => call.command === 'rdm.perm.check' && call.params.member === 'alice')).toBe(true)
expect(fixture.writes).toEqual([])
})
test('permission matrix filters members and cap-bound overrides use actual stored values and removal', async ({ page }) => {
const fixture = await settingsPage(page)
await page.getByRole('tab', { name: '项目权限', exact: true }).click()
await expect(page.getByLabel('编制与编辑文档项目覆盖', { exact: true })).toHaveValue('allow')
await expect(page.getByLabel('编制与编辑文档项目覆盖', { exact: true }).locator('option[value="allow"]')).toBeDisabled()
await page.getByLabel('读取文档项目覆盖', { exact: true }).selectOption('default')
await expect(page.getByLabel('读取文档项目覆盖', { exact: true })).toHaveValue('default')
expect(fixture.writes).toEqual([{ command: 'rdm.perm.unset', params: { projectId: 'domain-7', member: 'alice', action: 'doc.read' } }])
await page.getByLabel('权限查看方式', { exact: true }).selectOption('matrix')
await page.getByLabel('专业角色筛选', { exact: true }).selectOption('software')
await expect(page.getByRole('row').filter({ hasText: '陈工' })).toBeVisible()
await expect(page.getByRole('row').filter({ hasText: '项目负责人' })).toHaveCount(0)
await page.getByRole('button', { name: '陈工 · 读取文档 · 允许', exact: true }).click()
await expect(page.getByRole('heading', { name: '陈工 · 权限依据', exact: true })).toBeVisible()
await expect(page.getByText('创建项目', { exact: true })).toHaveCount(0)
expect(fixture.calls.some(call => call.command === 'rdm.perm.matrix' && call.params.roleId === 'software')).toBe(true)
})
+369 -13
View File
@@ -1,5 +1,5 @@
import { test, expect, type Page } from '@playwright/test'
import type { ActivityCard, Artifact, Conversation, StreamEvent } from '../src/contracts'
import type { ActivityCard, Artifact, Conversation, Part, StreamEvent, WorkCardAction } from '../src/contracts'
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
@@ -21,18 +21,20 @@ const confirmation = work('confirm1', 'action_required', { kind: 'confirm', titl
actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }],
})
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14) {
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14,
history?: { events: StreamEvent[]; cardRef?: string; savedParts?: Part[] }, theme: 'light' | 'dark' | 'molv' = 'light') {
let cards = [...initialCards]
let actionCount = 0
let releaseAction: (() => void) | undefined
let holdAction = false
let sent: { text: string; attachmentIds: string[] } | undefined
let sent: { text: string; attachmentIds: string[]; taskRef?: { cardId: string; resultVersion: number } } | undefined
const workActions: { cardId: string; actionId: string; payload: WorkCardAction }[] = []
let uploadNumber = 0
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
await page.addInitScript(({ fontSize }) => {
await page.addInitScript(({ fontSize, theme }) => {
localStorage.setItem('as.conv', 'c1')
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
localStorage.setItem('as.ui', JSON.stringify({ theme, fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
const streams: { url: string; emit: (event: unknown) => void }[] = []
class TestEventSource {
onopen: (() => void) | null = null
@@ -48,7 +50,7 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
close() {}
}
Object.assign(window, { EventSource: TestEventSource, __workStreams: streams })
}, { fontSize })
}, { fontSize, theme })
await page.route('**/api/**', async route => {
const request = route.request()
const url = new URL(request.url())
@@ -64,8 +66,21 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
body = { conversation: conversations.find(c => c.id === id), messages: id === 'c1' ? [
{ id: 'm1', conversationId: id, role: 'user', parts: [{ type: 'text', text: '请整理这三份材料,看看需要优先处理什么' }], createdAt: stamp },
{ id: 'm2', conversationId: id, role: 'assistant', parts: [{ type: 'text', text: '先核对时间安排,再补齐验收条件。其他内容仍在整理。' }], createdAt: stamp },
] : [], runs: [], activeHitl: [], lastSeq: 10 }
...(history ? [{ id: 'm3', conversationId: id, role: 'user', runId: 'run-history', parts: [{ type: 'text', text: '继续核对' }], createdAt: stamp },
{ id: 'm4', conversationId: id, role: 'assistant', runId: 'run-history', parts: history.savedParts ?? [
{ type: 'thinking', process: true, text: '先核对日期' },
{ type: 'text', process: true, text: '开始读取材料' },
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' } },
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok', summary: '读取成功' } },
{ type: 'text', text: '最终回复已完成' },
...(history.cardRef ? [{ type: 'card', card: { cardId: history.cardRef, kind: 'result', data: [] } }] : []),
], createdAt: stamp }] : []),
] : [], runs: history ? [{ id: 'run-history', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'completed', createdAt: stamp, endedAt: stamp }] : [], activeHitl: [], lastSeq: 10 }
} else if (path === '/api/conversations/c1/cards') body = { cards }
else if (path === '/api/conversations/c1/runs/run-history/events') {
const after = Number(url.searchParams.get('after') ?? 0)
body = { events: (history?.events ?? []).filter(event => event.seq > after).slice(0, 500) }
}
else if (path === '/api/conversations/c2/cards') body = { cards: [] }
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
else if (path === '/api/projects/p1/artifacts') body = [artifact]
@@ -77,6 +92,22 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
else if (/\/messages$/.test(path) && request.method() === 'POST') {
sent = request.postDataJSON()
body = { message: { id: 'sent', conversationId: 'c1', role: 'user', parts: [{ type: 'text', text: sent?.text ?? '' }], createdAt: stamp }, run: { id: 'run1', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'running', createdAt: stamp } }
} else if (/^\/api\/cards\/[^/]+\/actions\/work\./.test(path)) {
const cardId = path.split('/')[3]
const actionId = path.split('/').at(-1)!
const payload = request.postDataJSON() as WorkCardAction
workActions.push({ cardId, actionId, payload })
cards = cards.map(card => {
if (card.id !== cardId || !card.work) return card
const next = { ...card, work: { ...card.work } }
if (actionId === 'work.read') next.work.readVersion = payload.resultVersion
else if (next.work.decision) next.work.decision = { ...next.work.decision,
state: actionId === 'work.defer' ? 'deferred' : actionId === 'work.reopen' ? 'open' : 'submitted',
...(actionId === 'work.submit' ? { selectedIds: payload.selectedIds, freeText: payload.freeText, respondedAt: stamp } : {}),
}
return next
})
body = { card: cards.find(card => card.id === cardId) }
} else if (path === '/api/cards/confirm1/actions/confirm') {
actionCount++
if (holdAction) await new Promise<void>(resolve => { releaseAction = resolve })
@@ -88,7 +119,7 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
await page.goto('/')
await expect(page.locator('.chat-title')).toHaveText('整理项目材料')
if (initialCards.length) await expect(page.locator('[data-conversation-work]')).toBeVisible()
return { errors, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
return { errors, workActions, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
async emit(type: StreamEvent['type'], payload: unknown, seq: number) {
await page.evaluate(({ type, payload, seq }) => {
const win = window as typeof window & { __workStreams: { url: string; emit: (e: unknown) => void }[] }
@@ -100,6 +131,145 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
}
}
function optionalWork(id = 'optional-work', mode: 'single' | 'multiple' = 'single'): ActivityCard {
return work(id, 'done', { title: '说明书材料整理', sourceRunId: 'run-history',
work: { resultVersion: 2, readVersion: 0, completedAt: stamp,
result: '# 说明书重点\n\n已整理软件结构、角色权限与操作流程,原文件保留。\n\n'
+ '- **使用角色**:管理员、医生和患者\n- **软件结构**:患者端与医生端连接同一服务端\n- **核心内容**:安装维护、训练操作与安全说明\n\n'
+ '## 可继续整理的内容\n\n可以先阅读摘要了解全貌,也可以单独提取性能参数、设备要求与需要进一步确认的事项。'
+ '整理结果仅依据当前上传的说明书;缺少的材料和不能确认的内容会单独列出,不会补写成已确认的结论。'
+ '如果继续生成交付物,新的结果会更新到独立工作卡,您可以同时讨论其他话题。'
+ '每项结论都应保留材料范围和来源,便于后续复核。\n\n'
+ '## 后续核对\n\n在正式采用整理结果前,建议把产品版本、适用设备、使用人员与预期交付形式分别确认。'
+ '材料中直接写明的内容和整理时发现的问题会分开记录。对没有原文依据的数字、范围或归属,整理结果会保持待确认状态。'
+ '需要比较其他版本时,可补充相应文件,并说明希望重点比较的部分。\n\n最后一条:需要确认使用场景。',
decision: { mode, state: 'open', options: [{ id: 'summary', label: '生成摘要' }, { id: 'facts', label: '提取参数' }] },
},
})
}
test('keeps optional choices nonblocking and supports defer, reopen, free input and saved answers', async ({ page }) => {
const state = await fixture(page, [optionalWork('optional-work', 'multiple')])
const inline = page.locator('[data-conversation-work] [data-card-id="optional-work"]')
await expect(inline.getByRole('checkbox', { name: '生成摘要', exact: true })).toBeVisible()
await inline.getByRole('button', { name: '先不选择', exact: true }).click()
await expect(inline.getByText('已暂缓选择,您可以继续对话')).toBeVisible()
await expect(inline.getByRole('checkbox')).toHaveCount(0)
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('先聊一下本周的安排')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect.poll(() => state.sent?.text).toBe('先聊一下本周的安排')
expect(state.sent?.taskRef).toBeUndefined()
// Receiving the POST is earlier than rendering its legitimate foreground reply.
await expect(page.locator('.msg.assistant[data-msg-id="live:run1"]')).toBeVisible()
const assistantCount = await page.locator('.msg.assistant').count()
await inline.getByRole('button', { name: '继续选择', exact: true }).click()
await inline.getByRole('checkbox', { name: '生成摘要', exact: true }).check()
await inline.getByRole('checkbox', { name: '提取参数', exact: true }).check()
await inline.getByRole('textbox', { name: '补充要求或直接输入需求' }).fill('保留原文中的来源和限制')
await inline.getByRole('button', { name: '开始处理', exact: true }).click()
await expect(inline.getByText('已提交需求', { exact: true })).toBeVisible()
await expect(inline.getByText('保留原文中的来源和限制', { exact: true })).toBeVisible()
await expect(inline.getByRole('textbox')).toHaveCount(0)
expect(state.workActions.filter(action => action.actionId === 'work.submit')).toEqual([{
cardId: 'optional-work', actionId: 'work.submit', payload: { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '保留原文中的来源和限制' },
}])
await expect(page.locator('.msg.assistant')).toHaveCount(assistantCount)
expect(state.errors).toEqual([])
})
test('opens full results in one place, preserves unread until explicit action and references a version in the composer', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1050 })
const state = await fixture(page, [optionalWork()])
await expect(page.locator('.work-result-notice')).toContainText('1 项工作有新结果')
await page.locator('.work-result-notice').click()
const card = page.locator('.right-panel [data-card-id="optional-work"]')
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await expect(card.getByRole('radio', { name: '生成摘要', exact: true })).toBeVisible()
if (process.env.WORKBENCH_FLOW_SCREENSHOT) await page.screenshot({ path: process.env.WORKBENCH_FLOW_SCREENSHOT, fullPage: true })
await card.getByRole('button', { name: '查看完整结果', exact: true }).click()
await expect(card.getByText('最后一条:需要确认使用场景。', { exact: false })).toBeVisible()
expect(state.workActions.filter(action => action.actionId === 'work.read')).toHaveLength(0)
await card.getByRole('button', { name: '基于此结果继续', exact: true }).click()
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toContainText('第 2 版结果')
await expect.poll(() => state.workActions.filter(action => action.actionId === 'work.read').length).toBe(1)
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('解释一下需要确认的事项')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect.poll(() => state.sent?.taskRef).toEqual({ cardId: 'optional-work', resultVersion: 2 })
expect(state.sent?.text).toBe('解释一下需要确认的事项')
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toHaveCount(0)
await expect(page.locator('.msg.user time[datetime]')).toHaveCount(2)
await expect(page.locator('.msg.assistant time[datetime]')).toHaveCount(2)
expect(state.errors).toEqual([])
})
test('retains text, uploads and the result reference when mixed input is not supported', async ({ page }) => {
const state = await fixture(page, [optionalWork()])
await page.locator('.work-result-notice').click()
await page.getByRole('button', { name: '基于此结果继续', exact: true }).click()
await page.getByLabel('添加材料', { exact: true }).setInputFiles({ name: '补充材料.txt', mimeType: 'text/plain', buffer: Buffer.from('补充要求') })
const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true })
await input.fill('结合补充材料解释重点')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect(input).toHaveValue('结合补充材料解释重点')
await expect(page.locator('.attach-chip')).toContainText('补充材料.txt')
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toBeVisible()
expect(state.sent).toBeUndefined()
})
test('keeps middle and right scrolling independent while retaining full execution output', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 800 })
const events: StreamEvent[] = [
{ seq: 1, type: 'thinking.delta', payload: { text: '已保存的核对思路' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
{ seq: 2, type: 'tool.call', payload: { callId: 'call-record', name: 'fs_read_file', arguments: '{"path":"说明书"}' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
{ seq: 3, type: 'tool.output', payload: { callId: 'call-record', text: '完整的文档读取记录' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
{ seq: 4, type: 'tool.result', payload: { callId: 'call-record', status: 'ok' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
]
const cards = [optionalWork(), ...Array.from({ length: 15 }, (_, index) => work(`active-${index}`, 'active', { title: `其他工作 ${index + 1}` }))]
const state = await fixture(page, cards, 14, { events, savedParts: [{ type: 'text', text: '继续讨论当前主题。\n\n'.repeat(100) }] })
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const right = page.locator('.right-panel [data-work-scroll]')
const chat = page.locator('.chat-body')
await right.evaluate(element => { element.scrollTop = 180 })
const rightTop = await right.evaluate(element => element.scrollTop)
await chat.evaluate(element => { element.scrollTop = 80; element.dispatchEvent(new Event('scroll', { bubbles: true })) })
expect(await right.evaluate(element => element.scrollTop)).toBe(rightTop)
const chatTop = await chat.evaluate(element => element.scrollTop)
await right.evaluate(element => { element.scrollTop = 240; element.dispatchEvent(new Event('scroll', { bubbles: true })) })
expect(await chat.evaluate(element => element.scrollTop)).toBe(chatTop)
await page.locator('.right-panel [data-card-id="optional-work"]').getByRole('button', { name: '查看完整执行记录', exact: true }).click()
const record = page.getByRole('region', { name: '说明书材料整理的执行记录' })
await expect(record.getByText('已保存的核对思路', { exact: true })).toBeVisible()
await record.getByRole('button', { name: /查看记录/ }).click()
await expect(record.getByText('完整的文档读取记录', { exact: true })).toBeVisible()
await expect(page.locator('.work-execution-record')).toHaveCount(1)
await record.getByRole('button', { name: '返回工作', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id="optional-work"]')).toBeVisible()
expect(state.errors).toEqual([])
})
test('folds read task history newest first and retains submitted choices after reload', async ({ page }) => {
const older = optionalWork('older')
const latest = optionalWork('latest')
older.title = '旧材料整理'; latest.title = '新材料整理'
older.work = { ...older.work!, readVersion: 2, decision: { mode: 'single', state: 'submitted', selectedIds: ['summary'], freeText: '保留参数表', respondedAt: stamp,
options: [{ id: 'summary', label: '生成摘要' }] } }
latest.work = { ...latest.work!, readVersion: 2, decision: { mode: 'single', state: 'deferred' } }
latest.updatedAt = '2026-10-13T10:00:00Z'
const state = await fixture(page, [older, latest])
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(0)
await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(2)
expect(await page.locator('.right-panel [data-card-id]').evaluateAll(elements => elements.map(element => element.getAttribute('data-card-id')))).toEqual(['latest', 'older'])
await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible()
await page.reload()
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible()
await expect(page.locator('.right-panel [data-card-id="older"]').getByRole('textbox')).toHaveCount(0)
expect(state.errors).toEqual([])
})
test('keeps real summary and details accessible with right pane closed', async ({ page }) => {
const state = await fixture(page)
await expect(page.locator('.right-panel')).toBeHidden()
@@ -114,17 +284,105 @@ test('keeps real summary and details accessible with right pane closed', async (
expect(state.errors).toEqual([])
})
test('keeps one expanded ordinary summary when the desktop work pane opens', async ({ page }) => {
test('places the full work card only in the desktop work pane', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const state = await fixture(page)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await expect(page.locator('.right-panel .acard')).toHaveCount(1)
await expect(page.locator('[data-conversation-work] .acard')).toHaveCount(0)
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: /整理项目材料 · 查看摘要/ })).toBeVisible()
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await expect(page.locator('.companion-topbar').getByRole('button', { name: '打开工作' })).toHaveCount(0)
await expect(page.locator('.right-panel').getByRole('button', { name: /工作/ }).first()).toBeVisible()
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
expect(state.errors).toEqual([])
})
test('uses a focused reading column and a compact authored-message shape', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 })
const state = await fixture(page)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const conversation = page.locator('.chat-body .msg').first()
const authored = page.locator('.chat-body .msg.user').first()
const answer = page.locator('.chat-body .msg.assistant').first()
const [conversationBox, authoredBox, answerBox] = await Promise.all([
conversation.boundingBox(), authored.boundingBox(), answer.boundingBox(),
])
expect(conversationBox).not.toBeNull()
expect(authoredBox).not.toBeNull()
expect(answerBox).not.toBeNull()
expect(conversationBox!.width).toBeLessThanOrEqual(840)
expect(authoredBox!.width).toBeLessThanOrEqual(680)
expect(authoredBox!.x + authoredBox!.width).toBeCloseTo(answerBox!.x + answerBox!.width, 0)
const surfaces = await page.evaluate(() => {
const pick = (selector: string) => {
const element = document.querySelector(selector)
if (!element) throw new Error(`missing ${selector}`)
const style = getComputedStyle(element)
return {
background: style.backgroundColor,
color: style.color,
borderRadius: Number.parseFloat(style.borderRadius),
paddingLeft: Number.parseFloat(style.paddingLeft),
}
}
return {
chrome: pick('.companion-topbar'),
context: pick('.left-nav'),
conversation: pick('.main'),
answer: pick('.msg.assistant'),
authored: pick('.msg.user .bubble'),
}
})
const rgb = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
const distance = (a: string, b: string) => rgb(a).reduce((sum, value, index) => sum + Math.abs(value - rgb(b)[index]), 0)
expect(distance(surfaces.chrome.background, surfaces.conversation.background)).toBeGreaterThan(300)
expect(distance(surfaces.context.background, surfaces.conversation.background)).toBeGreaterThan(300)
expect(distance(surfaces.authored.background, surfaces.answer.background)).toBeGreaterThan(300)
expect(surfaces.answer.borderRadius).toBeGreaterThanOrEqual(12)
expect(surfaces.answer.paddingLeft).toBeGreaterThanOrEqual(20)
const activeCard = page.locator('.right-panel .acard.status-active')
await expect(activeCard).toBeVisible()
const cardStyle = await activeCard.evaluate(element => {
const style = getComputedStyle(element)
return { background: style.backgroundColor, borderLeftWidth: style.borderLeftWidth }
})
const panelBackground = await page.locator('.right-panel').evaluate(element => getComputedStyle(element).backgroundColor)
expect(cardStyle.background).not.toBe(panelBackground)
expect(Number.parseFloat(cardStyle.borderLeftWidth)).toBeGreaterThanOrEqual(3)
expect(state.errors).toEqual([])
})
for (const theme of ['light', 'dark', 'molv'] as const) {
test(`keeps the workbench frame legible in ${theme}`, async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const state = await fixture(page, [progress], 14, undefined, theme)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const surfaces = await page.evaluate(() => ['.companion-topbar', '.left-nav', '.msg.user .bubble', '.msg.assistant'].map(selector => {
const element = document.querySelector(selector)
if (!element) throw new Error(`missing ${selector}`)
const style = getComputedStyle(element)
return { selector, foreground: style.color, background: style.backgroundColor }
}))
const channels = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
const luminance = (value: string) => {
const linear = channels(value).map(channel => {
const normalized = channel / 255
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
})
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
}
for (const surface of surfaces) {
const lighter = Math.max(luminance(surface.foreground), luminance(surface.background))
const darker = Math.min(luminance(surface.foreground), luminance(surface.background))
expect((lighter + 0.05) / (darker + 0.05), `${surface.selector} contrast in ${theme}`).toBeGreaterThanOrEqual(4.5)
}
expect(state.errors).toEqual([])
})
}
test('preserves the selected material, physical scroll and focus when background work finishes', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const tabbed = work('work1', 'active', { body: [...progress.body!, {
@@ -160,19 +418,117 @@ test('preserves the selected material, physical scroll and focus when background
expect(state.errors).toEqual([])
})
test('shares submitting feedback between conversation and work confirmations', async ({ page }) => {
test('keeps one confirmation action location when the work pane is visible', async ({ page }) => {
const state = await fixture(page, [confirmation])
await page.getByRole('button', { name: '打开工作', exact: true }).click()
state.hold()
await page.locator('[data-conversation-work]').getByRole('button', { name: '确认生成', exact: true }).click()
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true }).click()
await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: '暂不生成', exact: true })).toBeDisabled()
await expect(page.locator('.chat-body').getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
expect(state.actionCount).toBe(1)
state.release()
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
expect(state.errors).toEqual([])
})
test('keeps each run process folded and reloadable after the final reply', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
({ seq, type, payload, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 })
const events = [
event(1, 'thinking.delta', { index: 0, text: '先核对日期' }),
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
event(4, 'tool.output', { callId: 'call-1', text: '完整工具输出' }),
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
event(6, 'run.phase', { phase: 'finalizing' }),
event(7, 'message.delta', { index: 1, text: '最终回复已完成' }),
]
const state = await fixture(page, [], 14, { events })
const message = page.locator('[data-msg-id="m4"]')
await expect(message.getByText('最终回复已完成')).toBeVisible()
await expect(message.getByText('开始读取材料')).toHaveCount(0)
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('先核对日期')).toBeVisible()
await expect(message.getByText('开始读取材料')).toBeVisible()
await message.locator('.tool-block-head').click()
await expect(message.getByText('完整工具输出')).toBeVisible()
await page.reload()
const restored = page.locator('[data-msg-id="m4"]')
await expect(restored.getByText('最终回复已完成')).toBeVisible()
await restored.getByRole('button', { name: /执行记录/ }).click()
await expect(restored.getByText('先核对日期')).toBeVisible()
await restored.locator('.tool-block-head').click()
await expect(restored.getByText('完整工具输出')).toBeVisible()
expect(state.errors).toEqual([])
})
test('replay enriches saved notes and keeps collaboration progress after completion', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
const events = [
event(1, 'message.delta', { index: 0, text: '<think>已解析的思考</think>' }),
event(2, 'message.delta', { index: 1, text: '开始读取' }),
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
event(4, 'tool.output', { callId: 'call-1', text: '完整输出留存' }),
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
event(6, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(7, 'run.phase', { phase: 'finalizing' }),
event(8, 'message.delta', { index: 1, text: '最终回复已完成' }),
event(9, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
event(10, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
]
const savedParts: Part[] = [
{ type: 'text', process: true, text: '模型在输出前失败,正在重试' },
{ type: 'thinking', process: true, text: '已解析的思考' },
{ type: 'text', process: true, text: '开始读取材料' },
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell' } },
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } },
{ type: 'text', text: '最终回复已完成' },
]
await fixture(page, [], 14, { events, savedParts })
const message = page.locator('[data-msg-id="m4"]')
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('模型在输出前失败,正在重试')).toBeVisible()
await expect(message.getByText('已解析的思考')).toBeVisible()
await expect(message.getByText('开始读取材料')).toBeVisible()
await expect(message.getByText('开始读取', { exact: true })).toHaveCount(0)
await expect(message.getByText('协作进展:正在核对附录')).toBeVisible()
await expect(message.getByText('协作结束:附录已核对')).toBeVisible()
await expect(message.getByText('<think>已解析的思考</think>')).toHaveCount(0)
await message.locator('.tool-block-head').click()
await expect(message.getByText('完整输出留存')).toBeVisible()
})
test('keeps a child completion note when the final reply uses the same status words', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
await fixture(page, [], 14, { events: [
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(2, 'run.phase', { phase: 'finalizing' }),
event(3, 'message.delta', { index: 0, text: '已完成' }),
event(4, 'agent.result', { agentRun: 'child', status: 'completed', summary: '已完成' }, 'child'),
], savedParts: [{ type: 'text', process: true, text: '记录已完成' }, { type: 'text', text: '已完成' }] })
const message = page.locator('[data-msg-id="m4"]')
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('协作结束:已完成')).toBeVisible()
})
test('locates a completed card even when work is filtered to project pending items', async ({ page }) => {
const result = work('result1', 'done', { kind: 'result', title: '材料核对结果',
body: [{ type: 'markdown', markdown: '完整结论在这里' }] })
await fixture(page, [result], 14, { events: [], cardRef: 'result1' })
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const workPane = page.locator('.right-panel')
await workPane.locator('.inbox-scope').getByRole('button', { name: '项目' }).click()
await workPane.locator('.inbox-scope').getByRole('button', { name: '待处理' }).click()
await expect(workPane.getByText('完整结论在这里')).toHaveCount(0)
await page.locator('[data-msg-id="m4"] .card-anchor-locate').click()
await expect(workPane.getByText('完整结论在这里')).toBeVisible()
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '本会话' })).toHaveClass(/active/)
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '全部' })).toHaveClass(/active/)
})
test('opens the one file preview and preserves reading during background updates', async ({ page }) => {
const result = work('result1', 'done', { kind: 'result', title: '材料摘要已准备好', body: [{ type: 'doc-ref', docId: 'doc1' }] })
const state = await fixture(page, [result])
+1 -1
View File
@@ -1,3 +1,3 @@
{
"exports": 43
"exports": 51
}
+59 -31
View File
@@ -5,7 +5,7 @@ import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard, SurfaceMeta, SurfaceSnapshot,
ActivityCard, SurfaceMeta, SurfaceSnapshot, WorkCardAction,
} from './contracts'
const BASE = ''
@@ -15,11 +15,28 @@ const BASE = ''
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
export interface SettingsView {
schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown>
projectRevision?: number; projectCanConfigure?: boolean
personalSchema?: SettingDesc[]; personalDefaults?: Record<string, unknown>
personalUser?: Record<string, unknown>; personalProject?: Record<string, unknown>
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
* passwordChangeMode 由服务端从会话派生;grant_setup 与 one_time_password
*(管理员生成的一次性登录密码会话)免当前密码,current_required(管理员
* 指定/生成的初始密码首登)必须校验当前密码。 */
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
export interface AccountRecord {
userId: string; owner: string; profile: AccountProfile; profileVersion: number
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
initializationState?: string; initializationGeneration?: number
}
export interface ExecEnvView {
configured: boolean
root?: string
python?: string
base?: string
layers?: string[]
requests?: { id: string; conversationId: string; owner: string; spec: string; reason: string; status: string; layerId: string; detail: string; approver: string }[]
bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[]
}
async function checked(res: Response): Promise<Response> {
@@ -55,7 +72,7 @@ export const api = {
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
@@ -63,14 +80,6 @@ export const api = {
auth.beginSession()
return me
}),
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
auth.beginSession()
return me
}),
logout: () => {
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
auth.clear()
@@ -80,12 +89,26 @@ export const api = {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
}),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
// ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)----
adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)),
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
// 忘记密码的唯一处置:管理员生成一次性登录密码(明文只在本次响应出现);
// 用户以该密码登录后进入 one_time_password 强制改密门,设置新密码后才放行。
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; oneTimePassword: string; mustChangePassword: boolean }>(r)),
// ---- 本人:可选资料(须完整密码会话)----
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
adminExecEnvGC: () => auth.fetch(`${BASE}/api/admin/exec-env/gc`, { method: 'POST' }).then(r => json<{ removed: string[] | null }>(r)),
adminExecEnvWheel: (file: File, conversationId: string, reason: string) => {
const body = new FormData()
body.set('wheel', file)
body.set('conversationId', conversationId)
body.set('reason', reason)
return auth.fetch(`${BASE}/api/admin/exec-env/wheels`, { method: 'POST', body }).then(r => json<{ file: string; outcome: { status: string; requestId?: string; detail?: string } }>(r))
},
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
@@ -93,8 +116,13 @@ export const api = {
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId }) })
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
.then(r => json<{ events: StreamEvent[] }>(r)),
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string, taskRef?: { cardId: string; resultVersion: number }) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId,
...(taskRef ? { taskRef: { cardId: taskRef.cardId, resultVersion: taskRef.resultVersion } } : {}) }) })
.then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }>
@@ -108,8 +136,8 @@ export const api = {
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, freeText?: string) =>
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ freeText }) })
cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) =>
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(typeof payload === 'object' ? payload : { freeText: payload }) })
.then(r => json<{ card: ActivityCard; receipt?: Message }>(r)),
cardPin: (cardId: string, pinned: boolean) =>
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
@@ -143,14 +171,14 @@ export const api = {
saveProfile: (p: AgentProfile) => auth.fetch(`${BASE}/api/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }),
deleteProfile: (id: string) => auth.fetch(`${BASE}/api/profiles/${id}`, { method: 'DELETE' }),
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + encodeURIComponent(projectId) : ''}`).then(r => json<SettingsView>(r)),
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
putProjectSettings: (projectId: string, values: Record<string, unknown>, expectedRevision: number, unset?: string[]) => auth.fetch(`${BASE}/api/settings/project/${encodeURIComponent(projectId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values, expectedRevision, ...(unset?.length ? { unset } : {}) }) }).then(r => json<{ ok: boolean; projectRevision: number }>(r)),
putPersonalProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${encodeURIComponent(projectId)}/personal`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r =>
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
// P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。
getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r =>
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown>; systemRevision?: number }>(r)),
putSystemSettings: (values: Record<string, unknown>, expectedRevision: number, unset?: string[]) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values, expectedRevision, ...(unset?.length ? { unset } : {}) }) }).then(checked),
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown> }>(r)),
putSystemSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
+112 -12
View File
@@ -3,8 +3,11 @@
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
padding: 12px 20px;
min-height: 64px;
padding: 12px 24px;
background: var(--bg-panel);
}
.work-companion-chat .chat-title { font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.025em; }
.work-companion-chat .chat-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-companion-chat .chat-header > .mk-col-root { position: relative; }
.work-companion-chat .chat-header > .mk-col-root > .mk-col {
@@ -36,9 +39,71 @@
.companion-setting select { max-width: 100%; padding: 4px 8px; background: var(--bg-panel); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-caption); }
.companion-setting .session-note { flex: 1 1 100%; min-width: 0; width: 100%; }
.companion-setting-note { flex: 1 1 100%; margin: 0; color: var(--text-dim); font-size: var(--fs-ui-sm); line-height: 1.6; }
.work-companion-chat .chat-body { padding: 24px clamp(16px, 5vw, 64px); }
.work-companion-chat .input-bar { padding: 8px clamp(16px, 5vw, 64px) 16px; }
.work-companion-chat .input-row { flex-direction: column; align-items: stretch; padding: 12px; box-shadow: none; }
.work-companion-chat .chat-body {
display: flex;
flex-direction: column;
align-items: center;
padding: 36px clamp(20px, 6vw, 96px) 48px;
scroll-padding-block: 36px;
}
.work-companion-chat .chat-body > * {
width: min(100%, 840px);
margin-left: auto;
margin-right: auto;
}
.work-companion-chat .chat-body > .msg { margin-bottom: 32px; }
.work-companion-chat .chat-body > .msg.user {
width: min(80%, 680px);
margin-right: max(0px, calc((100% - 840px) / 2));
margin-left: auto;
}
.work-companion-chat .chat-body > .msg.assistant {
padding: 20px 24px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--bg-raised);
box-shadow: var(--shadow-md);
}
.work-companion-chat .msg-role {
margin-bottom: 10px;
color: var(--text-dim);
font-size: var(--fs-ui-caption);
font-weight: 600;
}
.work-companion-chat .msg.user .msg-role { justify-content: flex-end; }
.message-timestamp { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 400; white-space: nowrap; }
.work-companion-chat .msg.assistant .message-timestamp { margin-left: auto; }
.work-result-notice, .composer-work-reference { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; box-sizing: border-box; padding: 8px 12px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-raised); color: var(--text-dim); font-size: var(--fs-ui-caption); text-align: left; }
.work-result-notice { cursor: pointer; }
.work-result-notice:hover { background: var(--bg-hover); color: var(--text); }
.work-result-notice > span:last-child { margin-left: auto; color: var(--accent-text); }
.composer-work-reference > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.work-execution-record { min-width: 0; padding: 16px; color: var(--text); background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); }
.work-record-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.work-record-heading strong { min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-ui-base); }
.work-record-heading .btn { flex-shrink: 0; font-size: var(--fs-ui-caption); }
.work-execution-record .tl { margin-top: 16px; }
.work-execution-record .part-text { overflow-wrap: anywhere; }
.work-companion-chat .msg.assistant .role-mark {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
}
.work-companion-chat .msg.assistant .part-text { color: var(--text); font-size: var(--fs-ui-base); line-height: 1.8; }
.work-companion-chat .input-bar {
padding: 12px clamp(20px, 6vw, 96px) 20px;
border-top: 1px solid var(--border-soft);
background: var(--bg-panel);
}
.work-companion-chat .input-bar > * { width: min(100%, 840px); margin-left: auto; margin-right: auto; }
.work-companion-chat .input-row {
flex-direction: column;
align-items: stretch;
padding: 14px;
border-color: var(--border-strong);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
}
.work-companion-chat .input-row textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 4px; line-height: 1.6; font-size: var(--fs-ui-base); }
.companion-compose-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
.companion-compose-spacer { flex: 1; }
@@ -47,7 +112,7 @@
.companion-add-material:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.companion-file-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); }
.companion-input-hint { font-size: var(--fs-ui-sm); color: var(--text-dim); }
.companion-operation-trigger { margin-bottom: 8px; }
.companion-operation-trigger { margin-bottom: 8px; color: var(--text-dim); }
.companion-operation-body { padding: 12px; margin-bottom: 12px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); }
.companion-operation-body .companion-setting { margin-bottom: 8px; }
.work-companion-chat .input-status { flex-wrap: wrap; gap: 4px 8px; padding: 8px 0; line-height: 1.5; }
@@ -57,28 +122,63 @@
.companion-remove-material { display: inline-flex; align-items: center; justify-content: center; padding: 4px; color: var(--text-dim); background: transparent; border: 0; border-radius: var(--radius-xs); cursor: pointer; }
.companion-remove-material:hover { background: var(--bg-hover); color: var(--text); }
.work-companion-chat .attach-chip { max-width: 100%; overflow-wrap: anywhere; }
.companion-empty { min-height: 240px; height: 100%; text-align: center; line-height: 1.7; }
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); }
.companion-empty { min-height: 320px; height: 100%; text-align: center; line-height: 1.7; }
.companion-empty .es-mark { width: 52px; height: 52px; margin-bottom: 8px; background: var(--bg-panel); box-shadow: var(--shadow-md); }
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.02em; }
.companion-examples { display: grid; gap: 8px; width: min(100%, 32rem); margin-top: 12px; }
.companion-example { padding: 12px 16px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
.companion-example:hover { color: var(--text); background: var(--bg-hover); }
.companion-example { padding: 14px 16px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
.companion-example:hover { color: var(--text); background: var(--bg-raised); border-color: var(--border-strong); }
.conversation-work { margin: 24px 0 16px; min-width: 0; }
.conversation-work-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 12px; color: var(--text-dim); font-size: var(--fs-ui-caption); }
.conversation-work-heading > span:first-child { color: var(--text); font-weight: 600; }
.conversation-work-heading > span:first-child { color: var(--text); font-size: var(--fs-ui-lg); font-weight: 650; }
.conversation-work-current, .conversation-work-history { display: grid; gap: 12px; min-width: 0; }
.conversation-work-more { padding: 12px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); }
.conversation-work .acard { margin: 0; }
.conversation-action-anchor { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; width: 100%; margin-top: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); color: var(--text); text-align: left; font-size: var(--fs-ui-caption); cursor: pointer; }
.conversation-action-anchor > span:last-child { color: var(--accent-text); }
.conversation-action-anchor:hover { background: var(--bg-hover); }
.work-companion-chat .live-bar { flex-wrap: wrap; }
.work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; }
@media (max-width: 540px) {
.work-companion-chat .chat-header { padding: 12px 16px; gap: 8px; }
.work-companion-chat .chat-body { padding: 24px 16px 32px; }
.work-companion-chat .chat-body > .msg.user { width: 92%; margin-right: 0; }
.work-companion-chat .input-bar { padding: 10px 12px 14px; }
.companion-input-hint { display: none; }
.companion-empty { min-height: 200px; }
.work-companion-chat .hitl-choices { grid-template-columns: minmax(0, 1fr); }
}
.work-companion-chat .msg.user .bubble { background: var(--bg-active); }
.work-companion-chat .msg.user .bubble {
padding: 14px 18px;
background: var(--primary);
border: 1px solid var(--primary);
border-radius: var(--radius-lg) var(--radius-lg) var(--radius-xs) var(--radius-lg);
color: var(--primary-foreground);
line-height: 1.7;
box-shadow: var(--shadow-md);
}
.work-companion-chat .process-collapse {
margin: 12px 0 16px;
border-color: var(--border);
background: var(--bg-panel);
}
.work-companion-chat .process-collapse .process-collapse-summary { padding: 9px 14px; color: var(--text-dim); }
.work-companion-chat .process-collapse .process-collapse-summary:hover { background: var(--bg-hover); color: var(--text); }
.work-companion-chat .process-collapse .body { padding: 8px 14px 14px; border-top: 1px solid var(--border-soft); }
.companion-message-copy { padding: 4px 8px; border: 0; background: transparent; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; }
.companion-message-copy:hover { background: var(--bg-hover); color: var(--text); }
.companion-message-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; }
.companion-message-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 0;
opacity: 0;
}
.work-companion-chat .msg:hover .companion-message-actions,
.work-companion-chat .msg:focus-within .companion-message-actions { opacity: 1; }
.work-companion-chat .msg-tools { gap: 4px; }
@media (hover: none) {
.companion-message-actions { opacity: 1; }
}
+117 -22
View File
@@ -9,6 +9,20 @@
.companion-app .left-nav,
.companion-app .main,
.companion-app .right-panel { min-height: 0; max-width: 100%; overflow: hidden; }
.companion-app .left-nav,
.companion-app .companion-topbar {
--text: var(--workbench-chrome-text);
--text-dim: var(--workbench-chrome-dim);
--text-faint: var(--workbench-chrome-faint);
--bg-hover: var(--workbench-chrome-hover);
--bg-active: var(--workbench-chrome-active);
--border: var(--workbench-chrome-active);
--border-soft: var(--workbench-chrome-hover);
--border-strong: var(--workbench-chrome-dim);
background: var(--workbench-chrome);
color: var(--workbench-chrome-text);
border-color: var(--workbench-chrome-hover);
}
.companion-app .left-nav { transition: none; }
.companion-app .left-nav[hidden],
.companion-app .right-panel[hidden],
@@ -16,12 +30,58 @@
.companion-app .icon-only[hidden] { display: none !important; }
.shell-context-content { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
.companion-app .companion-topbar { height: auto; min-height: 44px; gap: 8px; padding: 4px 12px; }
.companion-app .companion-topbar {
height: auto;
min-height: 52px;
gap: 12px;
padding: 8px 16px;
box-shadow: var(--shadow-md);
}
.topbar-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
.companion-topbar .topbar-name { font-size: var(--fs-ui-caption); white-space: nowrap; }
.companion-topbar .topbar-name { font-size: var(--fs-ui-lg); font-weight: 650; letter-spacing: -0.02em; white-space: nowrap; }
.companion-app .right-panel .tabs {
min-height: 52px;
align-items: flex-end;
gap: 16px;
padding: 0 20px;
background: var(--bg-panel);
}
.companion-app .right-panel .tab {
min-height: 52px;
padding: 0 2px;
color: var(--text-dim);
font-size: var(--fs-ui-base);
font-weight: 600;
}
.companion-app .right-panel .tab:hover { color: var(--text); }
.companion-app .right-panel .tab-body { padding: 20px; }
.companion-app .right-panel .inbox-zone-label { margin: 20px 0 8px; color: var(--text); font-size: var(--fs-ui-caption); font-weight: 650; }
.companion-app .right-panel .acard { margin: 12px 0; border-color: var(--border); padding: 18px; }
.companion-app .right-panel .acard.status-active {
border-left: 3px solid var(--work-focus-border);
background: var(--work-focus-surface);
box-shadow: var(--shadow-md);
}
.companion-app .right-panel .acard-title { color: var(--text); font-size: var(--fs-ui-lg); line-height: 1.4; }
.companion-app .right-panel .acard-status {
display: inline-flex;
width: fit-content;
margin-top: 8px;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--bg-active);
color: var(--text);
font-weight: 600;
}
.companion-app .right-panel .acard-body { margin-top: 16px; }
.companion-app .right-panel .acard-summary-row { grid-template-columns: minmax(64px, auto) minmax(0, 1fr); gap: 12px; margin: 12px 0; }
.companion-app .right-panel .acard-summary-row > span,
.companion-app .right-panel .acard-summary-label { color: var(--text-dim); font-weight: 600; }
.companion-app .right-panel .acard-summary-row p,
.companion-app .right-panel .acard-findings li { color: var(--text); font-size: var(--fs-ui-base); }
.companion-topbar .brand-mark {
background: var(--primary-sidebar);
color: var(--primary-sidebar-foreground);
background: var(--workbench-chrome-text);
color: var(--workbench-chrome);
box-shadow: none;
border-radius: var(--radius-sm) !important;
}
@@ -37,39 +97,52 @@
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-sidebar-dim);
color: var(--workbench-chrome-dim);
font-size: var(--fs-ui-caption);
cursor: pointer;
white-space: nowrap;
}
.shell-nav-button:hover,
.shell-return:hover { background: var(--bg-hover); color: var(--text-sidebar); }
.shell-nav-button[aria-pressed='true'] { background: var(--bg-active); color: var(--text-sidebar); }
.shell-return:hover { background: var(--workbench-chrome-hover); color: var(--workbench-chrome-text); }
.shell-nav-button[aria-pressed='true'] { background: var(--workbench-chrome-active); color: var(--workbench-chrome-text); }
.shell-nav-button svg,
.shell-return svg { flex: none; }
.shell-back-icon { transform: rotate(180deg); }
.shell-pending {
padding: 0 4px;
border-radius: var(--radius-xs);
background: var(--bg-active);
color: var(--text-sidebar);
background: var(--workbench-chrome-active);
color: var(--workbench-chrome-text);
font-size: var(--fs-ui-xs);
font-variant-numeric: tabular-nums;
}
.companion-app .tsb-brand { gap: 4px; padding: 16px 12px 8px; }
.companion-app .tsb-projrow { min-height: 36px; height: auto; border-color: transparent; background: none; padding: 4px 8px; }
.companion-app .tsb-brand { gap: 8px; padding: 16px 14px 12px; border-bottom: 1px solid var(--border-soft); }
.companion-app .tsb-projrow { min-height: 40px; height: auto; border-color: transparent; background: none; padding: 6px 10px; }
.companion-app .tsb-projrow:hover { background: var(--bg-hover); }
.companion-app .tsb-projrow-name { flex: 1; }
.companion-app .tsb-projrow-name { flex: 1; font-size: var(--fs-ui-base); font-weight: 650; }
.companion-app .tsb-projrow-stage { max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); border-radius: var(--radius-sm); }
.companion-app .tsb-iconbtn { width: 32px; height: 32px; flex: none; }
.companion-app .tsb-actions { padding: 8px 12px 12px; gap: 12px; }
.companion-app .tsb-newtask { min-height: 40px; height: auto; box-shadow: none; transition: none; }
.companion-app .tsb-actions { padding: 12px 14px; gap: 10px; }
.companion-app .tsb-newtask {
min-height: 40px;
height: auto;
background: var(--workbench-chrome-text);
color: var(--workbench-chrome);
box-shadow: none;
transition: none;
}
.companion-app .tsb-newtask:hover { background: var(--workbench-chrome-text); }
.companion-app .tsb-newtask-main { justify-content: center; padding: 8px 12px; }
.companion-app .tsb-newtask-caret { width: 36px; flex: none; }
.companion-app .tsb-search { min-height: 36px; height: auto; padding: 8px 12px; }
.companion-app .tsb-orgrow { padding-bottom: 4px; }
.companion-app .tsb-task { min-height: 40px; height: auto; gap: 4px; transition: none; }
.companion-app .tsb-newtask-caret { width: 36px; flex: none; border-left-color: var(--workbench-chrome-inverse-border); }
.companion-app .tsb-newtask-caret:hover { background: var(--workbench-chrome-inverse-hover); }
.companion-app .tsb-search { min-height: 38px; height: auto; padding: 8px 12px; background: var(--workbench-chrome-hover); border-color: var(--workbench-chrome-hover); }
.companion-app .tsb-search:focus-within { background: var(--workbench-chrome-active); border-color: var(--workbench-chrome-dim); }
.companion-app .tsb-orgrow { padding: 4px 16px 8px; }
.companion-app .tsb-seclabel { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 600; }
.companion-app .tsb-scroll { padding: 0 8px 12px; }
.companion-app .tsb-task { min-height: 42px; height: auto; gap: 6px; padding: 0 10px; transition: none; }
.companion-app .tsb-task.active { background: var(--bg-active); color: var(--text); }
.companion-app .tsb-task-open {
display: flex;
align-items: center;
@@ -85,6 +158,7 @@
cursor: pointer;
}
.companion-app .tsb-task-open > svg { flex: none; }
.companion-app .tsb-task-t { font-size: var(--fs-ui-base); }
.companion-app .tsb-task-tm { color: var(--text-dim); }
.companion-app .tsb-task:focus-within .tsb-row-acts { display: inline-flex; }
.companion-app .tsb-task:focus-within .tsb-task-tm { display: none; }
@@ -103,11 +177,32 @@
cursor: pointer;
}
.companion-app .tsb-row-menu:hover { background: var(--bg-hover); color: var(--text); }
.companion-app .tsb-bottom { padding: 8px 12px; gap: 4px; }
.companion-app .tsb-tool { min-height: 36px; }
.companion-app .tsb-bottom { padding: 10px 12px; gap: 4px; background: var(--workbench-chrome-hover); }
.companion-app .tsb-tool { min-height: 38px; padding: 7px 10px; }
.companion-app .tsb-avatar { background: var(--bg-active); color: var(--text); box-shadow: none; border-radius: var(--radius-full); }
.companion-app .tsb-user-sub { color: var(--text-dim); }
.companion-app .tsb-user { min-height: 44px; height: auto; }
.companion-app .tsb-footer { padding: 10px 12px; }
.companion-app .tsb-user { min-height: 46px; height: auto; }
.companion-app .right-panel .inbox-scope {
gap: 4px;
margin: 14px 20px 0;
padding: 4px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--bg);
}
.companion-app .right-panel .inbox-scope button {
flex: 1;
min-width: 0;
border: none;
border-radius: var(--radius-sm);
padding: 5px 10px;
opacity: 1;
color: var(--text-dim);
font-size: var(--fs-ui-sm);
white-space: nowrap;
}
.companion-app .right-panel .inbox-scope button.active { background: var(--bg-raised); box-shadow: var(--shadow-md); color: var(--text); }
.companion-app .tsb-menu {
min-width: min(216px, calc(100vw - 16px));
max-width: min(360px, calc(100vw - 16px));
@@ -116,7 +211,7 @@
}
.companion-app .tsb-mi { overflow-wrap: anywhere; }
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px; }
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px 12px; }
.companion-app.compact .shell-navigation { width: 100%; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.companion-app.compact .shell-nav-button { min-width: 0; min-height: 40px; flex-wrap: wrap; gap: 0 4px; padding: 4px; }
.companion-app.compact .shell-nav-button > svg { display: none; }
+10 -11
View File
@@ -4,10 +4,11 @@
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
import { useCallback, useState } from 'react'
import { useState } from 'react'
import { store, useStore } from '../store'
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 type { AdminZoneSection } from '../api'
@@ -18,7 +19,7 @@ interface AdminSectionDef {
icon: IconName
desc: string
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
render: (onDirtyChange?: (dirty: boolean) => void) => React.ReactNode
render: () => React.ReactNode
}
function ProfilesPanelWrapper() {
@@ -27,13 +28,14 @@ function ProfilesPanelWrapper() {
}
const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、生成一次性登录密码(管理员发放制)', render: () => <UsersPanel /> },
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: onDirtyChange => <SystemSettingsPanel onDirtyChange={onDirtyChange} /> },
{ 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 /> },
]
@@ -64,9 +66,6 @@ export function AdminZone() {
const declared = useStore(s => s.adminSections)
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
const [sectionKey, setSectionKey] = useState('users')
const [dirty, setDirty] = useState(false)
const dirtyChange = useCallback((value: boolean) => setDirty(value), [])
const depart = () => !dirty || window.confirm('系统配置尚未保存,离开后将丢弃这些改动。继续?')
const isAdmin = !!me?.isAdmin
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
@@ -85,18 +84,18 @@ export function AdminZone() {
return (
<div className="az" role="dialog" aria-label="系统管理">
<div className="az-head">
<button className="btn ghost" onClick={() => { if (depart()) store.setUI({ adminZoneOpen: false }) }}>
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
<Icon name="chevron-down" size={14} /> 返回工作台
</button>
<span className="az-title">系统管理</span>
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 按配置注明的时机生效</span>
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me?.owner ?? ''}</span>
</div>
<div className="az-body">
<nav className="az-side">
{sections.map(s => (
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => { if (depart()) { setDirty(false); setSectionKey(s.key) } }}>
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
<Icon name={s.icon} size={15} />
<span>{s.label}</span>
</button>
@@ -107,7 +106,7 @@ export function AdminZone() {
<h3>{section.label}</h3>
<p>{section.desc}</p>
</div>
{section.render(dirtyChange)}
{section.render()}
</main>
</div>
</div>
+36
View File
@@ -2,12 +2,25 @@ import { useEffect, useState } from 'react'
import { Btn, SettingRow, SettingsGroup } from '@ui'
import { api } from '../api'
const services = [
['models.chat', '主模型'],
['models.lightweight', '轻量模型'],
['models.embedding', 'Embedding'],
['models.rerank', 'Reranker'],
['tools.tavily', 'Tavily'],
] as const
type Secret = { id: string; service: string; label: string; active: boolean; mask: string }
type Entry = { id: string; keys: string[]; content: string; constant: boolean; logic: string }
type Cmd = { id: string; name: string; title: string; template: string }
export function BorrowPanels() {
const [secrets, setSecrets] = useState<Secret[]>([])
const [entries, setEntries] = useState<Entry[]>([])
const [cmds, setCmds] = useState<Cmd[]>([])
const [service, setService] = useState<string>(services[0][0])
const [label, setLabel] = useState('')
const [value, setValue] = useState('')
const [keys, setKeys] = useState('')
const [content, setContent] = useState('')
const [constant, setConstant] = useState(false)
@@ -16,6 +29,7 @@ export function BorrowPanels() {
const [cmdBody, setCmdBody] = useState('请总结以上内容。\n{{input}}')
const reload = () => {
api.listSecrets().then(setSecrets).catch(() => undefined)
api.listContextEntries().then(setEntries).catch(() => undefined)
api.listPromptCommands().then(setCmds).catch(() => undefined)
}
@@ -23,6 +37,28 @@ export function BorrowPanels() {
return (
<>
<SettingsGroup title="密钥">
<SettingRow label="新增密钥" desc="每个服务同时只有一把生效。列表只显示掩码。">
<select value={service} onChange={e => setService(e.target.value)}>
{services.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
</select>
</SettingRow>
<SettingRow label="标签与密钥" desc="保存后立即生效,并停用同服务的其他密钥。">
<input className="search-input" value={label} placeholder="标签" onChange={e => setLabel(e.target.value)} />
<input className="search-input" type="password" autoComplete="new-password" value={value} placeholder="密钥" onChange={e => setValue(e.target.value)} />
<Btn variant="primary" onClick={async () => {
if (!value.trim()) return
await api.createSecret(service, label || '未命名', value.trim())
setValue(''); setLabel(''); reload()
}}>添加</Btn>
</SettingRow>
{secrets.map(s => (
<SettingRow key={s.id} label={s.label} desc={`${s.service} · ${s.mask}${s.active ? ' · 生效' : ''}`}>
{!s.active && <Btn onClick={async () => { await api.activateSecret(s.id); reload() }}>启用</Btn>}
<Btn onClick={async () => { await api.deleteSecret(s.id); reload() }}>删除</Btn>
</SettingRow>
))}
</SettingsGroup>
<SettingsGroup title="条件注入">
<SettingRow label="关键词" desc="逗号分隔。用 /正则/ 表示正则。勾选后变为常驻。">
<input className="search-input" value={keys} placeholder="关键词" onChange={e => setKeys(e.target.value)} />
+261
View File
@@ -0,0 +1,261 @@
// 文档驾驶舱 I3:阅读器(连续正文 + 版本摘要/差异折叠)与右侧依据/影响面板。
// 消费 I1/I3 命令面 rdm.cockpit.node(+versions)/node.history(Dev 仓 docs/superpowers
// specs 2026-10-16 view-spec §5–§6)。不新增第四栏:右栏是选中节点的同一依据面,
// 全景与阅读器共享 selectedNodeRef;面板刷新原位进行,不抢占滚动位置(DV-R05/§5)。
// 折叠只用 @ui Collapsible;颜色/字号只消费 @ui/tokens.css。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Collapsible, EmptyState } from '@ui'
import {
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
PANEL_TABS, type CockpitContentBlock, type CockpitHistorySummary,
type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode,
type CockpitVersionSummary, type Density, type PanelTabId,
} from '../documentCockpit'
export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string }
interface NodeDetailShape {
meta: CockpitMeta
node: CockpitTreeNode
body?: CockpitContentBlock[]
children: CockpitTreeNode[]
sources: { items: any[] }
history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string }
impacts: { items: any[] }
conflicts: { items: any[] }
versions?: CockpitVersionSummary[]
}
function useNodeDetail(pid: string, sel?: CockpitNodeRef) {
const [detail, setDetail] = useState<NodeDetailShape | null>(null)
const [err, setErr] = useState('')
const gen = useRef(0)
useEffect(() => {
const g = ++gen.current
setDetail(null); setErr('')
if (!sel) return
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id })
.then(d => {
if (g !== gen.current) return
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
}
setDetail(d)
})
.catch(e => { if (g === gen.current) setErr(e.message) })
return () => { gen.current++ }
}, [pid, sel?.kind, sel?.id])
return { detail, err }
}
/** 版本块:当前版本 + 前一版本摘要;完整差异(行级标记)折叠。受限只给元数据。 */
export function VersionBlock({ versions, density }: { versions?: CockpitVersionSummary[]; density: Density }) {
if (!versions || versions.length === 0) return null
const { current, previous, redacted } = versionSummary(versions)
if (redacted || !current || current.content === undefined) {
return <div className="empty-note">版本受限(仅元数据可见)</div>
}
const prev = previous && previous.content !== undefined ? previous : undefined
return (
<Collapsible
id={`cockpit-versions-${current.version}`}
trigger={<span className="cockpit-versions-toggle">版本 {current.version}{previous ? `(前一版 ${previous.version})` : '(首版)'}与完整差异</span>}
>
<div className={`cockpit-versions cockpit-density-${density}`}>
{prev ? (
<>
<div className="cockpit-versions-head">当前 {current.version} · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
<pre className="cockpit-diff">
{diffLines(current.content || '', prev.content || '').map((l, i) => (
<div key={i} className={`cockpit-diff-${l.flag === '=' ? 'same' : l.flag === '+' ? 'add' : 'del'}`}>{l.flag} {l.text}</div>
))}
</pre>
<div className="cockpit-versions-head">前一版 {prev.version} · {prev.actor || '未知'} · {new Date(prev.at).toLocaleString('zh-CN')}</div>
</>
) : (
<div className="cockpit-versions-head">首版 · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
)}
</div>
</Collapsible>
)
}
/** 阅读器中栏:选中章节=该节连续正文+版本块;选中文档=全章节连续装配(每节详情按需拉取)。 */
export function CockpitReader({ pid, selection, sections, density, onBack }: {
pid: string
selection: ReaderSelection
sections: CockpitTreeNode[]
density: Density
onBack: () => void
}) {
const { detail, err } = useNodeDetail(pid, selection.ref)
const isSection = selection.ref.kind === 'section'
const [sectionDetails, setSectionDetails] = useState<Record<string, NodeDetailShape | undefined>>({})
const gen = useRef(0)
// 文档阅读器:按树顺序装配全部章节详情(上限 20;失败章节给受限占位不拖垮整篇)。
useEffect(() => {
if (isSection || selection.ref.kind !== 'document') return
const g = ++gen.current
const list = sections.slice(0, 20)
setSectionDetails({})
for (const sec of list) {
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: 'section', id: sec.ref.id })
.then(d => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: d })) })
.catch(() => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: undefined })) })
}
return () => { gen.current++ }
}, [pid, isSection, selection.ref.id, sections])
const anchorRef = useRef<HTMLDivElement | null>(null)
useEffect(() => { anchorRef.current?.scrollIntoView({ block: 'start' }) }, [selection.ref.id])
if (err) return <EmptyState icon="alert">阅读器加载失败:{err}</EmptyState>
return (
<div className={`cockpit-reader cockpit-density-${density}`}>
<div className="cockpit-nodehead" ref={anchorRef}>
<button type="button" className="cockpit-back" onClick={onBack}>← 返回全景</button>
<span className="cockpit-nodetitle">{detail?.node?.title || (isSection ? '章节' : '文档')}</span>
</div>
{isSection && detail && (
<>
{renderBody(detail.body, detail.meta)}
<VersionBlock versions={detail.versions} density={density} />
</>
)}
{!isSection && (
<>
{sections.length === 0 && <div className="empty-note">该文档暂无章节</div>}
{assembleReader(sections, sectionDetails).map(sec => (
<section key={sec.ref.id} id={`sec-${sec.ref.id}`} className="cockpit-reader-section">
<div className="cockpit-reader-sechead">
<span className="cockpit-reader-sectitle">{sec.title}</span>
{sec.blocks[0]?.kind === 'restricted'
? <span className="cockpit-badge">内容受限({sec.blocks[0].reason || '权限'})</span>
: sec.contentState === 'empty' && <span className="cockpit-badge">空</span>}
</div>
{sec.blocks.map((b, i) => b.kind === 'text'
? <p key={i} className="cockpit-reader-para">{b.text}</p>
: <p key={i} className="empty-note">内容受限——标题与状态可见,正文不展示</p>)}
<VersionBlock versions={sec.versions} density={density} />
</section>
))}
</>
)}
</div>
)
}
function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta) {
if (!body || body.length === 0) return <div className="empty-note">暂无正文</div>
if (body[0].kind === 'restricted') {
return <div className="empty-note">内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
}
return (
<>
{body.map((b, i) => <p key={i} className="cockpit-reader-para">{b.text}</p>)}
{meta.truncated && <div className="cockpit-truncated">详情已截断:{meta.truncationReasons?.join(';')}</div>}
</>
)
}
/** 右栏:依据/历史/影响/冲突四标签(同一选中节点;刷新原位,不抢占滚动)。 */
export function CockpitPanel({ pid, selection, tab, onTab, density }: {
pid: string
selection: ReaderSelection
tab: PanelTabId
onTab: (t: PanelTabId) => void
density: Density
}) {
const { detail, err } = useNodeDetail(pid, selection.ref)
const [historyItems, setHistoryItems] = useState<CockpitHistorySummary[]>([])
const [historyMore, setHistoryMore] = useState(false)
const [historyCursor, setHistoryCursor] = useState<string | undefined>(undefined)
useEffect(() => {
setHistoryItems([]); setHistoryMore(false); setHistoryCursor(undefined)
}, [selection.ref.kind, selection.ref.id])
useEffect(() => {
if (!detail?.history) return
// 严格合并:只接受 seq 更新的条目,乱序/重复丢弃;不重置已渲染内容。
setHistoryItems(prev => mergeHistoryStrict(prev, detail.history.items).items)
setHistoryMore(detail.history.hasMore)
setHistoryCursor(detail.history.nextCursor)
}, [detail])
const loadMoreHistory = useCallback(() => {
if (!historyCursor) return
api.rdmCommand<any>('rdm.cockpit.node.history', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id, cursor: historyCursor, limit: 20 })
.then(page => {
setHistoryItems(prev => mergeHistoryStrict(prev, page.items).items)
setHistoryMore(page.hasMore)
setHistoryCursor(page.nextCursor)
})
.catch(() => { /* 保留已渲染历史;可再次点击重试 */ })
}, [pid, selection.ref.kind, selection.ref.id, historyCursor])
const node = detail?.node
return (
<div className={`cockpit-panel cockpit-density-${density}`}>
<div className="cockpit-panel-tabs" role="tablist" aria-label="依据与影响">
{PANEL_TABS.map(t => (
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id}
className={`cockpit-panel-tab${tab === t.id ? ' cockpit-panel-tab-active' : ''}`}
onClick={() => onTab(t.id)}>
{t.label}
</button>
))}
</div>
{err && <div className="empty-note">依据面板加载失败:{err}</div>}
{!err && !detail && <div className="empty-note">加载依据…</div>}
{detail && tab === 'sources' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">来源 {node?.state.sourceCount ?? detail.sources.items.length} 条</div>
{detail.sources.items.length === 0 && <div className="empty-note">无来源——章节正文未挂依据切片</div>}
{detail.sources.items.map((s: any) => (
<div key={s.ref.id} className="cockpit-panel-row">
<span className="cockpit-panel-kind">{s.ref.kind}</span>
<span className="cockpit-panel-label">{s.label}</span>
{s.confidence && <span className="cockpit-badge">置信度 {s.confidence}</span>}
{s.redactedReason
? <span className="cockpit-badge">受限({s.redactedReason})</span>
: s.verification && s.verification !== 'not_applicable' && <span className="cockpit-badge">{s.verification}</span>}
</div>
))}
</div>
)}
{detail && tab === 'history' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">已提交事件(最新在前)</div>
{historyItems.length === 0 && <div className="empty-note">暂无历史</div>}
{historyItems.map(h => <div key={h.seq} className="cockpit-panel-row">{historyLine(h)}</div>)}
{historyMore && <button type="button" className="cockpit-tree-more" onClick={loadMoreHistory}>加载更早历史</button>}
</div>
)}
{detail && tab === 'impacts' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">影响 {node?.state.impactCount ?? detail.impacts.items.length} 项(一跳)</div>
{detail.impacts.items.length === 0 && <div className="empty-note">无一跳影响对象</div>}
{detail.impacts.items.map((r: any, i: number) => (
<div key={`${r.ref.kind}-${r.ref.id}-${i}`} className="cockpit-panel-row">
<span className="cockpit-panel-kind">{r.direction === 'incoming' ? '←' : '→'}</span>
<span className="cockpit-panel-label">{r.ref.kind}:{r.ref.id}</span>
<span className="cockpit-badge">{r.edgeType}</span>
<span className="cockpit-badge">{r.edgeState}</span>
</div>
))}
</div>
)}
{detail && tab === 'conflicts' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">冲突 {node?.state.conflictCount ?? 0} 项</div>
{detail.conflicts.items.length === 0 && <div className="empty-note">无开放冲突</div>}
{detail.conflicts.items.map(c => (
<div key={c.id} className="cockpit-panel-row cockpit-panel-conflict">
<span className="cockpit-badge">{c.state === 'open' ? '开放' : c.state}</span>
<div className="cockpit-conflict-side">甲方:{c.left.visibility === 'full' ? c.left.claim : `受限(${c.left.redactedReason || '权限'})`}</div>
<div className="cockpit-conflict-side">乙方:{c.right.visibility === 'full' ? c.right.claim : `受限(${c.right.redactedReason || '权限'})`}</div>
<div className="cockpit-changetime">{new Date(c.detectedAt).toLocaleString('zh-CN')}</div>
</div>
))}
</div>
)}
</div>
)
}
+78 -33
View File
@@ -5,15 +5,16 @@ import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpe
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../api'
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline } from '../live'
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
import { docBridge } from './Workbench'
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { ConversationWork } from './ConversationWork'
import { MessageTimestamp } from './MessageTimestamp'
import { loadRunEvents } from '../runEvents'
import '../companion-conversation.css'
function hasFiles(dt: DataTransfer | null): boolean {
@@ -25,6 +26,7 @@ function hasFiles(dt: DataTransfer | null): boolean {
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
switch (part.type) {
case 'text':
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
@@ -46,12 +48,12 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
case 'plan':
return null // 计划在右栏 Plan 标签呈现
case 'card': {
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
// 右栏工作可见时保留定位入口;详细正文只在右栏。关闭右栏时中栏可展开全文。
if (part.card?.cardId) {
const ref = part.card.cardId
const card = findCard(ref)
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={card}
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={workPaneOwnsCards || card?.work ? undefined : card}
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
}
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
@@ -197,6 +199,8 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
const childStamp = useStore(s => msg.runId ? s.runs.filter(r => r.parentRunId === msg.runId)
.map(r => `${r.id}:${r.status}:${r.endedAt ?? ''}`).join('|') : '')
const parts = msg.parts ?? []
useLiveClock(!!live)
const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl'
@@ -211,6 +215,26 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
useEffect(() => {
if (!touchedProcess.has(memKey)) setOpen(showProcess)
}, [memKey, showProcess])
const [replayed, setReplayed] = useState<Part[] | null>(null)
const [processError, setProcessError] = useState(false)
useEffect(() => { setReplayed(null); setProcessError(false) }, [childStamp])
useEffect(() => {
if (!open) { setReplayed(null); setProcessError(false) }
}, [open])
useEffect(() => {
if (!open || live || !msg.runId || !convId || replayed || processError) return
const controller = new AbortController()
let active = true
void (async () => {
try {
const events = await loadRunEvents(convId, msg.runId!, controller.signal)
if (active) setReplayed(runEventsToTimeline(events, msg.runId))
} catch {
if (active) setProcessError(true)
}
})()
return () => { active = false; controller.abort() }
}, [open, live, msg.runId, convId, replayed, processError])
const extras = live ? [] : citedArtifactCards(split.final)
const cards = live ? [] : dedupeDeliverables(split.final)
const lastText = (() => {
@@ -219,10 +243,11 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
})()
const streamingText = !!(live && live.phase === 'responding' && lastText >= 0
&& !live.items.some(it => it.kind === 'tool' && it.state !== 'done'))
const toolCount = split.timeline.filter(p => p.type === 'tool_call').length
const processParts = replayed === null ? split.timeline : mergeProcessTimeline(split.timeline, replayed, split.final)
const toolCount = processParts.filter(p => p.type === 'tool_call').length
const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}`
const timeline = (
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
<ProcessTimeline parts={processParts} live={!!live} idKey={msg.id}
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
)
const toggleProcess = (next: boolean) => {
@@ -235,16 +260,21 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
<div className="msg-role">
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
<span>助手</span>
<MessageTimestamp value={msg.createdAt || run?.createdAt} />
{live && <LiveBar live={live} />}
</div>
{split.timeline.some(part => part.type !== 'thinking') && (
{(split.timeline.length > 0 || !!msg.runId) && (
<div className="process-collapse">
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span className={live ? 'mk-sweep' : ''}>{headline}</span>
</>}>
<div className="body">{timeline}</div>
<div className="body">
{replayed === null && !live && !processError && <div className="process-load-note">正在读取完整记录…</div>}
{processError && <div className="process-load-note">完整记录暂不可用,以下为已保存的摘要</div>}
{processParts.length > 0 ? timeline : replayed && <div className="process-load-note">这轮没有可回看的过程记录</div>}
</div>
</Collapsible>
</div>
)}
@@ -424,18 +454,19 @@ function OutboxPanel() {
if (outbox.length === 0) return null
return (
<Collapsible id="composer-outbox" defaultOpen
trigger={<span>排队 {outbox.length}</span>}
trigger={<span>待发送 {outbox.length}</span>}
triggerClassName="outbox-trigger">
<ul className="outbox-list">
{outbox.map(item => (
<li key={item.id} className="outbox-item">
{item.deliveryError && <span className="outbox-text" role="status">{item.deliveryError}</span>}
{editing === item.id ? (
<input className="search-input outbox-edit" value={draft} onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) }
}} />
) : (
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}</span>
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}{item.taskRef ? ` · 基于「${item.taskRef.title}」` : ''}</span>
)}
{editing === item.id ? (
<button className="btn ghost" onClick={() => { store.updateOutbox(item.id, draft); setEditing(null) }}>保存</button>
@@ -443,6 +474,7 @@ function OutboxPanel() {
<button className="btn ghost" onClick={() => { setEditing(item.id); setDraft(item.text) }}>修改</button>
)}
<button className="btn ghost" onClick={() => store.sendOutboxNow(item.id)}>发送</button>
{item.taskRef && <button className="btn ghost" onClick={() => store.clearOutboxReference(item.id)}>移除引用</button>}
<button className="icon-btn" title="移出队列" onClick={() => store.removeOutbox(item.id)}><Icon name="x" size={12} /></button>
</li>
))}
@@ -457,7 +489,7 @@ export function NoticeMessage({ msg }: { msg: Message }) {
return (
<div className="msg notice" data-msg-id={msg.id}>
<Collapsible id={`notice:${msg.id}`} triggerClassName="notice-summary"
trigger={<span className="notice-label">通知 · {preview}</span>}>
trigger={<><span className="notice-label">通知 · {preview}</span><MessageTimestamp value={msg.createdAt} /></>}>
<div className="notice-body">{text}</div>
</Collapsible>
</div>
@@ -486,6 +518,8 @@ function ConversationSession() {
const cards = useStore(s => s.cards)
const currentProjectId = useStore(s => s.currentProjectId)
const cardActionStates = useStore(s => s.cardActionStates)
const workReference = useStore(s => s.workReference)
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
const [text, setText] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
@@ -514,8 +548,9 @@ function ConversationSession() {
})
const busy = isShellBusy(phase)
const showStop = phase === 'running'
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
// Background work stays in its own card and does not insert another chat turn.
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
const unreadResults = cards.filter(card => card.work?.result && card.work.resultVersion > card.work.readVersion)
useEffect(() => {
if (!stickRef.current) return
@@ -549,12 +584,18 @@ function ConversationSession() {
const t = text
const ids = attachmentIds
const names = attachmentNames
setText(''); setAttachmentIds([]); setAttachmentNames([])
if (busy) {
store.enqueueOutbox(t, ids, names)
const reference = workReference ?? undefined
if (reference && ids.length > 0) {
store.toast('这条消息已引用工作结果,请先移除引用或附件再发送')
return
}
await store.sendMessage(t, ids)
setText(''); setAttachmentIds([]); setAttachmentNames([])
store.clearWorkReference()
if (busy) {
store.enqueueOutbox(t, ids, names, reference)
return
}
await store.sendMessage(t, ids, undefined, reference)
}
if (!currentConvId) {
@@ -617,13 +658,6 @@ function ConversationSession() {
const stick = dist < 80
stickRef.current = stick
setShowJump(prev => (prev === !stick ? prev : !stick))
const ratio = scrollRatio(src)
if (ratio === null) return
const dst = document.querySelector('[data-scroll-sync="inbox"]')
if (!dst) return
const top = scrollTopForRatio(dst, ratio)
if (top === null) return
dst.scrollTop = top
}}>
{foldRunErrors(messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent')).map(m => {
if (m.role === 'user') {
@@ -633,16 +667,20 @@ function ConversationSession() {
<div className={`msg user${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
<div className="msg-role">
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
<MessageTimestamp value={m.createdAt} />
</div>
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
{anchored.map(c => (
<InlineConfirmCard key={c.id} card={c} callbacks={{
onAction: (card, actionId) => store.cardAction(card, actionId),
actionState: card => cardActionStates?.[card.id],
onLocate: card => store.openInbox(card.id),
}} />
))}
{anchored.map(c => workPaneOwnsCards
? <button type="button" key={c.id} className="conversation-action-anchor" onClick={() => store.openInbox(c.id)}>
<span>{c.title}</span><span>在工作栏处理 ↗</span>
</button>
: <InlineConfirmCard key={c.id} card={c} callbacks={{
onAction: (card, actionId) => store.cardAction(card, actionId),
actionState: card => cardActionStates?.[card.id],
onLocate: card => store.openInbox(card.id),
}} />
)}
</div>
)
}
@@ -674,16 +712,23 @@ function ConversationSession() {
<div className="input-bar">
<OutboxPanel />
{unreadResults.length > 0 && <button type="button" className="work-result-notice" onClick={() => store.openInbox(unreadResults[0].id)}>
<Icon name="check" size={14} /><span>{unreadResults.length} 项工作有新结果</span><span>查看工作 ↗</span>
</button>}
{workReference && <div className="composer-work-reference" role="group" aria-label="本条消息引用的工作结果">
<span>基于「{workReference.title}」的第 {workReference.resultVersion} 版结果</span>
<button type="button" className="icon-btn" aria-label="移除工作结果引用" onClick={() => store.clearWorkReference()}><Icon name="x" size={12} /></button>
</div>}
{!connected && <div className="companion-reconnect" role="status">正在重新连接,最新进展待确认</div>}
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
后台工作完成后更新原卡,用户按需打开。 */}
{(busy || bgRunning > 0) && (
<div className="input-status" data-testid="input-status">
<span className="live-dot" />
{busy
? <span className="mk-sweep">助手正在处理{bgRunning > 0 ? ` · ${bgRunning} 项后台工作` : ''}</span>
: <span>{bgRunning} 项后台工作</span>}
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,完成后会在这里汇报'}</span>
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,结果会更新到工作卡'}</span>
</div>
)}
{attachmentNames.length > 0 && (
+17 -7
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { ActivityCardView, Collapsible, activitySummary, pendingCount, type ActivityCard } from '@ui'
import { ActivityCardView, Collapsible, activitySummary, groupActivityCards, pendingCount, type ActivityCard } from '@ui'
import { store, useStore } from '../store'
import { docBridge } from './Workbench'
import type { WorkCardAction } from '../contracts'
import { WorkExecutionRecord } from './WorkExecutionRecord'
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
export function ConversationWork() {
@@ -11,8 +13,11 @@ export function ConversationWork() {
const messages = useStore(s => s.messages)
const actionStates = useStore(s => s.cardActionStates)
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
const [summaryOpen, setSummaryOpen] = useState(rightCollapsed)
useEffect(() => setSummaryOpen(rightCollapsed), [rightCollapsed])
const rightTab = useStore(s => s.ui.rightTab)
const record = useStore(s => s.workRecord)
const detailHere = rightCollapsed || rightTab !== 'work'
const [summaryOpen, setSummaryOpen] = useState(detailHere)
useEffect(() => setSummaryOpen(detailHere), [detailHere])
const { current, required, focused, history } = useMemo(() => {
const current = cards.filter(card => card.status !== 'dismissed'
&& card.conversationId === conversationId && card.projectId === projectId)
@@ -24,22 +29,27 @@ 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' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
if (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.
const results = groupActivityCards(remaining).find(group => group.key === 'results')?.cards ?? []
const focused = remaining.find(card => card.pinned && card.status === 'active')
?? remaining.find(card => card.status === 'active')
?? remaining.find(card => card.status === 'done') ?? remaining[0]
return { current, required, focused, history: remaining.filter(card => card !== focused) }
?? results[0]
return { current, required, focused, history: remaining.filter(card => card !== focused).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) }
}, [cards, messages, conversationId, projectId])
if (!detailHere) return null
if (record) return <div className="conversation-work" data-conversation-work><WorkExecutionRecord /></div>
if (current.length === 0) return null
const pending = pendingCount(current)
const active = current.filter(card => card.status === 'active').length
const results = current.filter(card => card.status === 'done' || card.status === 'failed').length
const summary = [pending ? `${pending} 项待处理` : '', active ? `${active} 项工作` : '', results ? `${results} 项结果` : ''].filter(Boolean).join(' · ')
const callbacks = {
onAction: (card: ActivityCard, actionId: string) => { void store.cardAction(card, actionId) },
onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => { void store.cardAction(card, actionId, payload) },
onOpenRecord: (card: ActivityCard) => store.openWorkRecord(card),
onReference: (card: ActivityCard) => store.referenceWork(card),
actionState: (card: ActivityCard) => actionStates?.[card.id],
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
+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>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { api, type ExecEnvView } from '../api'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup } from '@ui'
export function ExecEnvPanel() {
const [view, setView] = useState<ExecEnvView | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [conv, setConv] = useState('')
const [reason, setReason] = useState('')
const [wheel, setWheel] = useState<File | null>(null)
const refresh = async () => {
try { setView(await api.adminExecEnv()) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const run = async (work: () => Promise<string>) => {
setErr(''); setNotice(''); setBusy(true)
try { setNotice(await work()); await refresh() } catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
if (!view) return <EmptyState icon="package">加载 Python 环境…</EmptyState>
if (!view.configured) return <EmptyState icon="package">这台服务没有配置分层 Python 环境根</EmptyState>
return (
<div>
<SettingsGroup title="当前环境">
<SettingRow label="环境根" desc={view.root || '未设置'}><span /></SettingRow>
<SettingRow label="解释器" desc={view.python || '尚未安装托管解释器'}><span /></SettingRow>
<SettingRow label="基础层" desc={view.base || '尚未建立'}><span /></SettingRow>
<SettingRow label="层目录" desc={(view.layers ?? []).join('、') || '无'}>
<Btn variant="ghost" icon="undo" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvRebase()
return `基础层已切换到 ${out.base}。已有会话仍用原来的绑定。`
})}>重建基础层</Btn>
</SettingRow>
</SettingsGroup>
<SettingsGroup title="申请">
{(view.requests ?? []).length === 0 && <EmptyState icon="inbox">还没有扩展包申请</EmptyState>}
{(view.requests ?? []).map(req => (
<SettingRow key={req.id} label={req.spec || req.id} desc={`${req.status} · ${req.conversationId} · ${req.detail}`}>
{req.status === 'pending_approval' && (
<span className="ad-row">
<Btn variant="primary" icon="check" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, true, false)
return out.detail || out.status
})}>批准</Btn>
<Btn variant="ghost" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, true, true)
return out.detail || '已用隔离层'
})}>隔离层</Btn>
<Btn variant="ghost" icon="x" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, false, false)
return out.detail || '已拒绝'
})}>拒绝</Btn>
</span>
)}
</SettingRow>
))}
</SettingsGroup>
<SettingsGroup title="会话绑定">
{(view.bindings ?? []).map(b => (
<SettingRow key={b.conversationId} label={b.conversationId} desc={b.isolated ? `隔离 ${b.isolated}` : (b.extensions ?? []).join('、') || '只用基础层'}>
<span />
</SettingRow>
))}
<SettingRow label="回收" desc="删掉 7 天内没有会话引用、也不是当前基础层的目录">
<Btn variant="ghost" icon="trash" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvGC()
const n = out.removed?.length ?? 0
return n === 0 ? '没有可回收的层' : `已回收 ${n} 层`
})}>回收未引用层</Btn>
</SettingRow>
</SettingsGroup>
<SettingsGroup title="上传 wheel">
<SettingRow label="会话" desc="这个 wheel 只挂到指定会话,并进入审批">
<input className="lg-input" value={conv} onChange={e => setConv(e.target.value)} placeholder="会话 ID" />
</SettingRow>
<SettingRow label="理由" desc="写给审批记录">
<input className="lg-input" value={reason} onChange={e => setReason(e.target.value)} />
</SettingRow>
<SettingRow label="文件" desc="只接受预编译 wheel,不执行里面的脚本">
<input type="file" accept=".whl" onChange={e => setWheel(e.target.files?.[0] ?? null)} />
</SettingRow>
<div className="ad-row">
<Btn variant="primary" icon="download" disabled={busy || !wheel || !conv.trim()} onClick={() => run(async () => {
const out = await api.adminExecEnvWheel(wheel as File, conv.trim(), reason.trim())
return `${out.file}:${out.outcome.status}`
})}>提交审批</Btn>
</div>
</SettingsGroup>
{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>
)
}
+57 -60
View File
@@ -1,8 +1,11 @@
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key);
// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。
import { useState } from 'react'
import { api } from '../api'
// LoginPage —— 用户名+密码唯一入口;
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
// grant_setup 免当前密码(激活/恢复 Key 兑换),one_time_password 免当前密码
//(管理员生成的一次性登录密码会话,只需新密码 + 确认),current_required 必须
// 校验当前密码(管理员指定/生成的初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
import { useEffect, useState } from 'react'
import { api, type PasswordChangeMode } from '../api'
import { Icon, BrandMark } from '@ui'
function Err({ msg }: { msg: string }) {
@@ -31,10 +34,8 @@ function BrandPanel() {
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
const [tab, setTab] = useState<'password' | 'loginkey'>('password')
const [owner, setOwner] = useState('')
const [password, setPassword] = useState('')
const [key, setKey] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
@@ -54,74 +55,71 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
} finally { setBusy(false) }
}
const submitKey = async () => {
setErr(''); setBusy(true)
try {
// 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。
const me = await api.redeemLoginKey(key.trim())
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
onMustChange(me.owner)
return
}
onAuthed()
} catch (e) {
setErr(humanRedeemError(String(e)))
} finally { setBusy(false) }
}
return (
<div className="lg-wrap">
<BrandPanel />
<div className="lg-formside">
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
<form className="lg-card" onSubmit={e => { e.preventDefault(); submitPassword() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">登录</h2>
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key</p>
<div className="lg-tabs" role="tablist">
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
</div>
{tab === 'password' ? (
<>
<label className="lg-label">用户名</label>
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
<label className="lg-label">密码</label>
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
</>
) : (
<>
<label className="lg-label">一次性登录 Key</label>
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
<p className="lg-hint">忘记密码时由管理员签发;单次有效、默认 30 分钟内兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
</>
)}
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员生成一次性登录密码,使用后须立即改密</p>
<label className="lg-label">用户名</label>
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
<label className="lg-label">密码</label>
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
</form>
</div>
</div>
)
}
/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。
* 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框;
* 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)与 one_time_password
*(一次性登录密码会话)免当前密码。 */
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
return mode === 'current_required'
}
/** 设密门只认服务端声明的模式;未知/缺失模式一律拒绝放行。 */
export function isPasswordChangeMode(mode: string | null | undefined): mode is PasswordChangeMode {
return mode === 'grant_setup' || mode === 'current_required' || mode === 'one_time_password'
}
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
const [mode, setMode] = useState<PasswordChangeMode | null>(null)
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
useEffect(() => {
let active = true
api.me().then(me => {
if (!active) return
if (me.owner !== owner || me.sessionScope !== 'set_password_only' || !isPasswordChangeMode(me.passwordChangeMode)) {
setErr('当前设密凭据不再有效,请重新登录;忘记密码请联系管理员重新生成一次性登录密码。')
return
}
setMode(me.passwordChangeMode)
}).catch(() => { if (active) setErr('无法确认当前设密方式,请重新登录。') })
return () => { active = false }
}, [owner])
const submit = async () => {
setErr('')
if (!mode) { setErr('设密方式尚未由服务端确认'); return }
if (requiresCurrentPassword(mode) && !current) { setErr('请填写当前密码'); return }
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.changePassword('', next)
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
setDone(true)
} catch (e) {
setErr(humanChangePasswordError(String(e)))
@@ -143,13 +141,21 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">设置新密码</h2>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续;无需输入当前密码</p>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
{mode === 'current_required' && <>
<p className="lg-hint">首次登录必须更换初始密码;「当前密码」填写刚才登录用的初始密码。</p>
<label className="lg-label" htmlFor="forced-current">当前密码</label>
<input id="forced-current" className="lg-input" type="password" value={current}
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
</>}
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
{mode === 'one_time_password' && <p className="lg-hint">你正在使用管理员生成的一次性登录密码登录,请直接设置新密码,无需再次输入一次性密码。</p>}
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
<button className="lg-submit" type="submit" disabled={busy || !mode || (requiresCurrentPassword(mode) && !current) || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
</form>
)}
@@ -166,15 +172,6 @@ export function humanLoginError(raw: string): string {
return raw.replace(/^\d{3}:\s*/, '')
}
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
export function humanRedeemError(raw: string): string {
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
* 不能沿用登录的「用户名或密码不正确」一刀切。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
+8
View File
@@ -0,0 +1,8 @@
/** Stable server time, with the full local date available to keyboard and assistive readers. */
export function MessageTimestamp({ value }: { value?: string }) {
const date = value ? new Date(value) : null
if (!date || !Number.isFinite(date.getTime())) return null
const full = date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
const label = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
return <time className="message-timestamp" dateTime={date.toISOString()} title={full} aria-label={full}>{label}</time>
}
+64
View File
@@ -0,0 +1,64 @@
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
import { useEffect, useRef, useState } from 'react'
import { api, type AccountProfile, type AccountRecord } from '../api'
import { useStore } from '../store'
import { Collapsible, Icon, SettingsGroup } from '@ui'
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
]
export function MyAccountSettings() {
const me = useStore(s => s.me)
const [account, setAccount] = useState<AccountRecord | null>(null)
const [draft, setDraft] = useState<AccountProfile | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const sequence = useRef(0)
useEffect(() => {
if (!me?.auth) return
const seq = ++sequence.current
api.meProfile().then(profile => {
if (seq !== sequence.current) return
setAccount(profile); setDraft(profile.profile)
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
return () => { sequence.current++ }
}, [me?.owner, me?.auth])
if (!me?.auth) return null
const save = async () => {
if (!account || !draft) return
const patch: Partial<AccountProfile> = {}
for (const field of fields) if (draft[field.key] !== account.profile[field.key]) patch[field.key] = draft[field.key]
if (Object.keys(patch).length === 0) { setNotice('资料没有变化'); return }
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.patchMeProfile(account.profileVersion, patch)
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存')
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
finally { setBusy(false) }
}
return <SettingsGroup title="我的账号">
{account && draft ? <>
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空。</p>
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
<input id={`my-profile-${field.key}`} className="lg-input" value={draft[field.key]}
onChange={event => setDraft({ ...draft, [field.key]: event.target.value })} />
</div>)}
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
</Collapsible>
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密);生成后原密码仍可正常登录,用原密码登录会使未使用的一次性登录密码作废。</p>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
</SettingsGroup>
}
+12 -3
View File
@@ -1,6 +1,6 @@
// 执行记录按工作动作分组;展开状态由调用方控制。
import type { ReactNode } from 'react'
import { ToolBlock } from '@ui'
import { Collapsible, ToolBlock } from '@ui'
import type { Part } from '../contracts'
import { formatDuration, humanSize } from '../messageparts'
import { timelineEntries, toolGroupLabel, type ToolGroupItem } from '../toolkind'
@@ -15,8 +15,12 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
return (
<div className="tl">
{rows.map((row, i) => {
// 模型内部思考不是用户工作内容,也不属于可展开的执行记录。
if (row.kind === 'thinking') return null
if (row.kind === 'thinking') {
return row.text && <div className="tl-thinking" key={`${idKey}:th:${i}`}>
<span className="tl-thinking-label">思考过程</span>
<div className="tl-thinking-body">{row.text}</div>
</div>
}
if (row.kind === 'text') {
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
}
@@ -38,6 +42,11 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
<span className="tl-tools-name">{it.headline}</span>
{it.status === 'error' && <span className="status-error">失败</span>}
{it.summary && <span className="tl-tools-sum">{it.summary}</span>}
{(it.arguments || it.output) && <Collapsible id={`tool-record:${idKey}:${it.id}:${j}`}
triggerClassName="tl-tool-detail" trigger={<span>查看记录 ▸</span>}>
{it.arguments && <pre className="tool-preview">{it.arguments}</pre>}
{it.output && <pre className="tool-preview">{it.output}</pre>}
</Collapsible>}
</li>
))}
</ul>
+105 -83
View File
@@ -1,98 +1,120 @@
import { useCallback, useEffect, useState } from 'react'
import { api, type SettingsView } from '../api'
// 项目设置面板(提案 2026-09-30-cross-session-reading):首个项目级设置 UI。
// schema 驱动渲染 project scope(4.9 覆盖链:system → user → project),
// 写 putProjectSettings;后续项目级设置项经 settings schema 自动出现,零开发。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { SettingDesc } from '../contracts'
import { SettingsShell, Btn, EmptyState } from '@ui'
import { flatten, scopedDraft, scopedSchema, SettingsFields, settingsMutation } from './SettingsFields'
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections'
import { rdmProjectReadError } from '../rdmManagement'
import { projectActionError, projectWorkError } from '../projectmeta'
import { Feedback, useRequestFence, validVersion } from './rdmSettingsShared'
export function ProjectSettingsModal() {
const projectId = useStore(s => s.ui.projectSettingsId)
const project = useStore(s => s.projects.find(project => project.id === projectId))
const projects = useStore(s => s.projects)
const project = projects.find(p => p.id === projectId)
const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable)
const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable)
const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable)
const owner = useStore(s => s.me?.owner)
const membersAvailable = useStore(s => s.rdmMembersAvailable)
const dutiesAvailable = useStore(s => s.rdmDocReviewAvailable)
const permissionsAvailable = useStore(s => s.rdmPermMatrixAvailable)
const context = `${owner || ''}:${projectId || ''}`
const fence = useRequestFence(context)
const isAdmin = useStore(s => s.me?.isAdmin ?? false)
// P1-B 裁定:宿主覆盖设置(含模型与密钥)仅项目负责人/系统管理员可查看与修改;
// RDM 页签(成员/文档职责/项目权限)不在此列——数据面由 RDM 权限自行收敛,
// 授权的职责管理者仍可进入。
const projectPrivileged = !!project && (project.owner === owner || isAdmin)
const [tab, setTab] = useState('project')
const [settings, setSettings] = useState<SettingsView | null>(null)
const [schema, setSchema] = useState<SettingDesc[]>([])
const [draft, setDraft] = useState<Record<string, any>>({})
const [childDirty, setChildDirty] = useState(false)
const [busy, setBusy] = useState(false)
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const schema = settings ? scopedSchema(settings.schema, 'project') : []
const initial = settings ? scopedDraft(settings.project, schema) : {}
const dirty = JSON.stringify(draft) !== JSON.stringify(initial)
const writeError = projectWorkError(project)
const canSave = settings?.projectCanConfigure === true && validVersion(settings.projectRevision) && !writeError
const load = async () => {
if (!projectId) return false
const current = fence.begin('load')
try {
const next = await api.getSettings(projectId)
if (!current()) return false
setSettings(next); setDraft(scopedDraft(next.project, scopedSchema(next.schema, 'project')))
return true
} catch (e) { if (current()) setError(`项目设置未能加载:${projectActionError(e)}`); return false }
}
useEffect(() => {
fence.invalidate(); setSettings(null); setDraft({}); setTab('project'); setChildDirty(false)
setError(''); setNotice(''); setBusy(false)
void load()
}, [context])
const dirtyChange = useCallback((value: boolean) => setChildDirty(value), [])
const depart = () => !(dirty || childDirty) || window.confirm('存在未保存的设置,离开后将丢弃这些改动。继续?')
useEffect(() => {
if (!dirty && !childDirty) return
const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault() }
window.addEventListener('beforeunload', beforeUnload)
return () => window.removeEventListener('beforeunload', beforeUnload)
}, [dirty, childDirty])
const save = async () => {
if (!projectId || !canSave || !settings || !validVersion(settings.projectRevision) || busy) return
const current = fence.begin('save')
setBusy(true); setError(''); setNotice('')
try {
const mutation = settingsMutation(initial, draft, schema)
await api.putProjectSettings(projectId, mutation.values, settings.projectRevision, mutation.unset)
if (current() && await load()) setNotice('已保存共享项目配置;按各项标注的生效时机应用')
} catch (e) {
if (!current()) return
const message = String(e).includes('409') ? '项目设置已被其他成员更新;请核对最新设置后重新调整' : `保存结果未能确认:${projectActionError(e)};请核对最新设置后再保存`
await load()
if (current()) setError(message)
} finally { if (current()) setBusy(false) }
}
if (!projectId) return
let active = true
setLoaded(false); setSaved(false); setError(''); setDraft({}); setSchema([])
api.getSettings(projectId).then(s => {
if (!active) return
setSchema(s.schema)
setDraft(flatten(s.project))
setLoaded(true)
}).catch(() => { if (active) setError('项目设置未能加载,请关闭后重新打开或联系项目负责人') })
return () => { active = false }
}, [projectId, owner])
const groups = useMemo(() => {
const g: Record<string, SettingDesc[]> = {}
for (const d of schema) (g[d.group] ??= []).push(d)
return g
}, [schema])
if (!projectId) return null
const rdmTab = ['members', 'duties', 'matrix'].includes(tab)
const readError = rdmProjectReadError(project)
return <SettingsShell title={project?.name || '当前项目'} ariaLabel={`项目设置 · ${project?.name || '当前项目'}`} active={tab}
onSelect={next => { if (!busy && depart()) { if (dirty) setDraft(initial); setChildDirty(false); setNotice(''); setTab(next) } }}
onClose={() => { if (depart()) { fence.invalidate(); store.openProjectSettings(null) } }}
sections={[
{ key: 'project', label: '共享项目配置', icon: 'folder' },
...(membersAvailable ? [{ key: 'members', label: '项目成员', icon: 'users' as const }] : []),
...(dutiesAvailable ? [{ key: 'duties', label: '文档职责', icon: 'shield' as const }] : []),
...(permissionsAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
]}
footer="项目配置由成员共享;个人项目偏好在个人设置中调整"
actions={!rdmTab && settings && schema.length > 0 ? <Btn variant="primary" icon="check" disabled={!canSave || busy || !dirty} onClick={save}>保存项目设置</Btn> : undefined}>
{rdmTab && readError ? <EmptyState icon="folder">{readError}</EmptyState> : project && <>
{tab === 'members' && <RdmMembersSection key={context} project={project} />}
{tab === 'duties' && <RdmDutiesSection key={context} project={project} onDirtyChange={dirtyChange} />}
{tab === 'matrix' && <RdmPermMatrixSection key={context} project={project} />}
</>}
{!rdmTab && <>
<p className="ad-hint">此处配置项目共享的业务规则。模型、工具与系统插件由系统管理员配置;个人上下文、检索与会话偏好在个人设置中调整</p>
{!settings ? <><EmptyState icon="settings">{error || '加载项目设置…'}</EmptyState>{error && <Btn onClick={() => { setError(''); void load() }}>重试</Btn>}</>
: schema.length === 0 ? <EmptyState icon="settings">当前没有可配置的共享项目设置</EmptyState>
: <SettingsFields schema={schema} draft={draft} defaults={flatten(settings.defaults)} disabled={!canSave || busy} source="本项目共享配置" onChange={(key, value) => { setNotice(''); setDraft(previous => ({ ...previous, [key]: value })) }} />}
{settings && !canSave && <p className="ad-hint">{writeError || (validVersion(settings.projectRevision) ? '你可以查看共享配置;保存需要项目设置管理权限' : '共享配置版本尚未就绪,暂不能保存')}</p>}
<Feedback error={error} notice={notice} />
</>}
</SettingsShell>
const rdmError = rdmProjectReadError(project)
const writeError = projectWorkError(project)
const rdmTab = tab === 'members' || tab === 'duties' || tab === 'matrix'
const save = async () => {
if (!projectId) return
if (writeError) { store.toast(writeError); return }
try {
await api.putProjectSettings(projectId, unflatten(draft))
setSaved(true)
setTimeout(() => setSaved(false), 1500)
} catch (e) {
store.toast(`项目设置保存失败:${projectActionError(e)}`)
}
}
return (
<SettingsShell
title={`项目设置 · ${project?.name ?? projectId}`}
active={tab}
onSelect={setTab}
onClose={() => store.openProjectSettings(null)}
sections={[
{ key: 'project', label: '本项目', icon: 'folder' },
// U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。
...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []),
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责', icon: 'shield' as const }] : []),
...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
]}
footer={saved ? <span style={{ color: 'var(--ok-text)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
>
<div>
{rdmTab && rdmError ? <EmptyState icon="folder">{rdmError}</EmptyState> : project && <>
{tab === 'members' && <RdmMembersSection key={project.id} project={project} />}
{tab === 'duties' && <RdmDutiesSection key={project.id} project={project} />}
{tab === 'matrix' && <RdmPermMatrixSection key={project.id} project={project} />}
</>}
{tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? (
<EmptyState icon="settings">{error || '加载中…'}</EmptyState>
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !projectPrivileged ? (
<EmptyState icon="settings">项目覆盖设置(含模型与密钥)仅项目负责人或系统管理员可查看与修改。</EmptyState>
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? (
<>
<div className="empty-note" style={{ marginBottom: 10 }}>
{writeError ?? '此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。'}
</div>
{Object.entries(groups).map(([g, descs]) => (
<div className="settings-group" key={g}>
<h4>{groupNames[g] ?? g}</h4>
{descs.map(d => (
<div className="setting-row" key={d.key}>
<label title={d.key}>{d.label}</label>
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
</span>
</div>
))}
</div>
))}
<Btn variant="primary" icon="check" disabled={!!writeError} onClick={save}>保存项目设置</Btn>
</>
) : null}
</div>
</SettingsShell>
)
}
-353
View File
@@ -1,353 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Btn, Collapsible, Picker, SettingRow, SettingsGroup, type PickPage } from '@ui'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { assignDocDutiesPayload, docApprovePayload, docFinalPayload, docRejectPayload, docSubmitPayload } from '../rdmManagement'
import { Feedback, personItem, projectContext, readableError, RoleFilter, selectedPeople,
useRequestFence, useRoleOptions, validVersion, type MemberList, type Person } from './rdmSettingsShared'
interface Duties { author: string; reviewers: string[]; approver: string }
type WireDuties = Partial<Duties> & { Author?: string; Reviewers?: string[]; Approver?: string }
export function normalizeDuties(value?: WireDuties | null): Duties {
return { author: value?.author ?? value?.Author ?? '', reviewers: value?.reviewers ?? value?.Reviewers ?? [], approver: value?.approver ?? value?.Approver ?? '' }
}
const blank = (): Duties => ({ author: '', reviewers: [], approver: '' })
const sameDuties = (left: Duties, right: Duties) => left.author === right.author && left.approver === right.approver
&& [...left.reviewers].sort().join('\u0000') === [...right.reviewers].sort().join('\u0000')
export function dutyValidation(duties: Duties): string {
if (!duties.author || !duties.approver || duties.reviewers.length === 0) return '请选择编制人、至少一位审核人和批准人'
if (duties.author === duties.approver || duties.reviewers.includes(duties.author) || duties.reviewers.includes(duties.approver)) return '编制、审核、批准须由不同成员承担'
return ''
}
interface CatalogDoc {
docId: string; title: string; typeKey: string; typeLabel?: string; stage?: string; stageLabel?: string
sections: number; assigned: boolean; author?: string; reviewers?: string[]; approver?: string
activeRound?: number; activeSections?: number; status?: string; statusLabel?: string; dutyVersion?: number
}
interface DutyTreeNode {
id: string; kind: 'stage' | 'type' | 'doc' | 'section'; label: string; selectable: boolean
docId?: string; sectionId?: string; children?: DutyTreeNode[]
}
interface Catalog {
docs: CatalogDoc[]; next?: string; count?: number; total?: number; canAssignDuties?: boolean
filters?: { stages?: { value: string; label: string }[]; typeKeys?: { value: string; label: string }[]; statuses?: { value: string; label: string }[]; assignments?: { value: string; label: string }[] }
}
interface ActiveDuty { round?: number; state?: string; createdBy?: string; duties?: WireDuties; cardId?: string }
interface Chapter {
sectionId: string; title: string; roles: WireDuties; dutyVersion?: number; overridden: boolean; active?: ActiveDuty | null
}
interface DutyDetail {
docId: string; title: string; assigned: boolean; roles: WireDuties; dutyVersion?: number
sections: Chapter[]; active?: ActiveDuty | null; canAssignDuties?: boolean
}
/** A default change affects whole-document review and only chapters inheriting that default. */
export function documentAssignmentImpact(document: Pick<DutyDetail, 'roles' | 'active' | 'sections'>, duties: Duties): boolean {
if (sameDuties(normalizeDuties(document.roles), duties)) return false
return !!document.active || document.sections.some(section => !section.overridden && !!section.active
&& !sameDuties(normalizeDuties(section.roles), duties))
}
interface Submission {
round: number; state: string; duties: WireDuties; reviews: Record<string, boolean>; createdBy: string
}
const statusLabel = (state?: string) => ({ submitted: '审核中', final: '已批准', rejected: '已驳回', invalidated: '职责变更,待重新提交', 'in-review': '审核中', approved: '已批准', draft: '编写中', unassigned: '未分配' }[state || ''] || '暂无审签')
/** Only document nodes may become a document selection. Chapters remain visible context. */
export function DutyTree({ nodes, selected, onSelect }: { nodes: DutyTreeNode[]; selected: string; onSelect: (docId: string) => void }) {
return <ul className="rdm-duty-tree">{nodes.map(node => <li key={node.id}>
{node.kind === 'doc' && node.selectable === true && node.docId
? <button className={`ad-mini${selected === node.docId ? ' ad-row-on' : ''}`} onClick={() => onSelect(node.docId!)}>{node.label}</button>
: <span className="ad-dim">{node.label}{node.kind === 'section' ? ' · 章节' : ''}</span>}
{!!node.children?.length && <Collapsible trigger={`展开 ${node.label}`} defaultOpen={node.kind === 'stage' || node.kind === 'type'}>
<DutyTree nodes={node.children} selected={selected} onSelect={onSelect} />
</Collapsible>}
</li>)}</ul>
}
function mergeTree(previous: DutyTreeNode[], incoming: DutyTreeNode[]): DutyTreeNode[] {
const merged = new Map(previous.map(node => [node.id, node]))
for (const node of incoming) {
const old = merged.get(node.id)
merged.set(node.id, old ? { ...node, children: mergeTree(old.children || [], node.children || []) } : node)
}
return [...merged.values()]
}
function DutyPeople({ value, onChange, fetchPage, people, disabled, labelPrefix = '' }: {
value: Duties; onChange: (value: Duties) => void; fetchPage: (query: string, cursor: string, limit: number, purpose: 'author' | 'reviewer' | 'approver') => Promise<PickPage>
people: Person[]; disabled: boolean; labelPrefix?: string
}) {
const authorPage = useCallback((query: string, cursor: string, limit: number) => fetchPage(query, cursor, limit, 'author'), [fetchPage])
const reviewerPage = useCallback((query: string, cursor: string, limit: number) => fetchPage(query, cursor, limit, 'reviewer'), [fetchPage])
const approverPage = useCallback((query: string, cursor: string, limit: number) => fetchPage(query, cursor, limit, 'approver'), [fetchPage])
return <>
<SettingRow label={`${labelPrefix}编制人`} desc="单选项目成员">
<Picker aria-label={`${labelPrefix}编制人`} selected={value.author ? [value.author] : []} selectedItems={selectedPeople([value.author].filter(Boolean), people)}
onSelect={ids => onChange({ ...value, author: ids[0] || '' })} fetchPage={authorPage} disabled={disabled} />
</SettingRow>
<SettingRow label={`${labelPrefix}审核人`} desc="至少选择一位,与编制人和批准人互异">
<Picker aria-label={`${labelPrefix}审核人`} multiple selected={value.reviewers} selectedItems={selectedPeople(value.reviewers, people)}
onSelect={reviewers => onChange({ ...value, reviewers })} fetchPage={reviewerPage} disabled={disabled} />
</SettingRow>
<SettingRow label={`${labelPrefix}批准人`} desc="单选项目成员">
<Picker aria-label={`${labelPrefix}批准人`} selected={value.approver ? [value.approver] : []} selectedItems={selectedPeople([value.approver].filter(Boolean), people)}
onSelect={ids => onChange({ ...value, approver: ids[0] || '' })} fetchPage={approverPage} disabled={disabled} />
</SettingRow>
</>
}
export function RdmDutiesSection({ project, onDirtyChange }: { project: Project; onDirtyChange?: (dirty: boolean) => void }) {
const me = useStore(s => s.me)
const context = projectContext(project, me?.owner)
const fence = useRequestFence(context)
const writeError = projectWorkError(project)
const [catalog, setCatalog] = useState<Catalog | null>(null)
const [tree, setTree] = useState<DutyTreeNode[] | null>(null)
const [view, setView] = useState<'list' | 'tree'>('list')
const [query, setQuery] = useState('')
const [match, setMatch] = useState<'exact' | 'fuzzy'>('fuzzy')
const [stage, setStage] = useState('')
const [typeKey, setTypeKey] = useState('')
const [assignment, setAssignment] = useState('')
const [status, setStatus] = useState('')
const [roleId, setRoleId] = useState('')
const [members, setMembers] = useState<MemberList | null>(null)
const [docId, setDocId] = useState('')
const [sectionId, setSectionId] = useState('')
const [detail, setDetail] = useState<DutyDetail | null>(null)
const [draft, setDraft] = useState<Duties>(blank)
const [sub, setSub] = useState<Submission | null | undefined>(undefined)
const [selection, setSelection] = useState<Set<string>>(new Set())
const [batchDraft, setBatchDraft] = useState<Duties>(blank)
const [busy, setBusy] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const { roles, error: roleError } = useRoleOptions(project, 'duties', members?.canAssignDuties === true)
const selectedRef = useRef('')
const memberNames = useRef(new Map<string, Person>())
const canAssign = catalog?.canAssignDuties === true && members?.canAssignDuties === true && !writeError
const canEditCurrent = canAssign && detail?.canAssignDuties === true
const currentChapter = detail?.sections.find(section => section.sectionId === sectionId)
const original = normalizeDuties(sectionId ? currentChapter?.roles : detail?.roles)
const currentVersion = sectionId ? currentChapter?.dutyVersion : detail?.dutyVersion
const active = sectionId ? currentChapter?.active || detail?.active : detail?.active
const dirty = !!detail && !sameDuties(draft, original)
const batchDirty = selection.size > 0 && (batchDraft.author !== '' || batchDraft.approver !== '' || batchDraft.reviewers.length > 0)
useEffect(() => { onDirtyChange?.(dirty || batchDirty); return () => onDirtyChange?.(false) }, [dirty, batchDirty, onDirtyChange])
const filters = { query, match, stage, typeKey, assignment, status }
const clearDetail = () => {
fence.cancel('detail'); fence.cancel('status'); fence.cancel('mutation'); fence.cancel('batch-preview')
selectedRef.current = ''; setDocId(''); setSectionId(''); setDetail(null); setDraft(blank()); setSub(undefined)
setSelection(new Set()); setBatchDraft(blank()); setNotice(''); setBusy(false)
}
const depart = () => !(dirty || batchDirty) || window.confirm('有未保存的职责调整,离开后将丢弃这些改动。继续?')
const changeFilter = (change: () => void) => { if (depart()) { fence.cancel('catalog'); clearDetail(); setCatalog(null); setTree(null); setError(''); change() } }
const loadCatalog = async (cursor = '') => {
const current = fence.begin('catalog')
setLoading(true)
try {
const out = await api.rdmProjectCommand<Catalog>(project, 'rdm.duty.catalog', { ...filters, cursor, limit: 30 })
if (!current()) return false
if (!Array.isArray(out.docs) || typeof out.canAssignDuties !== 'boolean') throw new Error('文档目录契约尚未就绪,请升级项目服务')
setCatalog(prev => cursor && prev ? { ...out, docs: [...prev.docs, ...out.docs] } : out)
if (view === 'tree') {
const treeOut = await api.rdmProjectCommand<{ nodes: DutyTreeNode[]; next?: string }>(project, 'rdm.duty.tree', { ...filters, cursor, limit: 30 })
if (!current()) return false
if (!Array.isArray(treeOut.nodes)) throw new Error('文档树接口尚未就绪')
setTree(previous => cursor ? mergeTree(previous || [], treeOut.nodes) : treeOut.nodes)
}
return true
} catch (e) { if (current()) { setError(readableError(e)); if (!cursor) { setCatalog(null); setTree(null) } } return false }
finally { if (current()) setLoading(false) }
}
const loadDetail = async (id: string) => {
const current = fence.begin('detail')
const statusCurrent = fence.begin('status')
selectedRef.current = id; setDocId(id); setSectionId(''); setDetail(null); setDraft(blank()); setSub(undefined); setError('')
try {
const duty = await api.rdmProjectCommand<DutyDetail>(project, 'rdm.duty.get', { docId: id })
if (!current() || selectedRef.current !== id) return false
if (duty.docId !== id || !Array.isArray(duty.sections)) throw new Error('职责结果与所选文档不一致')
setDetail(duty); setDraft(normalizeDuties(duty.roles))
} catch (e) {
if (current()) setError(readableError(e))
return false
}
if (!current() || !statusCurrent()) return false
try {
const output = await api.rdmProjectCommand<{ submission: Submission | null }>(project, 'rdm.review.doc.status', { docId: id })
if (!current() || !statusCurrent() || selectedRef.current !== id) return false
setSub(output.submission)
return true
} catch (e) {
if (current() && statusCurrent()) setError(`整文审签状态读取失败:${readableError(e)}`)
return false
}
}
const selectDoc = (id: string) => {
if (!depart()) return
fence.cancel('mutation'); setBusy(false); setSelection(new Set()); setBatchDraft(blank()); setNotice('')
void loadDetail(id)
}
useEffect(() => {
fence.invalidate(); clearDetail(); setCatalog(null); setTree(null); setMembers(null); memberNames.current.clear()
setQuery(''); setStage(''); setTypeKey(''); setAssignment(''); setStatus(''); setRoleId(''); setError('')
const current = fence.begin('members')
api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { limit: 50 })
.then(out => { if (current()) { setMembers(out); for (const person of out.members ?? []) memberNames.current.set(person.owner, person) } })
.catch(e => { if (current()) setError(`成员候选无法读取:${readableError(e)}`) })
}, [context])
useEffect(() => {
fence.cancel('catalog')
const timer = setTimeout(() => { void loadCatalog() }, query ? 250 : 0)
return () => { clearTimeout(timer); fence.cancel('catalog') }
}, [context, query, match, stage, typeKey, assignment, status, view])
const memberPage = useCallback(async (query: string, cursor: string, limit: number, purpose: 'author' | 'reviewer' | 'approver'): Promise<PickPage> => {
const current = fence.capture()
const out = await api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { query, cursor, limit, roleId })
if (!current()) return { items: [] }
if (!Array.isArray(out.members) || out.accountDirectoryAvailable !== true) throw new Error('项目成员账号状态尚未就绪')
return { items: out.members.map(person => {
memberNames.current.set(person.owner, person)
const item = personItem(person)
const eligibility = person.dutyEligibility?.[purpose]
return eligibility?.allowed === false ? { ...item, disabled: true, disabledReason: eligibility.reason || '当前成员暂不具备该职责资格' } : item
}), nextCursor: out.next || undefined }
}, [project, roleId, context])
const run = async (message: string, mutation: () => Promise<unknown>, id = selectedRef.current) => {
const current = fence.begin('mutation')
setBusy(true); setError(''); setNotice('')
try {
await mutation()
if (!current()) return
const catalogVerified = await loadCatalog()
if (!current()) return false
const detailVerified = !id || await loadDetail(id)
if (current() && catalogVerified && detailVerified) { setNotice(message); return true }
return false
} catch (e) {
if (!current()) return
setError(`${readableError(e)};请核对最新状态后再操作`)
// Outcome may be unknown after disconnection. Reconcile, never retry a write automatically.
if (id) await loadDetail(id)
if (current()) setError(`${readableError(e)};已尝试读取最新状态,未自动重试写入`)
return false
} finally { if (current()) setBusy(false) }
}
const save = () => {
if (!canEditCurrent || !detail || !validVersion(currentVersion) || !dirty || dutyValidation(draft) || busy) return
const affected = sectionId ? !!active : documentAssignmentImpact(detail, draft)
const confirmed = affected ? window.confirm(`「${detail.title}」${active?.round ? `第 ${active.round} 轮` : '相关章节'}审签将失效,需重新提交。确认职责变更?`) : false
if (affected && !confirmed) return
const payload = assignDocDutiesPayload(project.id, detail.docId, draft.author, draft.reviewers, draft.approver, currentVersion, confirmed)
void run('职责已保存,已重新读取当前状态', () => api.rdmProjectCommand(project,
sectionId ? 'rdm.review.assign' : 'rdm.review.assign.doc', sectionId ? { ...payload, sectionId } : payload))
}
const batchApply = async () => {
if (!canAssign || busy || selection.size === 0 || dutyValidation(batchDraft)) return
const current = fence.begin('batch-preview'); setBusy(true); setError('')
try {
const ids = [...selection]
const preview = await Promise.all(ids.map(id => api.rdmProjectCommand<DutyDetail>(project, 'rdm.duty.get', { docId: id })))
if (!current()) return
if (preview.some((document, index) => document.docId !== ids[index] || !validVersion(document.dutyVersion))) throw new Error('批次中有文档版本未核验,请重新选择')
const affected = preview.filter(document => documentAssignmentImpact(document, batchDraft))
const summary = preview.map(document => `${document.title}${!documentAssignmentImpact(document, batchDraft) ? '' : document.active?.round ? `(第 ${document.active.round} 轮将失效)` : '(章节审签将失效)'}`).join('\n')
if (!window.confirm(`将对以下 ${preview.length} 份文档应用相同职责,任一失败整批不写入:\n${summary}\n编制:${batchDraft.author}\n审核:${batchDraft.reviewers.join('、')}\n批准:${batchDraft.approver}\n${affected.length ? '受影响的审签需重新提交。' : ''}确认分配?`)) return
if (!current()) return
const saved = await run('批量职责已保存,已重新读取目录', () => api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', {
docs: preview.map(document => ({ docId: document.docId, author: batchDraft.author, reviewers: batchDraft.reviewers, approver: batchDraft.approver,
expectedDutyVersion: document.dutyVersion!, confirmInvalidation: affected.length > 0 })),
confirmInvalidation: affected.length > 0,
}))
if (current() && saved) { setSelection(new Set()); setBatchDraft(blank()) }
} catch (e) { if (current()) setError(readableError(e)) }
finally { if (current()) setBusy(false) }
}
const submissionDuties = normalizeDuties(sub?.duties)
const canSubmit = !!detail && detail.assigned && normalizeDuties(detail.roles).author === me?.owner && sub !== undefined && sub?.state !== 'submitted'
const canReview = sub?.state === 'submitted' && !!me?.owner && submissionDuties.reviewers.includes(me.owner)
const canFinal = sub?.state === 'submitted' && submissionDuties.approver === me?.owner && submissionDuties.reviewers.every(owner => sub.reviews?.[owner] === true)
const people = [...memberNames.current.values()]
return <div className="rdm-settings">
<h4 className="settings-group-title">文档职责</h4>
<div className="ad-row">
<input className="lg-input" aria-label="搜索文档" placeholder="输入文档名称或 ID" value={query} onChange={e => changeFilter(() => setQuery(e.target.value))} />
<select className="lg-input" aria-label="文档匹配方式" value={match} onChange={e => changeFilter(() => setMatch(e.target.value as 'exact' | 'fuzzy'))}>
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
</select>
<select className="lg-input" aria-label="文档浏览方式" value={view} onChange={e => { fence.cancel('catalog'); setTree(null); setView(e.target.value as 'list' | 'tree') }}>
<option value="list">列表</option><option value="tree">树形</option>
</select>
</div>
<div className="ad-row rdm-filter-row">
<select className="lg-input" aria-label="阶段筛选" value={stage} onChange={e => changeFilter(() => setStage(e.target.value))}><option value="">全部阶段</option>{(catalog?.filters?.stages ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select className="lg-input" aria-label="文档类型筛选" value={typeKey} onChange={e => changeFilter(() => setTypeKey(e.target.value))}><option value="">全部文档类型</option>{(catalog?.filters?.typeKeys ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select className="lg-input" aria-label="职责分配筛选" value={assignment} onChange={e => changeFilter(() => setAssignment(e.target.value))}><option value="">全部职责</option>{(catalog?.filters?.assignments ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select className="lg-input" aria-label="审签状态筛选" value={status} onChange={e => changeFilter(() => setStatus(e.target.value))}><option value="">全部审签状态</option>{(catalog?.filters?.statuses ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
</div>
{view === 'tree' && tree ? <DutyTree nodes={tree} selected={docId} onSelect={selectDoc} /> : view === 'list' && catalog && <div className="rdm-table-scroll"><table className="ad-table">
<thead><tr><th>选择</th><th>文档</th><th>类型/阶段</th><th>当前职责</th><th>审签状态</th></tr></thead>
<tbody>{catalog.docs.map(document => <tr key={document.docId} className={document.docId === docId ? 'ad-row-on' : ''}>
<td><input type="checkbox" aria-label={`选择 ${document.title}`} checked={selection.has(document.docId)} disabled={!canAssign || busy || (selection.size >= 20 && !selection.has(document.docId))} onChange={e => {
if (dirty && !window.confirm('丢弃当前文档未保存的职责调整,切换到批量分配?')) return
if (docId) { fence.cancel('detail'); setDocId(''); selectedRef.current = ''; setDetail(null); setDraft(blank()); setSub(undefined) }
const next = new Set(selection); e.target.checked ? next.add(document.docId) : next.delete(document.docId); setSelection(next)
}} /></td>
<td><button className="ad-mini" onClick={() => selectDoc(document.docId)}>{document.title}</button><div className="ad-dim">{document.docId}</div></td>
<td>{document.typeLabel || catalog.filters?.typeKeys?.find(option => option.value === document.typeKey)?.label || '文档'}{document.stage && <div className="ad-dim">{document.stageLabel || catalog.filters?.stages?.find(option => option.value === document.stage)?.label || '阶段未提供'}</div>}</td>
<td>{document.assigned ? `编制 ${document.author || '—'} · 审核 ${(document.reviewers ?? []).join('、') || '—'} · 批准 ${document.approver || '—'}` : '未分配'}</td>
<td>{document.activeRound ? `第 ${document.activeRound} 轮审核中` : document.statusLabel || statusLabel(document.status)}{document.activeSections ? <div className="ad-dim">{document.activeSections} 个章节审签中</div> : null}</td>
</tr>)}</tbody>
</table></div>}
{loading && <p className="ad-hint" role="status">加载文档…</p>}
{!loading && catalog?.docs.length === 0 && <p className="ad-hint">没有匹配且可访问的文档</p>}
{!loading && !catalog && <Btn onClick={() => { setError(''); void loadCatalog() }}>重新加载文档</Btn>}
{catalog?.next && <Btn disabled={loading} onClick={() => loadCatalog(catalog.next)}>加载更多文档</Btn>}
{writeError && <p className="ad-hint">{writeError}</p>}
{!canAssign && catalog && <p className="ad-hint">你可以查看有权访问的文档;分配职责需要文档职责管理权限</p>}
{docId && !detail && <p className="ad-hint">{error ? '文档职责未能加载' : '读取当前职责…'}</p>}
{detail && <SettingsGroup title={`${detail.title} · 当前职责`}>
<div className="ad-row"><select className="lg-input" aria-label="职责对象" value={sectionId} onChange={e => {
if (dirty && !window.confirm('丢弃未保存的职责调整?')) return
const id = e.target.value; setSectionId(id); setDraft(normalizeDuties(id ? detail.sections.find(section => section.sectionId === id)?.roles : detail.roles))
}}><option value="">整份文档默认职责</option>{detail.sections.map(section => <option key={section.sectionId} value={section.sectionId}>{section.title}{section.overridden ? ' · 单独指派' : ' · 沿用文档默认'}</option>)}</select>
<RoleFilter roles={roles} value={roleId} onChange={value => { fence.cancel('member-candidates'); setRoleId(value) }} disabled={!canEditCurrent || !!roleError} />
</div>
<DutyPeople value={draft} onChange={setDraft} fetchPage={memberPage} people={people} disabled={!canEditCurrent || busy || !validVersion(currentVersion) || members?.accountDirectoryAvailable !== true} />
{dirty && dutyValidation(draft) && <p className="ad-hint">{dutyValidation(draft)}</p>}
{!validVersion(currentVersion) && <p className="ad-hint">职责版本尚未就绪,只能查看;请升级项目服务后重新读取</p>}
{active && <p className="ad-hint">{active.round ? `第 ${active.round} 轮` : '章节'}审签正在进行,职责变更需要确认后重新提交</p>}
<Btn variant="primary" disabled={!canEditCurrent || busy || !dirty || !validVersion(currentVersion) || !!dutyValidation(draft)} onClick={save}>保存当前职责</Btn>
<p className="ad-hint">当前职责与提交时冻结名单独立。项目管理权限不代表编制、审核或批准资格</p>
</SettingsGroup>}
{detail && <SettingsGroup title={sub ? `整文审签 · 第 ${sub.round} 轮 · ${statusLabel(sub.state)}` : '整文审签'}>
<Btn disabled={busy || !!writeError || !canSubmit} onClick={() => run('整文已提交审核', () => api.rdmProjectCommand(project, 'rdm.review.doc.submit', docSubmitPayload(project.id, docId)))}>整文提交(编制人)</Btn>
{sub && <>
<p>第 {sub.round} 轮 · {statusLabel(sub.state)}</p>
<p className="ad-hint">冻结名单:编制 {submissionDuties.author} · 审核 {submissionDuties.reviewers.join('、')} · 批准 {submissionDuties.approver}</p>
<p className="ad-hint">审核进度:{submissionDuties.reviewers.map(owner => `${owner} ${sub.reviews?.[owner] ? '已通过' : '待审核'}`).join(';')}</p>
<div className="ad-row"><Btn disabled={busy || !!writeError || !canReview} onClick={() => run('已完成审核', () => api.rdmProjectCommand(project, 'rdm.review.doc.approve', docApprovePayload(project.id, docId, '通过')))}>审核通过</Btn>
<Btn disabled={busy || !!writeError || !canReview} onClick={() => { const reason = window.prompt('请输入驳回原因'); if (reason?.trim()) void run('已驳回整文', () => api.rdmProjectCommand(project, 'rdm.review.doc.reject', docRejectPayload(project.id, docId, reason.trim()))) }}>驳回</Btn>
<Btn variant="primary" disabled={busy || !!writeError || !canFinal} onClick={() => run('整文已批准', () => api.rdmProjectCommand(project, 'rdm.review.doc.final', docFinalPayload(project.id, docId, sub.round)))}>终批(批准人)</Btn></div>
</>}
{sub === null && <p className="ad-hint">尚无整文提交记录</p>}
{sub === undefined && <p className="ad-hint">审签状态尚未核验,暂不能执行审签操作</p>}
</SettingsGroup>}
{selection.size > 0 && <SettingsGroup title={`批量分配 · 已选 ${selection.size} 份文档`}>
<p className="ad-hint">最多选择 20 份;提交前读取各文档版本、预览影响,任一失败整批不写入</p>
<RoleFilter roles={roles} value={roleId} onChange={setRoleId} disabled={!canAssign || !!roleError} />
<DutyPeople value={batchDraft} onChange={setBatchDraft} fetchPage={memberPage} people={people} disabled={!canAssign || busy || members?.accountDirectoryAvailable !== true} labelPrefix="批量" />
<div className="ad-row"><Btn variant="primary" disabled={!canAssign || busy || !!dutyValidation(batchDraft)} onClick={batchApply}>预览并分配 {selection.size} 份文档</Btn>
<Btn disabled={busy} onClick={() => { if (!batchDirty || window.confirm('清空已选文档及未保存的批量职责?')) { fence.cancel('batch-preview'); setSelection(new Set()); setBatchDraft(blank()) } }}>清空选择</Btn></div>
</SettingsGroup>}
<Feedback error={error || roleError} notice={notice} />
</div>
}
-145
View File
@@ -1,145 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Btn, Picker } from '@ui'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { memberAddPayload, memberRemovePayload, memberSetRolePayload } from '../rdmManagement'
import { Feedback, personItem, personLabel, projectContext, projectRoleLabel, readableError,
RoleFilter, useRequestFence, useRoleOptions, type Person, type MemberList } from './rdmSettingsShared'
export function RdmMembersSection({ project }: { project: Project }) {
const owner = useStore(s => s.me?.owner)
const context = projectContext(project, owner)
const fence = useRequestFence(context)
const writeError = projectWorkError(project)
const [list, setList] = useState<MemberList | null>(null)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [picked, setPicked] = useState<string[]>([])
const [role, setRole] = useState<'member' | 'owner'>('member')
const [roleId, setRoleId] = useState('')
const [match, setMatch] = useState<'exact' | 'fuzzy'>('fuzzy')
const [impact, setImpact] = useState<{ member: string; canRemove: boolean; lastOwner: boolean; reasons: string[]; documents: { docId: string; title?: string; label?: string; sectionId?: string; activeRound?: number }[] } | null>(null)
const { roles, error: roleError } = useRoleOptions(project, 'members', list?.canManageMembers === true)
const candidates = useRef(new Map<string, Person>())
const canManage = list?.canManageMembers === true && !writeError
const directoryReady = list?.accountDirectoryAvailable === true
const rowsRef = useRef(list?.members ?? [])
rowsRef.current = list?.members ?? []
const refresh = async (cursor = '') => {
const current = fence.begin('members')
try {
const out = await api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { cursor, limit: 50 })
if (!current()) return false
if (!Array.isArray(out.members)) throw new Error('成员查询接口未就绪')
setList(prev => cursor && prev ? { ...out, members: [...prev.members, ...out.members] } : out)
return true
} catch (e) { if (current()) { setError(readableError(e)); if (!cursor) setList(null) } return false }
}
useEffect(() => {
setList(null); setPicked([]); candidates.current.clear(); setRoleId(''); setRole('member')
setError(''); setNotice(''); setBusy(false); setImpact(null)
void refresh()
}, [context])
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
const current = fence.begin('candidates')
const out = await api.rdmProjectCommand<{ accounts: Person[]; next?: string }>(project, 'rdm.account.list', { query, cursor, limit, roleId, match })
if (!current()) return { items: [] }
if (!Array.isArray(out.accounts)) throw new Error('账号目录接口未就绪')
const joined = new Set(rowsRef.current.map(person => person.owner))
return { items: out.accounts.map(person => {
const candidate = { ...person, joined: person.joined === true || joined.has(person.owner) }
candidates.current.set(candidate.owner, candidate)
return personItem(candidate, true)
}), nextCursor: out.next || undefined }
}, [project, roleId, match, context])
const run = async (message: string, mutation: () => Promise<unknown>, clearPick = false) => {
if (!canManage || busy) return
const current = fence.begin('mutation')
setBusy(true); setError(''); setNotice('')
try {
await mutation()
if (!current()) return
if (clearPick) setPicked([])
const verified = await refresh()
if (current() && verified) setNotice(message)
} catch (e) {
if (!current()) return
setError(`${readableError(e)};已重新读取成员状态,请核对后再操作`)
await refresh()
} finally { if (current()) setBusy(false) }
}
const pickedPerson = candidates.current.get(picked[0] || '')
const addable = pickedPerson?.valid === true && pickedPerson.joined === false
const previewRemove = async (person: Person) => {
if (!canManage || busy) return
const current = fence.begin('impact'); setBusy(true); setError(''); setImpact(null)
try {
const out = await api.rdmProjectCommand<NonNullable<typeof impact>>(project, 'rdm.project.member.impact', { member: person.owner })
if (!current()) return
if (out.member !== person.owner || typeof out.canRemove !== 'boolean' || !Array.isArray(out.documents) || !Array.isArray(out.reasons)) throw new Error('成员职责影响查询尚未就绪,暂不能移除成员')
setImpact(out)
} catch (e) { if (current()) setError(readableError(e)) }
finally { if (current()) setBusy(false) }
}
return <div className="rdm-settings">
<div className="ad-create">
<div className="ad-row">
<RoleFilter roles={roles} value={roleId} disabled={!canManage || !!roleError} onChange={value => { fence.cancel('candidates'); candidates.current.clear(); setRoleId(value); setPicked([]) }} />
<select aria-label="人员匹配方式" className="lg-input" value={match} disabled={!canManage} onChange={e => { fence.cancel('candidates'); setMatch(e.target.value as 'exact' | 'fuzzy'); setPicked([]) }}>
<option value="fuzzy">模糊匹配</option><option value="exact">精确账号或姓名</option>
</select>
<Picker aria-label="选择人员" selected={picked} onSelect={setPicked} fetchPage={fetchPage}
selectedItems={pickedPerson ? [personItem(pickedPerson)] : []} disabled={!canManage || !directoryReady || busy}
reloadKey={`${context}:${roleId}:${match}:${list?.members.map(p => p.owner).join(',')}`}
placeholder="输入账号或姓名,或浏览候选…" />
<select aria-label="新成员项目身份" className="lg-input" value={role} disabled={!canManage || busy} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option><option value="owner">项目负责人</option>
</select>
<Btn variant="primary" disabled={!canManage || !directoryReady || !addable || busy} onClick={() => {
if (!pickedPerson || !addable) return
void run(`已添加 ${personLabel(pickedPerson)}`, () => api.rdmProjectCommand(project, 'rdm.project.member.add', memberAddPayload(project.id, pickedPerson.owner, role)), true)
}}>添加成员</Btn>
</div>
<p className="ad-hint">专业角色用于查找人员,项目身份用于项目管理。已加入人员请在下表调整身份;最后一位项目负责人不可移除或降级</p>
{list && !list.canManageMembers && <p className="ad-hint">你可以查看成员;添加、调整与移除需要管理项目成员权限</p>}
{list && !directoryReady && <p className="ad-hint">系统账号目录尚未就绪,暂不能添加成员</p>}
{writeError && <p className="ad-hint">{writeError}</p>}
</div>
<div className="rdm-table-scroll"><table className="ad-table">
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th>操作</th></tr></thead>
<tbody>{(list?.members ?? []).map(person => <tr key={person.owner}>
<td>{personLabel(person)}{person.name && <span className="ad-dim"> · {person.owner}</span>}</td>
<td>{person.profRole?.name || '未配置专业角色'}</td><td>{projectRoleLabel(person.role)}</td>
<td className="ad-dim">{person.addedBy || '—'}</td><td className="ad-dim">{person.addedAt ? new Date(person.addedAt).toLocaleString() : '—'}</td>
<td><button className="ad-mini" disabled={!canManage || busy} onClick={() => {
const next = person.role === 'owner' ? 'member' : 'owner'
if (window.confirm(`将「${personLabel(person)}」调整为${projectRoleLabel(next)}?`)) void run('已调整项目身份', () => api.rdmProjectCommand(project, 'rdm.project.member.set-role', memberSetRolePayload(project.id, person.owner, next)))
}}>{person.role === 'owner' ? '降为成员' : '升为负责人'}</button>{' '}
<button className="ad-mini" disabled={!canManage || busy} onClick={() => previewRemove(person)}>移除</button></td>
</tr>)}{!list && <tr><td colSpan={6}>{error ? '成员未能加载' : '加载中…'}</td></tr>}
{list?.members.length === 0 && <tr><td colSpan={6}>暂无成员</td></tr>}
</tbody>
</table></div>
{list?.next && <Btn disabled={busy} onClick={() => refresh(list.next)}>加载更多成员</Btn>}
{error && !list && <Btn onClick={() => { setError(''); void refresh() }}>重试</Btn>}
{impact && <div className="settings-group"><h4>移除成员 · {list?.members.find(person => person.owner === impact.member)?.name || impact.member}</h4>
{impact.documents.length > 0 && <><p className="ad-hint">该成员仍承担以下文档职责,请先在文档职责中完成交接</p><ul>{impact.documents.map(document => <li key={`${document.docId}:${document.sectionId || ''}`}>
{document.title || document.label || '受控文档'} <span className="ad-dim">{document.docId}{document.sectionId ? ' · 章节职责' : ''}{document.activeRound ? ` · 第 ${document.activeRound} 轮审核中` : ''}</span>
</li>)}</ul></>}
{impact.reasons.map(reason => <p key={reason} className="ad-hint">{reason}</p>)}
{impact.lastOwner && <p className="ad-hint">最后一位项目负责人不能移除,请先指定其他负责人</p>}
<div className="ad-row"><Btn disabled={busy || !canManage || !impact.canRemove || impact.lastOwner || impact.documents.length > 0} onClick={() => {
if (window.confirm(`确认移除「${impact.member}」?`)) void run('已移除项目成员', async () => { await api.rdmProjectCommand(project, 'rdm.project.member.remove', memberRemovePayload(project.id, impact.member)); setImpact(null) })
}}>确认移除</Btn><Btn disabled={busy} onClick={() => { fence.cancel('impact'); setImpact(null) }}>取消</Btn></div>
</div>}
<Feedback error={error || roleError} notice={notice} />
</div>
}
-192
View File
@@ -1,192 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Btn, Collapsible, Picker } from '@ui'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { actionLabel, describeSource } from '../rdmManagement'
import { Feedback, personItem, projectContext, projectRoleLabel, readableError, RoleFilter,
useRequestFence, useRoleOptions, type MemberList } from './rdmSettingsShared'
export interface MatrixAction {
action: string; label?: string; scope?: string; allowed: boolean; defaultAllowed?: boolean; capAllowed?: boolean
override?: null | boolean; source: string; sourceLabel?: string; reason?: string
}
interface MatrixRow {
member: string; name?: string; role: string; profRole?: { roleId: string; name: string } | null; actions: MatrixAction[]
}
interface MatrixOut extends MatrixRow { projectId: string; canConfigurePermissions?: boolean }
interface MatrixBatch { projectId: string; rows: MatrixRow[]; next?: string; count?: number; canConfigurePermissions?: boolean }
export function projectActions(actions: MatrixAction[]): MatrixAction[] {
return actions.filter(action => action.scope === 'project' && action.action !== 'project.create')
}
export function overrideMode(action: MatrixAction): 'default' | 'allow' | 'deny' | 'unavailable' {
return action.override === null ? 'default' : action.override === true ? 'allow' : action.override === false ? 'deny' : 'unavailable'
}
const actionGroup = (action: string) => action.startsWith('doc.') || action.startsWith('review.') || action === 'duty.assign' ? 'documents'
: action.startsWith('trace.') || action.startsWith('knowledge.') ? 'knowledge' : 'project'
const reasonText = (action: MatrixAction) => {
if (!action.reason) return action.allowed ? '具备项目动作权限;具体资源仍按职责与状态核验' : '当前项目策略未允许'
if (action.reason.includes('project.create') || action.reason === action.action) return '配置范围不符'
return /[\u4e00-\u9fff]/.test(action.reason) ? action.reason : '当前策略未允许,请联系项目负责人核对'
}
export function ActionDetailTable({ actions, canManage = false, busy = false, onOverride }: {
actions: MatrixAction[]; canManage?: boolean; busy?: boolean
onOverride?: (action: string, mode: 'default' | 'allow' | 'deny') => void
}) {
return <div className="rdm-table-scroll"><table className="ad-table">
<thead><tr><th>项目操作</th><th>权限</th><th>来源</th><th>说明</th>{canManage && <th>项目内配置</th>}</tr></thead>
<tbody>{projectActions(actions).map(action => {
const mode = overrideMode(action)
const supported = mode !== 'unavailable' && typeof action.capAllowed === 'boolean' && typeof action.defaultAllowed === 'boolean'
return <tr key={action.action}><td>{actionLabel(action.action, action.label)}</td>
<td><span className={`ad-chip${action.allowed ? ' ad-chip-admin' : ' ad-chip-warn'}`}>{action.allowed ? '允许' : '禁止'}</span></td>
<td>{action.sourceLabel && action.sourceLabel !== action.source ? action.sourceLabel : describeSource(action.source)}</td>
<td>{reasonText(action)}<Collapsible trigger="查看权限依据">
<p className="ad-hint">角色默认:{action.defaultAllowed === true ? '允许' : action.defaultAllowed === false ? '禁止' : '未提供'} · 管理员上限:{action.capAllowed === true ? '允许' : action.capAllowed === false ? '禁止' : '未提供'}</p>
<p className="ad-hint">项目内配置:{mode === 'default' ? '沿用默认' : mode === 'allow' ? '允许' : mode === 'deny' ? '禁止' : '尚未核验'}</p>
</Collapsible></td>
{canManage && <td><select className="lg-input" aria-label={`${actionLabel(action.action, action.label)}项目覆盖`} value={mode} disabled={busy || !supported}
onChange={e => onOverride?.(action.action, e.target.value as 'default' | 'allow' | 'deny')}>
{!supported && <option value="unavailable">配置尚未核验</option>}
<option value="default">沿用角色默认</option><option value="allow" disabled={action.capAllowed !== true}>项目内允许</option><option value="deny">项目内禁止</option>
</select>{action.capAllowed === false && <p className="ad-hint">管理员上限禁止授予</p>}</td>}
</tr>
})}</tbody>
</table></div>
}
export function RdmPermMatrixSection({ project }: { project: Project }) {
const me = useStore(s => s.me)
const context = projectContext(project, me?.owner)
const fence = useRequestFence(context)
const writeError = projectWorkError(project)
const [view, setView] = useState<'member' | 'matrix'>('member')
const [member, setMember] = useState(me?.owner || '')
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
const [batch, setBatch] = useState<MatrixBatch | null>(null)
const [canConfigure, setCanConfigure] = useState(false)
const [query, setQuery] = useState('')
const [roleId, setRoleId] = useState('')
const [projectRole, setProjectRole] = useState('')
const [group, setGroup] = useState('all')
const [expanded, setExpanded] = useState<{ member: string; action: string } | null>(null)
const [busy, setBusy] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const { roles, error: roleError } = useRoleOptions(project, 'permissions', canConfigure)
const queryMember = async (owner: string) => {
if (!owner) return false
const current = fence.begin('read')
setMatrix(null); setLoading(true); setError('')
try {
const out = await api.rdmProjectCommand<MatrixOut>(project, 'rdm.perm.check', { member: owner })
if (!current()) return false
if (out.member !== owner || !Array.isArray(out.actions)) throw new Error('权限查询结果与成员不一致')
setCanConfigure(out.canConfigurePermissions === true)
setMatrix(out)
return true
} catch (e) { if (current()) { setError(readableError(e)); setCanConfigure(false) } return false }
finally { if (current()) setLoading(false) }
}
const queryBatch = async (cursor = '') => {
if (!canConfigure) return false
const current = fence.begin('read')
setLoading(true); setError(''); if (!cursor) setBatch(null)
try {
const out = await api.rdmProjectCommand<MatrixBatch>(project, 'rdm.perm.matrix', { query, roleId, projectRole, cursor, limit: 20 })
if (!current()) return false
if (out.canConfigurePermissions !== true || !Array.isArray(out.rows)) throw new Error('没有查看项目权限矩阵的能力')
setBatch(previous => cursor && previous ? { ...out, rows: [...previous.rows, ...out.rows] } : out)
return true
} catch (e) { if (current()) setError(readableError(e)); return false }
finally { if (current()) setLoading(false) }
}
useEffect(() => {
fence.invalidate(); setView('member'); setMember(me?.owner || ''); setBatch(null); setMatrix(null)
setCanConfigure(false); setExpanded(null); setQuery(''); setRoleId(''); setProjectRole(''); setBusy(false); setNotice(''); setError('')
void queryMember(me?.owner || '')
}, [context])
useEffect(() => {
if (view !== 'matrix' || !canConfigure) return
fence.cancel('read'); setExpanded(null)
const timer = setTimeout(() => { void queryBatch() }, query ? 250 : 0)
return () => { clearTimeout(timer); fence.cancel('read') }
}, [view, query, roleId, projectRole, canConfigure])
const memberPage = useCallback(async (query: string, cursor: string, limit: number) => {
const out = await api.rdmProjectCommand<MemberList>(project, 'rdm.project.member.list', { query, roleId, projectRole, cursor, limit })
if (!Array.isArray(out.members) || out.canConfigurePermissions !== true) throw new Error('无权选择其他项目成员')
return { items: out.members.map(person => personItem(person)), nextCursor: out.next || undefined }
}, [project, roleId, projectRole])
const override = async (owner: string, actionId: string, mode: 'allow' | 'deny' | 'default') => {
const row = view === 'member' ? matrix : batch?.rows.find(row => row.member === owner)
const action = row?.actions.find(action => action.action === actionId && action.scope === 'project' && action.action !== 'project.create')
if (!canConfigure || writeError || busy || !action || overrideMode(action) === 'unavailable' || (mode === 'allow' && action.capAllowed !== true)) return
const current = fence.begin('mutation')
setBusy(true); setNotice(''); setError('')
try {
await api.rdmProjectCommand(project, mode === 'default' ? 'rdm.perm.unset' : 'rdm.perm.set',
{ member: owner, action: actionId, ...(mode === 'default' ? {} : { allow: mode === 'allow' }) })
if (!current()) return
const verified = view === 'matrix' ? await queryBatch() : await queryMember(owner)
if (current() && verified) setNotice(`${actionLabel(actionId)}:${mode === 'default' ? '已恢复角色默认' : mode === 'allow' ? '已配置为项目内允许' : '已配置为项目内禁止'}`)
} catch (e) {
if (!current()) return
const failure = readableError(e)
if (view === 'matrix') await queryBatch(); else await queryMember(owner)
if (current()) setError(`${failure};已尝试核对最新权限,未自动重试写入`)
} finally { if (current()) setBusy(false) }
}
const visible = (actions: MatrixAction[]) => projectActions(actions).filter(action => group === 'all' || actionGroup(action.action) === group)
const matrixActions = [...new Map((batch?.rows ?? []).flatMap(row => visible(row.actions)).map(action => [action.action, action])).values()]
const expandedRow = expanded ? batch?.rows.find(row => row.member === expanded.member) : undefined
return <div className="rdm-settings">
<div className="ad-create"><div className="ad-row">
<select aria-label="权限查看方式" className="lg-input" value={view} disabled={busy} onChange={e => {
const next = e.target.value as 'member' | 'matrix'
if (next === 'matrix' && !canConfigure) return
fence.cancel('read'); fence.cancel('mutation'); setExpanded(null); setNotice(''); setView(next)
if (next === 'member') void queryMember(member || me?.owner || '')
}}><option value="member">按成员查看</option><option value="matrix" disabled={!canConfigure}>权限矩阵</option></select>
<select aria-label="权限分组" className="lg-input" value={group} onChange={e => { setGroup(e.target.value); setExpanded(null) }}>
<option value="all">全部项目操作</option><option value="project">项目与成员</option><option value="documents">文档与审签</option><option value="knowledge">知识与追溯</option>
</select>
<RoleFilter roles={roles} value={roleId} disabled={!canConfigure || !!roleError || busy} onChange={value => { fence.cancel('read'); setRoleId(value); setExpanded(null) }} />
<select aria-label="项目身份筛选" className="lg-input" value={projectRole} disabled={!canConfigure || busy} onChange={e => { fence.cancel('read'); setProjectRole(e.target.value); setExpanded(null) }}>
<option value="">全部项目身份</option><option value="owner">项目负责人</option><option value="member">项目成员</option>
</select>
{view === 'member' && <Picker aria-label="选择权限成员" selected={member ? [member] : []}
selectedItems={[{ id: member, title: matrix?.name || (member === me?.owner ? me?.name || member : member), subtitle: member }]}
onSelect={ids => { fence.cancel('read'); setMember(ids[0] || me?.owner || ''); void queryMember(ids[0] || me?.owner || '') }}
fetchPage={memberPage} disabled={!canConfigure || busy} reloadKey={`${context}:${roleId}:${projectRole}`} />}
{view === 'matrix' && <input aria-label="搜索权限成员" className="lg-input" placeholder="输入账号或姓名" value={query} onChange={e => { fence.cancel('read'); setBatch(null); setQuery(e.target.value) }} />}
<Btn disabled={loading || busy} onClick={() => view === 'matrix' ? queryBatch() : queryMember(member)}>刷新权限</Btn>
</div>
<p className="ad-hint">默认显示自己的权限。查看其他成员、矩阵和配置需要权限管理能力;文档操作仍按具体资源、职责和审签状态核验</p>
</div>
{writeError && <p className="ad-hint">{writeError}</p>}
{loading && <p className="ad-hint" role="status">核验项目权限…</p>}
{view === 'member' && matrix && <>
<p>{matrix.name || matrix.member} · {projectRoleLabel(matrix.role)}{matrix.profRole?.name ? ` · ${matrix.profRole.name}` : ''}</p>
<ActionDetailTable actions={visible(matrix.actions)} canManage={canConfigure && !writeError} busy={busy} onOverride={(action, mode) => override(matrix.member, action, mode)} />
</>}
{view === 'matrix' && batch && <>
<div className="rdm-table-scroll"><table className="ad-table">
<thead><tr><th>成员</th>{matrixActions.map(action => <th key={action.action}>{actionLabel(action.action, action.label)}</th>)}</tr></thead>
<tbody>{batch.rows.map(row => <tr key={row.member}><td>{row.name || row.member}<div className="ad-dim">{projectRoleLabel(row.role)}{row.profRole?.name ? ` · ${row.profRole.name}` : ''}</div></td>
{matrixActions.map(action => {
const permission = row.actions.find(item => item.action === action.action && item.scope === 'project')
return <td key={action.action}>{permission ? <button className="ad-mini" aria-label={`${row.name || row.member} · ${actionLabel(action.action)} · ${permission.allowed ? '允许' : '禁止'}`}
onClick={() => setExpanded({ member: row.member, action: action.action })}>{permission.allowed ? '允许' : '禁止'}</button> : '未核验'}</td>
})}</tr>)}</tbody>
</table></div>
{batch.rows.length === 0 && <p className="ad-hint">没有匹配的项目成员</p>}
{batch.next && <Btn disabled={loading || busy} onClick={() => queryBatch(batch.next)}>加载更多成员</Btn>}
{expanded && <div className="settings-group"><h4>{expandedRow?.name || expanded.member} · 权限依据</h4>
<ActionDetailTable actions={expandedRow?.actions.filter(action => action.action === expanded.action) || []}
canManage={canConfigure && !writeError} busy={busy} onOverride={(action, mode) => override(expanded.member, action, mode)} /></div>}
</>}
<Feedback error={error || (canConfigure ? roleError : '')} notice={notice} />
</div>
}
-104
View File
@@ -1,104 +0,0 @@
import { Btn, SettingsGroup, SettingRow } from '@ui'
import type { SettingDesc } from '../contracts'
const groupNames: Record<string, string> = {
models: '模型与密钥', runtime: '运行环境', context: '上下文', retrieval: '检索',
ui: '界面', plugins: '系统插件', crossSession: '个人会话协作', tools: '工具服务', deliverables: '项目交付规则', telemetry: '运行观测',
}
const optionLabels: Record<string, string> = { modern: '现代', formal: '正式', minimal: '简洁',
dark: '深色', light: '浅色', system: '跟随系统', zh: '中文', en: '英文', auto: '自动', off: '关闭', on: '开启',
disabled: '关闭', enabled: '开启', relevant: '相关会话', all: '全部本人会话', none: '关闭',
direct: '直接对话', squad: '协作小队', zhCN: '简体中文', 'zh-CN': '简体中文', 'en-US': '英语',
}
function readableValue(value: unknown) {
if (value === true) return '开启'
if (value === false) return '关闭'
if (value === undefined || value === null || value === '') return '未配置'
const text = String(value)
return optionLabels[text] || text
}
export function scopedSchema(schema: SettingDesc[], scope: string) {
return schema.filter(desc => !desc.scope
? scope === 'user' || scope === 'project-personal'
: desc.scope === scope || (scope === 'project-personal' && desc.scope === 'user'))
}
export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } {
return desc.secret || desc.type === 'secret' ? { type: 'password', autoComplete: 'new-password' } : { type: 'text', autoComplete: 'off' }
}
export function SettingInput({ desc, value, onChange, disabled = false }: { desc: SettingDesc; value: any; onChange: (value: any) => void; disabled?: boolean }) {
if (desc.type === 'boolean') return <select aria-label={desc.label} value={value === true ? 'true' : value === false ? 'false' : ''} disabled={disabled}
onChange={e => onChange(e.target.value === '' ? undefined : e.target.value === 'true')}>
<option value="">沿用默认({readableValue(desc.default)})</option><option value="true">开启</option><option value="false">关闭</option>
</select>
if (desc.type === 'enum') return <select aria-label={desc.label} value={value ?? ''} disabled={disabled} onChange={e => onChange(e.target.value || undefined)}>
<option value="">沿用默认({readableValue(desc.default)})</option>
{(desc.options ?? []).map(option => <option key={option} value={option}>{optionLabels[option] || option}</option>)}
</select>
if (desc.type === 'number') return <input aria-label={desc.label} type="number" step="any" disabled={disabled} value={value ?? ''}
placeholder={`默认:${readableValue(desc.default)}`} onChange={e => onChange(e.target.value === '' || !Number.isFinite(e.target.valueAsNumber) ? undefined : e.target.valueAsNumber)} />
const secret = desc.secret || desc.type === 'secret'
return <input aria-label={desc.label} {...settingInputAttrs(desc)} disabled={disabled} value={value === '••••••' ? '' : value ?? ''}
placeholder={secret ? value === '••••••' ? '已配置,输入以替换' : '未配置,输入密钥' : `默认:${readableValue(desc.default)}`}
onChange={e => onChange(secret ? e.target.value || undefined : e.target.value)} />
}
export function SettingsFields({ schema, draft, defaults = {}, onChange, disabled = false, source = '个人配置' }: {
schema: SettingDesc[]; draft: Record<string, any>; defaults?: Record<string, any>
onChange: (key: string, value: any) => void; disabled?: boolean; source?: string
}) {
const groups: Record<string, SettingDesc[]> = {}
for (const desc of schema) (groups[desc.group] ??= []).push(desc)
return <>{Object.entries(groups).map(([group, descs]) => <SettingsGroup key={group} title={groupNames[group] || '其他设置'}>
{descs.map(desc => <SettingRow key={desc.key} label={desc.label} effect={desc.effect}
desc={`${desc.description || ''}${desc.description ? ' · ' : ''}${draft[desc.key] === undefined ? `沿用默认:${desc.secret ? '受保护配置' : readableValue(defaults[desc.key] ?? desc.default)}` : source}`}>
<SettingInput desc={{ ...desc, default: defaults[desc.key] ?? desc.default }} value={draft[desc.key]} disabled={disabled} onChange={value => onChange(desc.key, value)} />
{desc.type === 'string' && !desc.secret && <Btn disabled={disabled || draft[desc.key] === undefined} onClick={() => onChange(desc.key, undefined)}>沿用默认</Btn>}
</SettingRow>)}
</SettingsGroup>)}</>
}
export function flatten(map: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
const visit = (object: Record<string, any>, prefix = '') => {
for (const [key, value] of Object.entries(object || {})) {
const path = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object' && !Array.isArray(value)) visit(value, path)
else out[path] = value
}
}
visit(map); return out
}
function unflatten(map: Record<string, any>, preserveEmpty = false): Record<string, any> {
const out: Record<string, any> = {}
for (const [path, value] of Object.entries(map)) {
if (value === undefined || (value === '' && !preserveEmpty)) continue
const parts = path.split('.')
let current = out
for (const part of parts.slice(0, -1)) current = current[part] ??= {}
current[parts[parts.length - 1]] = value
}
return out
}
export function scopedDraft(values: Record<string, unknown>, schema: SettingDesc[]) {
const keys = new Set(schema.map(desc => desc.key))
return Object.fromEntries(Object.entries(flatten(values)).filter(([key]) => keys.has(key)))
}
/** A settings write is a merge patch; omitted overrides are deliberately removed. */
export function settingsMutation(initial: Record<string, any>, draft: Record<string, any>, schema: SettingDesc[]) {
const changed: Record<string, any> = {}
const unset: string[] = []
for (const desc of schema) {
const next = draft[desc.key]
if (Object.is(initial[desc.key], next)) continue
if (desc.secret || desc.type === 'secret') {
if (next === undefined || next === '••••••') continue
changed[desc.key] = next
} else if (next === undefined) unset.push(desc.key)
else changed[desc.key] = next
}
return { values: unflatten(changed, true), unset }
}
export function personalSettingsMutation(initial: Record<string, any>, draft: Record<string, any>, schema: SettingDesc[]) {
const mutation = settingsMutation(initial, draft, schema)
const values = flatten(mutation.values)
for (const key of mutation.unset) values[key] = null
return unflatten(values, true)
}
+156 -98
View File
@@ -1,110 +1,168 @@
import { useEffect, useState } from 'react'
import { api, type SettingsView } from '../api'
// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义,
// 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。
// 重设计方案 阶段②(IA):设置瘦身——只留个人偏好(常规);智能体画像/插件管理/
// 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { isFontSizeChoice, store, useStore } from '../store'
import type { ThemeChoice } from '../theme'
import { SettingsShell, SettingRow, SettingsGroup, Btn, EmptyState } from '@ui'
import type { SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
import { BorrowPanels } from './BorrowPanels'
import { flatten, scopedDraft, scopedSchema, SettingsFields, personalSettingsMutation } from './SettingsFields'
import { Feedback, readableError, useRequestFence } from './rdmSettingsShared'
import { MyAccountSettings } from './MyAccountSettings'
export { SettingInput, settingInputAttrs } from './SettingsFields'
type Section = 'settings' | 'account'
// 分组显示名(项目设置面板共用)。
export const groupNames: Record<string, string> = {
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具',
}
export function SettingsModal() {
const owner = useStore(s => s.me?.owner)
const projectId = useStore(s => s.currentProjectId)
const projectName = useStore(s => s.projects.find(project => project.id === projectId)?.name)
const [section, setSection] = useState<Section>('settings')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
const [saved, setSaved] = useState(false)
const theme = useStore(s => s.ui.theme)
const fontSize = useStore(s => s.ui.fontSize)
const fence = useRequestFence(`${owner || ''}:${projectId || ''}`)
const [section, setSection] = useState('settings')
const [settings, setSettings] = useState<SettingsView | null>(null)
const [projectSettings, setProjectSettings] = useState<SettingsView | null>(null)
const [draft, setDraft] = useState<Record<string, any>>({})
const [personalDraft, setPersonalDraft] = useState<Record<string, any>>({})
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const schema = settings ? scopedSchema(settings.schema, 'user') : []
const personalSchema = projectSettings ? scopedSchema(projectSettings.personalSchema || [], 'project-personal') : []
const initial = settings ? scopedDraft(settings.user, schema) : {}
const personalInitial = projectSettings ? scopedDraft(projectSettings.personalProject || {}, personalSchema) : {}
const dirty = JSON.stringify(draft) !== JSON.stringify(initial) || JSON.stringify(personalDraft) !== JSON.stringify(personalInitial)
const load = async (projectOnly = false) => {
const current = fence.begin('load')
setError('')
try {
if (!projectOnly) {
const out = await api.getSettings()
if (!current()) return false
setSettings(out); setDraft(scopedDraft(out.user, scopedSchema(out.schema, 'user')))
}
if (projectId) {
const out = await api.getSettings(projectId)
if (!current()) return false
const personal = scopedSchema(out.personalSchema || [], 'project-personal')
setProjectSettings(out); setPersonalDraft(scopedDraft(out.personalProject || {}, personal))
}
return true
} catch (e) { if (current()) setError(`个人设置无法读取:${readableError(e)}`); return false }
}
useEffect(() => {
fence.invalidate(); setSettings(null); setProjectSettings(null); setDraft({}); setPersonalDraft({}); setNotice(''); setBusy(false)
void load()
}, [owner, projectId])
const depart = () => !dirty || window.confirm('个人设置尚未保存,离开后将丢弃这些改动。继续?')
api.getSettings().then(s => {
setSchema(s.schema)
setUser(flatten(s.user))
setDraft(flatten(s.user))
}).catch(() => undefined)
}, [])
const groups = useMemo(() => {
const g: Record<string, SettingDesc[]> = {}
for (const d of schema) (g[d.group] ??= []).push(d)
return g
}, [schema])
const save = async () => {
if (busy || (section === 'settings' ? !settings : !projectSettings?.personalSchema)) return
const current = fence.begin('save')
setBusy(true); setError(''); setNotice('')
try {
if (section === 'settings') await api.putUserSettings(personalSettingsMutation(initial, draft, schema))
else if (projectId) await api.putPersonalProjectSettings(projectId, personalSettingsMutation(personalInitial, personalDraft, personalSchema))
if (!current()) return
if (await load()) {
if (!current()) return
setNotice('个人偏好已保存;按各项标注的生效时机应用')
void store.refreshPlugins()
}
} catch (e) {
if (!current()) return
const message = `保存结果未能确认:${readableError(e)};请核对最新设置后再保存`
await load()
if (current()) setError(message)
} finally { if (current()) setBusy(false) }
const values = unflatten(draft)
await api.putUserSettings(values)
setUser(draft)
setSaved(true)
setTimeout(() => setSaved(false), 1500)
// hot 类设置即时生效:主题。
if (draft['ui.theme']) store.setUI({ theme: draft['ui.theme'] })
store.refreshPlugins()
}
return <SettingsShell title="个人设置" active={section}
onSelect={next => { if (!busy && depart()) { setDraft(initial); setPersonalDraft(personalInitial); setNotice(''); setSection(next) } }}
onClose={() => { if (depart()) { fence.invalidate(); store.openSettings(false) } }}
sections={[{ key: 'settings', label: '个人偏好', icon: 'settings' }, ...(projectId ? [{ key: 'project-personal', label: '我在当前项目的偏好', icon: 'folder' as const }] : [])]}
footer="个人偏好只影响本人;项目业务规则在项目设置中管理"
actions={(section === 'settings' ? settings : projectSettings?.personalSchema) ? <Btn variant="primary" icon="check" disabled={busy || !dirty} onClick={save}>保存个人设置</Btn> : undefined}>
{section === 'settings' && <>
<SettingsGroup title="外观">
<SettingRow label="主题" desc="立即应用到这台设备;深色和浅色可随个人偏好保存" effect="hot">
<select value={theme} aria-label="主题" disabled={busy || !settings} onChange={e => {
const value = e.target.value as ThemeChoice; store.setUI({ theme: value })
if (value === 'dark' || value === 'light') setDraft(previous => ({ ...previous, 'ui.theme': value }))
}}><option value="dark">深色</option><option value="light">浅色</option><option value="molv">墨绿</option><option value="system">跟随系统</option></select>
</SettingRow>
<SettingRow label="界面字号" desc="仅保存在这台设备上;图标和间距不随字号缩放" effect="hot">
<select value={fontSize} aria-label="界面字号" onChange={e => { const value = Number(e.target.value); if (isFontSizeChoice(value)) store.setUI({ fontSize: value }) }}>
<option value={13}>小</option><option value={14}>标准</option><option value={16}>大</option><option value={18}>特大</option>
</select>
</SettingRow>
</SettingsGroup>
{!settings && <><EmptyState icon="settings">{error || '读取个人偏好…'}</EmptyState>{error && <Btn onClick={() => load()}>重试</Btn>}</>}
{settings && <SettingsFields schema={schema.filter(desc => desc.key !== 'ui.theme')} draft={draft} defaults={flatten(settings.defaults)} disabled={busy} onChange={(key, value) => setDraft(previous => ({ ...previous, [key]: value }))} />}
<BorrowPanels />
</>}
{section === 'project-personal' && <>
<p className="ad-hint">当前项目:{projectName || '尚未选择'}。以下偏好仅影响本人;跨会话读取仅访问本人授权的会话,不开放其他成员的私聊、原件或草稿</p>
{!projectSettings && <><EmptyState icon="settings">{error || '读取当前项目的个人偏好…'}</EmptyState>{error && <Btn onClick={() => load(true)}>重试</Btn>}</>}
{projectSettings && !projectSettings.personalSchema && <EmptyState icon="settings">项目个人偏好接口尚未就绪,请升级服务后重试</EmptyState>}
{projectSettings?.personalSchema && personalSchema.length === 0 && <EmptyState icon="settings">当前没有可调整的个人项目偏好</EmptyState>}
{projectSettings?.personalSchema && <SettingsFields schema={personalSchema} draft={personalDraft}
defaults={{ ...flatten(projectSettings.personalDefaults || {}), ...flatten(projectSettings.personalUser || {}) }} source="本人在当前项目的偏好" disabled={busy}
onChange={(key, value) => setPersonalDraft(previous => ({ ...previous, [key]: value }))} />}
</>}
<Feedback error={error} notice={notice} />
</SettingsShell>
return (
<SettingsShell
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 ? (
<>
{saved && <span style={{ color: 'var(--ok)', fontSize: 'var(--fs-ui-sm)', marginRight: 8 }}>✓ 已保存</span>}
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
</>
) : undefined}
>
{section === 'settings' && (
<SettingsGroup title="外观">
<SettingRow label="主题" desc="墨绿是可选身份皮肤。跟随系统只在深色与浅色之间切换。深色和浅色会随设置保存" effect="hot">
<select value={theme} onChange={e => {
const next = e.target.value as ThemeChoice
store.setUI({ theme: next })
if (next === 'dark' || next === 'light') setDraft(x => ({ ...x, 'ui.theme': next }))
}}>
<option value="dark">深色</option>
<option value="light">浅色</option>
<option value="molv">墨绿</option>
<option value="system">跟随系统</option>
</select>
</SettingRow>
<SettingRow label="界面字号" desc="小、标准、大、特大。只保存在这台设备上,图标和间距不跟随缩放" effect="hot">
<select value={fontSize} onChange={e => {
const next = Number(e.target.value)
if (isFontSizeChoice(next)) store.setUI({ fontSize: next })
}}>
<option value={13}>小</option>
<option value={14}>标准</option>
<option value={16}>大</option>
<option value={18}>特大</option>
</select>
</SettingRow>
</SettingsGroup>
)}
{section === 'settings' && Object.entries(groups).map(([g, descs]) => {
const rows = descs.filter(d => d.key !== 'ui.theme')
if (rows.length === 0) return null
return (
<SettingsGroup key={g} title={groupNames[g] ?? g}>
{rows.map(d => (
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
</SettingRow>
))}
</SettingsGroup>
)
})}
{section === 'settings' && <BorrowPanels />}
{section === 'account' && <MyAccountSettings />}
</SettingsShell>
)
}
// 输入框语义(2026-10-12 增量 3):仅秘密字段用密码框;普通文本用 text——
// 无条件 type="password" 会把整排设置项当凭据表单(浏览器「password 不在 form 中/
// 缺少 username」警告来源之一),也把普通文本无谓遮蔽。
export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } {
const isSecret = desc.secret || desc.type === 'secret'
return isSecret
? { type: 'password', autoComplete: 'new-password' }
: { type: 'text', autoComplete: 'off' }
}
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
if (desc.type === 'boolean') {
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
}
if (desc.type === 'enum') {
return <select value={value} onChange={e => onChange(e.target.value)}>
{(desc.options ?? []).map(o => <option key={o} value={o}>{o}</option>)}
</select>
}
const isSecret = desc.secret || desc.type === 'secret'
const attrs = settingInputAttrs(desc)
return <input type={attrs.type} autoComplete={attrs.autoComplete} style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
onChange={e => onChange(e.target.value)} />
}
// 点路径扁平化/还原。
function flat(m: Record<string, any>, prefix: string, out: Record<string, any>) {
for (const [k, v] of Object.entries(m)) {
const key = prefix ? `${prefix}.${k}` : k
if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out)
else out[key] = v
}
}
export function flatten(m: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
flat(m, '', out)
return out
}
export function unflatten(flatMap: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(flatMap)) {
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
const parts = k.split('.')
let cur = out
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]] ??= {}
cur[parts[parts.length - 1]] = v
}
return out
}
+2 -1
View File
@@ -57,6 +57,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const serverWorkspaces = useStore(s => s.serverWorkspaces)
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
@@ -74,7 +75,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
} catch { /* store owns the actionable failure toast */ }
}
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable, rdmCockpit: rdmCockpitAvailable })
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
+2 -2
View File
@@ -36,7 +36,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur
<Icon name="message" size={15} /><span>对话</span>
</button>
</>}
<button className="shell-nav-button" aria-label="打开工作"
{(compact || ui.rightCollapsed) && <><button className="shell-nav-button" aria-label="打开工作"
aria-pressed={compact ? activePane === 'work' : !ui.rightCollapsed && ui.rightTab === 'work'}
title={pending > 0 ? `${pending} 项待处理` : '工作'} onClick={onWork}>
<Icon name="inbox" size={15} /><span>工作</span>
@@ -45,7 +45,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur
<button className="shell-nav-button" aria-label="打开文件"
aria-pressed={compact ? activePane === 'files' : !ui.rightCollapsed && ui.rightTab === 'files'} onClick={onFiles}>
<Icon name="folder" size={15} /><span>文件</span>
</button>
</button></>}
</nav>
</header>
)
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useMemo, useState } from 'react'
import { SafeMarkdown } from '@ui'
import type { StreamEvent } from '../contracts'
import { loadRunEvents } from '../runEvents'
import { store, useStore } from '../store'
import { runEventsToTimeline } from '../live'
import { mergeProcessTimeline } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { MessageTimestamp } from './MessageTimestamp'
/** The work pane and inline fallback share one selection. Only this detail host
* reads that run's authorized durable events; opening a record never adds chat. */
export function WorkExecutionRecord() {
const selected = useStore(s => s.workRecord)
const conversationId = useStore(s => s.currentConvId)
const messages = useStore(s => s.messages)
const run = useStore(s => s.runs.find(r => r.id === selected?.runId))
const [result, setResult] = useState<{ runId: string; events: StreamEvent[] } | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!selected || selected.conversationId !== conversationId) return
const controller = new AbortController()
let active = true
setResult(null); setFailed(false)
void (async () => {
try {
const events = await loadRunEvents(selected.conversationId, selected.runId, controller.signal)
if (active) setResult({ runId: selected.runId, events })
} catch { if (active) setFailed(true) }
})()
return () => { active = false; controller.abort() }
}, [selected?.runId, selected?.conversationId, conversationId, run?.status, run?.endedAt])
const parts = useMemo(() => {
if (!selected || result?.runId !== selected.runId) return []
const saved = messages.filter(message => message.runId === selected.runId && message.role === 'assistant')
.flatMap(message => message.parts).map(part => ({ ...part, process: true }))
return mergeProcessTimeline(saved, runEventsToTimeline(result.events, selected.runId, true), [])
}, [messages, result, selected?.runId])
if (!selected || selected.conversationId !== conversationId) return null
return <section className="work-execution-record" aria-label={`${selected.title}的执行记录`}>
<div className="work-record-heading"><strong>{selected.title} · 执行记录</strong>
<button type="button" className="btn ghost" onClick={() => store.closeWorkRecord()}>返回工作</button></div>
<MessageTimestamp value={run?.createdAt} />
{failed ? <p role="status">完整记录暂不可用,请稍后重新打开</p>
: !result ? <p role="status">正在读取已保存的记录…</p>
: parts.length === 0 ? <p>这项工作没有已保存的过程输出</p>
: <ProcessTimeline parts={parts} live={false} idKey={`work-record:${selected.runId}`}
renderPart={part => <SafeMarkdown className="part-text" markdown={part.text ?? ''} breaks />} />}
</section>
}
+11 -4
View File
@@ -2,13 +2,14 @@
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import type { Run, WorkCardAction } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon, SurfaceView } from '@ui'
import { previewTabsKey } from '../previewtabs'
import { surfaceKey } from '../surfaceKey'
import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane'
import { WorkExecutionRecord } from './WorkExecutionRecord'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
@@ -21,7 +22,9 @@ export const docBridge: DocBridge = {
}
const inboxCallbacks = {
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
onAction: (c: ActivityCard, id: string, payload?: WorkCardAction) => store.cardAction(c, id, payload),
onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c),
onReference: (c: ActivityCard) => store.referenceWork(c),
actionState: (c: ActivityCard) => store.getState().cardActionStates[c.id],
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
@@ -64,6 +67,7 @@ export function Workbench() {
const filter = useStore(s => s.ui.inboxFilter)
const visible = inboxScope === 'project' ? projectCards : cards
const pending = pendingCount(visible)
const unread = visible.filter(card => card.work && card.work.resultVersion > card.work.readVersion).length
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
const switchTab = (tab: 'work' | 'files') =>
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
@@ -71,7 +75,7 @@ export function Workbench() {
<>
<div className="tabs">
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}{unread > 0 ? ` · ${unread} 项新结果` : ''}</button>
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
title="文件搜索、预览、编辑与下载">文件</button>
<span className="spacer" style={{ flex: 1 }} />
@@ -108,10 +112,12 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
projectCards: ActivityCard[]
cards: ActivityCard[]
}) {
const record = useStore(s => s.workRecord)
const showCurrentTask = !hasActiveProgressCard(cards)
return (
<>
<div className="tab-body" data-scroll-sync="inbox">
<div className="tab-body" data-work-scroll>
{record ? <WorkExecutionRecord /> : <>
{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>
@@ -126,6 +132,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={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}
+363 -59
View File
@@ -1,16 +1,19 @@
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 文档职责」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState, useRef, useCallback } from 'react'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { EmptyState, Icon } from '@ui'
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
import {
ROLE_ACTIONS,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
actionLabel,
memberAddPayload, memberRemovePayload, memberSetRolePayload,
permCheckPayload, describeSource,
rolesListPlaceholder, describeRoleListError,
} from '../rdmManagement'
@@ -25,57 +28,28 @@ interface RoleView {
accounts: string[] | null
}
interface RoleActionView { action: string; label: string; scope: 'project' | 'system'; configurable: boolean }
interface RoleListView {
roles: RoleView[]
roleActions?: RoleActionView[]
systemActions?: RoleActionView[]
canManageRoles?: boolean
canAssignRoles?: boolean
}
export function RdmRolesPanel() {
const me = useStore(s => s.me)
const [roles, setRoles] = useState<RoleView[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [catalog, setCatalog] = useState<RoleListView | null>(null)
const request = useRef(0)
const [newName, setNewName] = useState('')
// 动作编辑器
const [editRole, setEditRole] = useState('')
const [editAction, setEditAction] = useState<string>('doc.read')
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 () => {
const seq = ++request.current
try {
const out = await api.rdmCommand<RoleListView>('rdm.role.list')
if (seq !== request.current) return
setErr(''); setRoles(out.roles ?? []); setCatalog(out)
} catch (e) {
if (seq !== request.current) return
setRoles(null); setCatalog(null); setErr(describeRoleListError(String(e)))
}
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
setErr(''); setRoles(out.roles ?? [])
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
}
useEffect(() => { refresh(); return () => { request.current++ } }, [])
const projectActions = catalog?.roleActions?.filter(a => a.scope === 'project' && a.configurable && a.action !== 'project.create')
?? [...ROLE_ACTIONS.filter(a => a !== 'project.create'), 'audit.read'].map(action => ({ action, label: actionLabel(action), scope: 'project' as const, configurable: false }))
const systemActions = catalog?.systemActions?.filter(a => a.scope === 'system')
?? [{ action: 'project.create', label: actionLabel('project.create'), scope: 'system' as const, configurable: false }]
const canManage = catalog?.canManageRoles === true && Array.isArray(catalog.roleActions)
const canAssign = catalog?.canAssignRoles === true
const sel = roles?.find(r => r.roleId === editRole)
const editCurrent = sel?.actions?.[editAction]
const canEditAction = canManage && !sel?.disabled && projectActions.some(a => a.action === editAction && a.configurable)
useEffect(() => {
setEditDef(!!editCurrent?.[0]); setEditCap(!!editCurrent?.[1])
}, [editRole, editAction, roles])
useEffect(() => { refresh() }, [])
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
@@ -89,19 +63,22 @@ export function RdmRolesPanel() {
// rolesListPlaceholder 注释)。
const placeholder = rolesListPlaceholder(roles, err)
const sel = roles?.find(r => r.roleId === editRole)
const editCurrent = sel?.actions?.[editAction]
return (
<div>
<div className="ad-create">
<div className="ad-row">
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
onChange={e => setNewName(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !canManage || !newName.trim()}
<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">新角色尚无业务权限,配置后才会生效。项目权限可在角色上限内调整。</p>
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
</div>
<table className="ad-table">
@@ -109,13 +86,13 @@ export function RdmRolesPanel() {
<tbody>
{(roles ?? []).map(r => (
<tr key={r.roleId}>
<td title={r.roleId}>{r.name}</td>
<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 || !canManage}
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号的业务权限停止生效)`,
<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>
@@ -132,32 +109,33 @@ export function RdmRolesPanel() {
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<select className="lg-input" value={editAction}
onChange={e => setEditAction(e.target.value)}>
{projectActions.map(a => <option key={a.action} value={a.action}>{a.label}</option>)}
<optgroup label="系统权限(此处仅供查看)">
{systemActions.map(a => <option key={a.action} value={a.action} disabled>{a.label}</option>)}
</optgroup>
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} disabled={busy || !canEditAction}
<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} disabled={busy || !canEditAction}
<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 || !canEditAction}
onClick={() => run(`已配置 ${sel?.name || '角色'} · ${actionLabel(editAction)}`,
<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">当前值:默认{editCurrent[0] ? '允许' : '禁止'},上限{editCurrent[1] ? '允许' : '禁止'}</p>}
{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" placeholder="成员账号" value={assignOwner}
<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 || !canAssign || !assignOwner.trim() || !editRole || sel?.disabled}
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${sel?.name || '所选角色'}`,
<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>
@@ -167,6 +145,332 @@ export function RdmRolesPanel() {
)
}
export { RdmMembersSection } from './RdmMembersSection'
export { RdmDutiesSection } from './RdmDutiesSection'
export { RdmPermMatrixSection } from './RdmPermMatrixSection'
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
interface MemberRow {
owner: string; role: string; addedBy: string; addedAt: string
profRole?: { roleId: string; name: string } | null
}
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 [rows, setRows] = useState<MemberRow[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [picked, setPicked] = useState<string[]>([])
const [role, setRole] = useState<'member' | 'owner'>('member')
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
const rowsRef = useRef<MemberRow[] | null>(null)
rowsRef.current = rows
/** 人员选择器取数适配(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 }
}, [project])
const refresh = async () => {
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 run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refresh() }
catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const pickedId = picked[0] ?? ''
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-create">
<div className="ad-row">
<Picker
aria-label="选择人员"
selected={picked}
onSelect={setPicked}
fetchPage={fetchPage}
placeholder="输入账号或姓名,或浏览候选…"
/>
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</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>
</div>
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
</div>
<table className="ad-table">
<thead><tr><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-dim">{m.profRole?.name || '—'}</td>
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
<td className="ad-dim">{m.addedBy || '—'}</td>
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
<td>
<button className="ad-mini" disabled={busy || !!writeError}
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
{m.role === 'owner' ? '降为成员' : '升为负责人'}
</button>{' '}
<button className="ad-mini" disabled={busy || !!writeError}
onClick={() => run(`已移除 ${m.owner}`,
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
</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>}
</tbody>
</table>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
export { RdmDutiesSection } from './duties/RdmDutiesSection'
// ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖;
// 与实际判定同源——UI 不重算,全部读服务端解释) ----
interface MatrixAction {
action: string; label?: string; scope?: string
allowed: boolean; source: string; reason?: string
}
interface MatrixOut {
projectId: string; member: string; role: string
profRole: { roleId: string; name: string } | null
actions: MatrixAction[]
}
interface MatrixRowOut {
member: string; role: string
profRole: { roleId: string; name: string } | null
actions: MatrixAction[]
}
interface MatrixBatchOut { projectId: string; rows: MatrixRowOut[] }
function actionTitle(a: MatrixAction) { return a.label || a.action }
/** 动作行:中文标签为主,内部标识进 title 技术详情(方案 §5)。 */
function ActionDetailTable({ actions, onOverride, canManage }: {
actions: MatrixAction[]
onOverride?: (action: string, mode: 'allow' | 'deny' | 'default') => void
canManage?: boolean
}) {
const project = actions.filter(a => a.scope !== 'system')
const system = actions.filter(a => a.scope === 'system')
const overrideOf = (a: MatrixAction): 'allow' | 'deny' | 'default' =>
a.source.includes('override') || a.source === 'project' ? (a.allowed ? 'allow' : 'deny') : 'default'
return (
<>
<table className="ad-table">
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th>{canManage ? <th>项目覆盖</th> : null}</tr></thead>
<tbody>
{project.map(a => (
<tr key={a.action}>
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
<td className="ad-dim">{describeSource(a.source)}</td>
<td className="ad-dim">{a.reason || '—'}</td>
{canManage ? (
<td>
<select className="lg-input" value={overrideOf(a)} style={{ minWidth: 120 }}
onChange={e => onOverride?.(a.action, e.target.value as 'allow' | 'deny' | 'default')}>
<option value="default">沿用角色默认</option>
<option value="allow">项目内允许</option>
<option value="deny">项目内禁止</option>
</select>
</td>
) : null}
</tr>
))}
</tbody>
</table>
{system.length > 0 && (
<Collapsible trigger={`系统范围动作(${system.length} 项,不在项目内配置)`}>
<table className="ad-table">
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因</th></tr></thead>
<tbody>
{system.map(a => (
<tr key={a.action}>
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
<td className="ad-dim">{describeSource(a.source)}</td>
<td className="ad-dim">{a.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</Collapsible>
)}
</>
)
}
export function RdmPermMatrixSection({ 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 [view, setView] = useState<'member' | 'matrix'>('member')
const [member, setMember] = useState('')
const [members, setMembers] = useState<MemberRow[] | null>(null)
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
const [batch, setBatch] = useState<MatrixBatchOut | null>(null)
const [expand, setExpand] = useState('')
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const loadMembers = () =>
cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
.then(out => {
setMembers(out.members ?? [])
if (me?.owner) setMember(prev => prev || me.owner)
}).catch(e => setErr(String(e)))
useEffect(() => { loadMembers() }, [project, me?.owner]) // eslint-disable-line react-hooks/exhaustive-deps
const query = async (m: string) => {
if (!m) return
setErr(''); setBusy(true); setMatrix(null)
try {
setMatrix(await cmd<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const queryBatch = async () => {
setErr(''); setBusy(true); setBatch(null)
try {
setBatch(await cmd<MatrixBatchOut>('rdm.perm.matrix', { projectId }))
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
useEffect(() => { if (view === 'matrix') queryBatch() }, [view]) // eslint-disable-line react-hooks/exhaustive-deps
/** 三态覆盖写回:default=实际撤销(perm.unset);allow/deny=perm.set。 */
const override = async (m: string, action: string, mode: 'allow' | 'deny' | 'default') => {
if (writeError) { setErr(writeError); return }
setErr(''); setNotice(''); setBusy(true)
try {
if (mode === 'default') {
await cmd('rdm.perm.unset', { projectId, member: m, action })
setNotice(`已恢复默认(撤销 ${action} 的项目覆盖)`)
} else {
await cmd('rdm.perm.set', { projectId, member: m, action, allow: mode === 'allow' })
setNotice(mode === 'allow' ? `已在项目内允许 ${action}` : `已在项目内禁止 ${action}`)
}
await query(m)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const canManage = !writeError && !!matrix && matrix.role !== undefined
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-create">
<div className="ad-row">
<select className="lg-input" value={view} onChange={e => setView(e.target.value as 'member' | 'matrix')}>
<option value="member">按成员查看(默认自省)</option>
<option value="matrix">权限矩阵(成员 × 动作,须管理权)</option>
</select>
{view === 'member' && (
<>
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
<option value="">选择成员…</option>
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
</select>
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看</Btn>
</>
)}
</div>
<p className="ad-hint">普通成员可查看自己的有效权限;查看他人与矩阵须项目管理权。解释与实际判定同源(服务端,非前端推算);系统范围动作折叠为技术详情。</p>
</div>
{view === 'member' && matrix && (
<>
<div className="ad-row">
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目身份 <b>{matrix.role === 'owner' ? '项目负责人' : matrix.role === 'member' ? '项目成员' : matrix.role}</b>
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b></>}
</span>
</div>
<ActionDetailTable actions={matrix.actions ?? []}
canManage={canManage}
onOverride={(action, mode) => override(matrix.member, action, mode)} />
</>
)}
{view === 'member' && !matrix && !err && members !== null && <p className="ad-hint">选择成员后展示各动作的有效权限、来源与第一拒绝原因。</p>}
{view === 'matrix' && batch && (
<table className="ad-table" style={{ overflowX: 'auto', display: 'block' }}>
<thead><tr>
<th>成员</th>
{(batch.rows[0]?.actions ?? []).filter(a => a.scope !== 'system').map(a => (
<th key={a.action} title={`内部标识 ${a.action}`}>{actionTitle(a)}</th>
))}
</tr></thead>
<tbody>
{batch.rows.map(row => (
<tr key={row.member}>
<td>
<button className="ad-mini" onClick={() => setExpand(expand === row.member ? '' : row.member)}>
{row.member}
</button>
<span className="ad-dim"> · {row.role === 'owner' ? '负责人' : '成员'}</span>
</td>
{row.actions.filter(a => a.scope !== 'system').map(a => (
<td key={a.action} title={`${a.label ?? a.action}:${a.allowed ? '允许' : '拒绝'}(${describeSource(a.source)}${a.reason ? ';' + a.reason : ''})`}>
{a.allowed ? <span className="ad-chip ad-chip-admin">✓</span> : <span className="ad-chip ad-chip-warn">✗</span>}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
{view === 'matrix' && batch && expand && (
<div className="settings-group" style={{ marginTop: 12 }}>
<h4>{expand} · 单成员详情与三态覆盖</h4>
{(() => {
const row = batch.rows.find(r => r.member === expand)
if (!row) return null
return <ActionDetailTable actions={row.actions} canManage={!writeError}
onOverride={(action, mode) => override(expand, action, mode)} />
})()}
</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>
)
}
+60 -156
View File
@@ -6,23 +6,13 @@ import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { SettingInput, flatten, scopedDraft, settingsMutation, scopedSchema } from './SettingsFields'
import { Feedback, readableError, useRequestFence, validVersion } from './rdmSettingsShared'
import { SettingInput, flatten, unflatten } from './SettingsModal'
import type { SettingDesc } from '../contracts'
import { docBridge } from './Workbench'
import { randomPassword } from '../passwords'
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制 + 2026-10-13 恢复 Key)----
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
interface LoginKeyRow { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }
/** Key 状态徽标:复用既有 chip 体系,不新增颜色。 */
function keyStatusChip(status: string) {
if (status === 'unused') return <span className="ad-chip ad-chip-warn">待使用</span>
if (status === 'consumed') return <span className="ad-chip">已使用</span>
if (status === 'expired') return <span className="ad-chip">已过期</span>
return <span className="ad-chip">已撤销</span>
}
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码只有一个处置——
// 管理员生成一次性登录密码,用户使用后必须强制改密。 ----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
export function UsersPanel() {
const me = useStore(s => s.me)
@@ -31,60 +21,34 @@ export function UsersPanel() {
const [notice, setNotice] = useState('')
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [initialPassword, setInitialPassword] = useState('')
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'one-time' } | null>(null)
const [busy, setBusy] = useState(false)
// 登录 Key 面板:keyFor 打开指定用户的签发/状态视图;issued 明文仅签发响应持有一次。
const [keyFor, setKeyFor] = useState<string | null>(null)
const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null)
const [keys, setKeys] = useState<LoginKeyRow[] | null>(null)
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
const refreshKeys = async (owner: string) => {
try { setKeys(await api.adminListLoginKeys(owner)) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const openKeyPanel = async (owner: string) => {
setErr(''); setNotice(''); setIssued(null); setKeyFor(owner); setKeys(null)
await refreshKeys(owner)
}
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
setBusy(true)
try {
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
setOwner(''); setName(''); setPw('')
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined)
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
setOwner(''); setName(''); setInitialPassword('')
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const issue = async () => {
if (!keyFor) return
setErr(''); setNotice(''); setBusy(true)
const issueOneTimePassword = async (u: AdminUserRow) => {
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
try {
const out = await api.adminIssueLoginKey(keyFor)
setIssued({ key: out.key, expiresAt: out.expiresAt })
await refreshKeys(keyFor)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const copyKey = async () => {
if (!issued) return
try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') }
}
const revoke = async (id: string) => {
if (!keyFor) return
setErr(''); setBusy(true)
try {
await api.adminRevokeLoginKey(keyFor, id)
await refreshKeys(keyFor)
const out = await api.adminIssueOneTimePassword(u.owner)
setGeneratedCredential({ owner: u.owner, password: out.oneTimePassword, kind: 'one-time' })
setNotice(`已为 ${u.owner} 生成一次性登录密码,原密码仍可正常登录。该用户用一次性密码登录后必须立即设置新密码(强制改密);若改用原密码登录,未使用的一次性登录密码随即作废,且不会被要求改密。`)
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
@@ -95,69 +59,40 @@ export function UsersPanel() {
<div className="ad-row">
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
</div>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。</p>
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{generatedCredential && <div className="ad-reset">
<p className="ad-hint">{generatedCredential.kind === 'initial'
? `初始密码仅显示一次,请立即安全交付给 ${generatedCredential.owner}。`
: `一次性登录密码仅显示一次,请立即安全交付给 ${generatedCredential.owner};使用后必须强制改密。`}</p>
<input className="lg-input ad-mono" readOnly value={generatedCredential.password} aria-label="一次性密码" />
<button type="button" className="ad-mini" onClick={() => setGeneratedCredential(null)}>关闭</button>
</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{keyFor ? (
<div className="ad-reset">
<div className="ad-row">
<span><b>{keyFor}</b> 的一次性登录 Key(忘记密码恢复)</span>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={issue}>{issued ? '重新签发(旧 Key 与受限会话立即失效)' : '生成 Key'}</button>
<button type="button" className="ad-mini" onClick={() => { setKeyFor(null); setIssued(null); setKeys(null) }}>关闭</button>
</div>
{issued && (
<div className="ad-row">
<input className="lg-input lg-mono" readOnly value={issued.key} aria-label="一次性登录 Key(仅显示一次)" onFocus={e => e.currentTarget.select()} />
<button type="button" className="ad-mini" onClick={copyKey}>复制</button>
</div>
)}
{issued && <p className="ad-hint">明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。</p>}
{keys !== null && keys.length > 0 && (
<table className="ad-table">
<thead><tr><th>签发时间</th><th>用途</th><th>状态</th><th>过期时间</th><th></th></tr></thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td className="ad-dim">{new Date(k.createdAt).toLocaleString()}</td>
<td>{k.purpose === 'recover' ? '恢复' : '激活'}</td>
<td>{keyStatusChip(k.status)}</td>
<td className="ad-dim">{new Date(k.expiresAt).toLocaleString()}</td>
<td>{k.status === 'unused' && <button className="ad-mini" disabled={busy} onClick={() => revoke(k.id)}>撤销</button>}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
) : (
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" onClick={() => openKeyPanel(u.owner)}>生成登录 Key</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点该用户行的「生成一次性登录密码」:生成不会使原密码失效。用户用一次性密码登录后必须设置新密码才能继续;若用户改用原密码登录,未使用的一次性登录密码随即作废,且不会被要求改密。</p>}
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
</div>
)
}
@@ -303,75 +238,47 @@ export function PluginWorkspacesPanel() {
* SystemSettingsPanel:消费 GET/PUT /api/settings/system。
* - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施);
* 非受管项属于用户/项目偏好,不在本面板出现。
* - 保存只提交变化字段与载入版本,未修改的掩码凭据不回传;显式清除发送空值。
* 保存后重新 GET 以服务端结果回填。
* - 保存后重新 GET 以服务端结果回填(保存成功 ≠ 本地草稿即真相);掩码占位符
* 原样传回,由服务端视为未修改。
* - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。
*/
export function SystemSettingsPanel({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void } = {}) {
const owner = useStore(s => s.me?.owner)
const fence = useRequestFence(owner || '')
export function SystemSettingsPanel() {
const [schema, setSchema] = useState<SettingDesc[] | null>(null)
const [draft, setDraft] = useState<Record<string, any>>({})
const [initial, setInitial] = useState<Record<string, any>>({})
const [defaults, setDefaults] = useState<Record<string, any>>({})
const [revision, setRevision] = useState<number | undefined>(undefined)
const [loaded, setLoaded] = useState(false)
const [forbidden, setForbidden] = useState(false)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const dirty = JSON.stringify(draft) !== JSON.stringify(initial)
useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false) }, [dirty, onDirtyChange])
useEffect(() => {
if (!dirty) return
const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault() }
window.addEventListener('beforeunload', beforeUnload)
return () => window.removeEventListener('beforeunload', beforeUnload)
}, [dirty])
const load = async () => {
const current = fence.begin('load')
setErr(''); setForbidden(false)
try {
const out = await api.getSystemSettings()
if (!current()) return false
const schema = scopedSchema(out.schema, 'system')
const values = scopedDraft(out.system, schema)
setSchema(schema); setDraft(values); setInitial(values); setDefaults(flatten(out.defaults))
setRevision(out.systemRevision)
setSchema(out.schema.filter(d => d.scope === 'system'))
setDraft(flatten(out.system))
setLoaded(true)
return true
} catch (e) {
if (!current()) return false
if (String(e).includes('403')) { setForbidden(true); setLoaded(true) }
else { setErr(readableError(e)); setLoaded(true); setSchema(null) }
return false
else setErr(String(e))
}
}
useEffect(() => { fence.invalidate(); setLoaded(false); setSchema(null); setDraft({}); setInitial({}); setNotice(''); setBusy(false); void load() }, [owner])
useEffect(() => { load() }, [])
const groups: Record<string, SettingDesc[]> = {}
for (const d of schema ?? []) (groups[d.group] ??= []).push(d)
const save = async () => {
if (!validVersion(revision) || !schema || busy || !dirty) return
const current = fence.begin('save')
setErr(''); setNotice(''); setBusy(true)
try {
const mutation = settingsMutation(initial, draft, schema)
await api.putSystemSettings(mutation.values, revision, mutation.unset)
if (current() && await load()) setNotice('系统配置已保存;需重启的项在服务重启后生效')
} catch (e) {
if (!current()) return
const message = String(e).includes('409') ? '系统配置已被其他管理员修改;请核对最新配置后重新调整' : `保存结果未能确认:${readableError(e)};请核对最新配置后再保存`
await api.putSystemSettings(unflatten(draft))
setNotice('已保存;按各项标注的生效时机生效(需重启的项在重启后生效)')
await load()
if (current()) setErr(message)
} finally { if (current()) setBusy(false) }
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
if (!loaded) return <EmptyState icon="settings">加载系统设置…</EmptyState>
if (forbidden) return <EmptyState icon="shield">系统设置仅管理员可配置</EmptyState>
if (!schema) return <><EmptyState icon="settings">{err || '系统设置未能读取'}</EmptyState><Btn onClick={load}>重试</Btn></>
return (
<div>
@@ -381,20 +288,17 @@ export function SystemSettingsPanel({ onDirtyChange }: { onDirtyChange?: (dirty:
{Object.entries(groups).map(([group, descs]) => (
<SettingsGroup key={group} title={group === 'models' ? '模型' : group === 'tools' ? '工具服务' : group === 'telemetry' ? '遥测' : group === 'runtime' ? '运行基础设施' : group}>
{descs.map(d => (
<SettingRow key={d.key} label={d.label} desc={`${d.description || ''} · ${draft[d.key] === undefined ? '沿用系统默认' : d.secret ? '受保护的系统凭据' : '系统配置'}`} effect={d.effect}>
<SettingInput desc={{ ...d, default: defaults[d.key] ?? d.default }} value={draft[d.key]} disabled={busy || !validVersion(revision)} onChange={v => { setNotice(''); setDraft(prev => ({ ...prev, [d.key]: v })) }} />
{d.secret || d.type === 'secret' ? <Btn disabled={busy || !validVersion(revision)} onClick={() => {
if (window.confirm(`清除「${d.label}」配置?保存后该系统凭据将撤销`)) setDraft(previous => ({ ...previous, [d.key]: '' }))
}}>清除配置</Btn> : d.type === 'string' ? <Btn disabled={busy || !validVersion(revision) || draft[d.key] === undefined} onClick={() => setDraft(previous => ({ ...previous, [d.key]: undefined }))}>沿用默认</Btn> : null}
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
<SettingInput desc={d} value={draft[d.key]} onChange={v => setDraft(prev => ({ ...prev, [d.key]: v }))} />
</SettingRow>
))}
</SettingsGroup>
))}
<div className="ad-row" style={{ marginTop: 12 }}>
<Btn variant="primary" icon="check" disabled={busy || !validVersion(revision) || !dirty} onClick={save}>保存系统配置</Btn>
<Btn variant="primary" icon="check" disabled={busy} onClick={save}>保存系统配置</Btn>
</div>
{!validVersion(revision) && <p className="ad-hint">系统配置版本尚未就绪,暂不能保存</p>}
<Feedback error={err} notice={notice} />
{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>
)
}
+253
View File
@@ -0,0 +1,253 @@
import { useEffect, 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 {
absorbServerDocs, dirtyDocs, editCell, savePayload,
type CellDuty, type DutyDocDraft, type DutyMember,
} from '../../duties/draft'
import { DutyByDocumentView, DutyByMemberView, DutyChangePreview, DutyMatrixView } from '../../duties/views'
type ViewName = 'member' | 'document' | 'matrix'
interface MatrixDoc {
docId: string
title: string
code?: string
typeKey: string
stage?: string
dutyVersion: number
activeRound?: number
legacySectionOverrides?: number
}
interface MatrixOut {
canAssign: boolean
docs: MatrixDoc[]
members: DutyMember[]
cells: { docId: string; owner: string; duty: string }[]
nextDocCursor?: string
nextMemberCursor?: string
}
interface PreviewOut {
ok: boolean
docs: {
docId: string
title?: string
replacedAuthor?: string
replacedApprover?: string
needsConfirm?: boolean
reasons?: string[]
}[]
}
interface LegacyOut {
findings: { docId: string; title: string; sectionTitle: string; kind: string }[]
}
export function RdmDutiesSection({ project }: { project: Project }) {
const projectId = project.id
const writeError = projectWorkError(project)
const [view, setView] = useState<ViewName>('member')
const [docs, setDocs] = useState<DutyDocDraft[]>([])
const [members, setMembers] = useState<DutyMember[]>([])
const [member, setMember] = useState('')
const [docId, setDocId] = useState('')
const [query, setQuery] = useState('')
const [stage, setStage] = useState('')
const [typeKey, setTypeKey] = useState('')
const [assignment, setAssignment] = useState('')
const [docCursor, setDocCursor] = useState('')
const [nextDoc, setNextDoc] = useState('')
const [canAssign, setCanAssign] = useState(false)
const [narrow, setNarrow] = useState(false)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [preview, setPreview] = useState<PreviewOut['docs']>([])
const [confirm, setConfirm] = useState(false)
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
const [legacyOpen, setLegacyOpen] = useState(false)
const seq = useRef(0)
const op = useRef(0)
const docsRef = useRef(docs)
docsRef.current = docs
const load = async (cursor: string, request = ++seq.current, replaceDirty = false) => {
const seenMembers: DutyMember[] = []
const seenCells: MatrixOut['cells'] = []
let pageDocs: MatrixDoc[] = []
let memberCursor = ''
let nextDocument = ''
let assignable = false
try {
do {
const page = await api.rdmProjectCommand<MatrixOut>(project, 'rdm.duty.matrix', dutyMatrixPayload(projectId, {
view: 'matrix', query, stage, typeKey, assignment, docCursor: cursor, memberCursor, docLimit: 10, memberLimit: 20,
}))
if (request !== seq.current) return
if (pageDocs.length === 0) pageDocs = page.docs ?? []
seenMembers.push(...(page.members ?? []))
seenCells.push(...(page.cells ?? []))
assignable = page.canAssign
nextDocument = page.nextDocCursor ?? ''
memberCursor = page.nextMemberCursor ?? ''
} while (memberCursor)
if (request !== seq.current) return
setMembers(seenMembers)
setCanAssign(assignable)
setNextDoc(nextDocument)
setDocs(current => absorbServerDocs(current, pageDocs, seenCells, replaceDirty))
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
}
}
useEffect(() => {
const media = window.matchMedia('(max-width: 720px)')
const apply = () => setNarrow(media.matches)
apply()
media.addEventListener('change', apply)
return () => media.removeEventListener('change', apply)
}, [])
useEffect(() => {
void load(docCursor)
return () => { seq.current += 1 }
}, [project, query, stage, typeKey, assignment, docCursor])
const onCell = (id: string, owner: string, duty: CellDuty) => {
setPreview([])
setConfirm(false)
setDocs(current => editCell(current, id, 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
if (stage && doc.stage !== stage) return false
if (typeKey && doc.typeKey !== typeKey) return false
return true
})
const save = async () => {
const payload = savePayload(docsRef.current, confirm)
if (payload.issues.length > 0) {
setErr(payload.issues.join(';'))
return
}
const request = ++seq.current
const ticket = ++op.current
setBusy(true); setErr(''); setNotice('')
let sent = false
try {
const looked = await api.rdmProjectCommand<PreviewOut>(project, 'rdm.duty.preview', dutyPreviewPayload(projectId, payload.docs))
if (request !== seq.current) return
setPreview(looked.docs ?? [])
if (!looked.ok) {
setErr('预览未通过,未写入')
return
}
if ((looked.docs ?? []).some(item => item.needsConfirm) && !confirm) {
setErr('在途审签将失效。确认后再保存。')
return
}
sent = true
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm).docs))
if (request !== seq.current) return
setNotice('已保存文档职责')
setPreview([])
setConfirm(false)
setDocs(current => current.map(doc => ({ ...doc, dirty: false })))
await load(docCursor, request)
} catch (error) {
if (request !== seq.current) return
if (!sent) {
setErr(String(error))
return
}
setErr(`保存结果未确认:${error}`)
setConfirm(false)
await load(docCursor, request, true)
} finally {
if (ticket === op.current) setBusy(false)
}
}
const scanLegacy = async (request = ++seq.current) => {
try {
const out = await api.rdmProjectCommand<LegacyOut>(project, 'rdm.duty.legacy.scan', dutyLegacyPayload(projectId))
if (request !== seq.current) return
setLegacy(out.findings ?? [])
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
}
}
const applyIdentical = async () => {
const request = ++seq.current
const ticket = ++op.current
setBusy(true)
try {
await api.rdmProjectCommand(project, 'rdm.duty.legacy.apply', dutyLegacyPayload(projectId))
if (request !== seq.current) return
setNotice('已归并无歧义的章节职责')
await scanLegacy(request)
await load(docCursor, request)
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
} finally {
if (ticket === op.current) setBusy(false)
}
}
const disabled = busy || !!writeError || !canAssign
const ambiguous = legacy.filter(item => item.kind !== 'identical')
return (
<div>
<p className="ad-hint">本页只分配已有文档的职责,不创建或生成文档。</p>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-row" role="tablist" aria-label="职责视图">
<button className="ad-mini" role="tab" aria-selected={view === 'member'} onClick={() => setView('member')}>按人员</button>
<button className="ad-mini" role="tab" aria-selected={view === 'document'} onClick={() => setView('document')}>按文档</button>
<button className="ad-mini" role="tab" aria-selected={view === 'matrix'} onClick={() => setView('matrix')}>矩阵</button>
</div>
<div className="ad-row">
<input aria-label="筛选文档名称或编号" value={query} placeholder="名称或编号" disabled={busy} onChange={event => { setDocCursor(''); setQuery(event.target.value) }} />
<input aria-label="筛选类型" value={typeKey} placeholder="类型" disabled={busy} onChange={event => { setDocCursor(''); setTypeKey(event.target.value) }} />
<input aria-label="筛选阶段" value={stage} placeholder="阶段" disabled={busy} onChange={event => { setDocCursor(''); setStage(event.target.value) }} />
<select aria-label="筛选当前职责" value={assignment} disabled={busy} onChange={event => { setDocCursor(''); setAssignment(event.target.value) }}>
<option value="">全部</option>
<option value="assigned">已分配</option>
<option value="unassigned">未分配</option>
</select>
</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 === '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>}
{nextDoc && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor(nextDoc)}>下一页文档</button>}
<Btn variant="primary" icon="check" disabled={disabled || dirtyDocs(docs).length === 0} onClick={() => { void save() }}>保存职责</Btn>
</div>
<DutyChangePreview impacts={preview} confirm={confirm} onConfirm={setConfirm} />
<Collapsible id="duty-legacy" open={legacyOpen} onToggle={setLegacyOpen} trigger="旧章节职责">
<button className="ad-mini" disabled={busy} onClick={() => { void scanLegacy() }}>扫描</button>
<button className="ad-mini" disabled={disabled} onClick={() => { void applyIdentical() }}>归并无歧义覆盖</button>
{legacy.length === 0 ? <p className="ad-hint">尚未扫描,或没有章节级覆盖。</p> : (
<ul>
{legacy.map(item => <li key={`${item.docId}-${item.sectionTitle}`}>{item.title} / {item.sectionTitle}:{item.kind === 'identical' ? '与文档职责一致' : '有歧义,需逐文档确认'}</li>)}
</ul>
)}
{ambiguous.length > 0 && <p className="ad-hint">有歧义的文档不会自动覆盖。请在上面的视图里逐份确认后再保存。</p>}
</Collapsible>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{docs.length === 0 && !err && <EmptyState icon="inbox">暂无文档</EmptyState>}
</div>
)
}
-101
View File
@@ -1,101 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { Icon, type PickItem } from '@ui'
import { api } from '../api'
import type { Project } from '../contracts'
import { useStore } from '../store'
export interface Person {
owner: string; name?: string; valid?: boolean; role?: string; projectRole?: string; joined?: boolean
profRole?: { roleId: string; name: string } | null
dutyEligibility?: { author: { allowed: boolean; reason?: string }; reviewer: { allowed: boolean; reason?: string }; approver: { allowed: boolean; reason?: string } }
addedBy?: string; addedAt?: string
}
export interface MemberList {
members: Person[]; next?: string; count?: number; accountDirectoryAvailable?: boolean
canManageMembers?: boolean; canAssignDuties?: boolean; canConfigurePermissions?: boolean
}
interface RoleOption { roleId: string; name: string }
export const projectRoleLabel = (role?: string) => role === 'owner' ? '项目负责人' : role === 'member' ? '项目成员' : '未加入项目'
export const personLabel = (person: Person) => person.name || person.owner
export function personItem(person: Person, adding = false): PickItem {
const disabledReason = person.valid !== true ? '账号有效状态尚未核验' : adding && person.joined ? '已加入,请在成员表中调整项目身份' : undefined
return { id: person.owner, title: personLabel(person),
subtitle: [person.owner, person.profRole?.name, !adding ? projectRoleLabel(person.role || person.projectRole) : ''].filter(Boolean).join(' · '),
badge: person.joined && adding ? '已加入' : undefined, disabled: !!disabledReason, disabledReason }
}
export const selectedPeople = (ids: string[], people: Person[]) => ids.map(id => personItem(people.find(p => p.owner === id) ?? { owner: id }))
/** A context change invalidates requests immediately, before effects run. */
export class RequestFence {
private context: string
private epoch = 0
private sequences = new Map<string, number>()
constructor(context: string) { this.context = context }
setContext(context: string) { if (context !== this.context) { this.context = context; this.invalidate() } }
invalidate() { this.epoch++; this.sequences.clear() }
capture() { const epoch = this.epoch; return () => epoch === this.epoch }
cancel(channel: string) { this.sequences.set(channel, (this.sequences.get(channel) ?? 0) + 1) }
begin(channel: string) {
const epoch = this.epoch
const seq = (this.sequences.get(channel) ?? 0) + 1
this.sequences.set(channel, seq)
return () => epoch === this.epoch && seq === this.sequences.get(channel)
}
}
export function useRequestFence(context: string) {
const ref = useRef(new RequestFence(context))
ref.current.setContext(context)
useEffect(() => () => ref.current.invalidate(), [])
return ref.current
}
export function projectContext(project: Project, owner?: string) {
return `${owner || ''}:${project.id}:${project.settings?.['rdm.projectId'] || ''}:${project.settings?.['rdm.state'] || ''}`
}
export function useRoleOptions(project: Project, purpose: 'members' | 'duties' | 'permissions', enabled: boolean) {
const owner = useStore(s => s.me?.owner)
const context = projectContext(project, owner)
const fence = useRequestFence(context)
const [roles, setRoles] = useState<RoleOption[]>([])
const [error, setError] = useState('')
useEffect(() => {
const current = fence.begin('roles')
setRoles([]); setError('')
if (!enabled) return
api.rdmProjectCommand<{ roles: RoleOption[] }>(project, 'rdm.role.options', { purpose })
.then(out => { if (current()) { if (!Array.isArray(out.roles)) throw new Error('专业角色选项接口未就绪'); setRoles(out.roles) } })
.catch(() => { if (current()) setError('专业角色筛选暂不可用,请重试') })
return () => fence.cancel('roles')
}, [context, purpose, enabled])
return { roles, error }
}
export function RoleFilter({ roles, value, onChange, disabled = false }: {
roles: RoleOption[]; value: string; onChange: (role: string) => void; disabled?: boolean
}) {
return <select aria-label="专业角色筛选" className="lg-input" value={value} disabled={disabled} onChange={e => onChange(e.target.value)}>
<option value="">全部专业角色</option>
{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
</select>
}
export function Feedback({ error, notice }: { error?: string; notice?: string }) {
return <>{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{error && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{error}</div>}</>
}
export function readableError(error: unknown): string {
const raw = String(error).replace(/^(?:Error|TypeError):\s*/, '')
if (raw.includes('unknown or disabled command')) return '该功能尚未装配到当前服务,请升级服务后重新打开'
const text = raw.replace(/^\d{3}:\s*/, '')
try {
const body = JSON.parse(text)
if (typeof body?.receipt?.reason === 'string') return readableError(body.receipt.reason.replace(/^(?:rejected|failed):\s*/i, ''))
if (typeof body.error === 'string') return readableError(body.error)
if (typeof body.message === 'string') return readableError(body.message)
if (body && typeof body === 'object') return '操作暂未完成,请重新读取状态后再试'
} catch { /* A plain service message is also valid. */ }
if (/[\u4e00-\u9fff]/.test(text)) return text
if (raw.startsWith('403:') || /denied|forbidden/i.test(raw)) return '当前账号没有权限执行此操作,请联系项目负责人核对'
if (raw.startsWith('409:') || /version.*conflict|revision.*mismatch/i.test(raw)) return '数据已经更新,请核对最新结果后重新操作'
if (/fetch|network|connection/i.test(raw)) return '网络连接中断,请核对最新状态;写入结果可能尚未确认'
return '操作暂未完成,请重新读取状态后再试'
}
export const validVersion = (version: unknown): version is number => typeof version === 'number' && Number.isSafeInteger(version) && version >= 0
+17 -3
View File
@@ -151,7 +151,21 @@ export interface ActivityCard {
sourceRunId?: ID; anchorMessageId?: ID;
kind: CardKind; title: string; status: CardStatus; tone?: string;
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
pinned?: boolean; createdAt: string; updatedAt: string;
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
}
export interface WorkCard {
result?: string; resultVersion: number; readVersion: number; completedAt?: string;
decision?: WorkDecision;
}
export interface WorkDecision {
runId?: ID;
mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'dispatching' | 'submitted';
options?: { id: string; label: string }[];
selectedIds?: string[]; freeText?: string; respondedAt?: string;
}
export interface WorkCardAction {
resultVersion: number; selectedIds?: string[]; freeText?: string;
}
export interface CardPayload { card: ActivityCard }
@@ -270,8 +284,8 @@ export interface SettingDesc {
default?: unknown; options?: string[];
effect: 'hot' | 'new-run' | 'restart';
secret?: boolean;
/** Setting ownership; an omitted scope retains legacy personal compatibility. */
scope?: 'system' | 'user' | 'project' | 'project-personal';
/** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */
scope?: 'system';
}
// ---- 插件 ----
+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')})`
}
+197
View File
@@ -0,0 +1,197 @@
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
interface DutyRoles {
author: string
reviewers: string[]
approver: string
}
export interface DutyDocDraft extends DutyRoles {
docId: string
title: string
code: string
typeKey: string
stage: string
dutyVersion: number
activeRound: number
legacySectionOverrides: number
dirty: boolean
}
export interface DutyMember {
owner: string
role: string
valid: boolean
canAuthor: boolean
canReview: boolean
canApprove: boolean
}
interface DutySaveItem {
docId: string
author: string
reviewers: string[]
approver: string
expectedDutyVersion: number
confirmInvalidation: boolean
}
export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
if (roles.author === owner) return 'author'
if (roles.approver === owner) return 'approver'
if (roles.reviewers.includes(owner)) return 'reviewer'
return 'none'
}
export function applyCell(roles: DutyRoles, owner: string, duty: CellDuty): {
roles: DutyRoles
replacedAuthor: string
replacedApprover: string
} {
const replacedAuthor = duty === 'author' && roles.author !== '' && roles.author !== owner ? roles.author : ''
const replacedApprover = duty === 'approver' && roles.approver !== '' && roles.approver !== owner ? roles.approver : ''
let author = roles.author === owner ? '' : roles.author
let approver = roles.approver === owner ? '' : roles.approver
let reviewers = roles.reviewers.filter(person => person !== owner)
switch (duty) {
case 'author':
author = owner
break
case 'approver':
approver = owner
break
case 'reviewer':
reviewers = [...reviewers, owner]
break
case 'none':
break
default: {
const exhaustive: never = duty
throw new Error(exhaustive)
}
}
return { roles: { author, reviewers, approver }, replacedAuthor, replacedApprover }
}
export function replacementNote(roles: DutyRoles, owner: string, duty: CellDuty): string {
if (duty === 'author' && roles.author !== '' && roles.author !== owner) return `将替换${roles.author}`
if (duty === 'approver' && roles.approver !== '' && roles.approver !== owner) return `将替换${roles.approver}`
return ''
}
function dutyConflicts(roles: DutyRoles): number {
const seen = new Map<string, number>()
const mark = (owner: string) => {
if (!owner) return
seen.set(owner, (seen.get(owner) ?? 0) + 1)
}
mark(roles.author)
mark(roles.approver)
for (const reviewer of roles.reviewers) mark(reviewer)
let conflicts = 0
for (const count of seen.values()) if (count > 1) conflicts += 1
return conflicts
}
export function completeness(roles: DutyRoles): { authors: number; reviewers: number; approvers: number; conflicts: number } {
return {
authors: roles.author ? 1 : 0,
reviewers: roles.reviewers.length,
approvers: roles.approver ? 1 : 0,
conflicts: dutyConflicts(roles),
}
}
export function saveIssues(roles: DutyRoles): string[] {
const issues: string[] = []
if (!roles.author) issues.push('需指定编制人')
if (roles.reviewers.length === 0) issues.push('审核至少一人')
if (!roles.approver) issues.push('需指定批准人')
if (dutyConflicts(roles) > 0) issues.push('一人只能担一个职责')
return issues
}
function rolesFromCells(docId: string, cells: { docId: string; owner: string; duty: string }[]): DutyRoles {
const roles: DutyRoles = { author: '', reviewers: [], approver: '' }
for (const cell of cells) {
if (cell.docId !== docId) continue
if (cell.duty !== 'none' && cell.duty !== 'author' && cell.duty !== 'reviewer' && cell.duty !== 'approver') continue
const next = applyCell(roles, cell.owner, cell.duty)
roles.author = next.roles.author
roles.reviewers = next.roles.reviewers
roles.approver = next.roles.approver
}
return roles
}
export function absorbServerDocs(
current: DutyDocDraft[],
docs: {
docId: string; title: string; code?: string; typeKey: string; stage?: string
dutyVersion: number; activeRound?: number; legacySectionOverrides?: number
}[],
cells: { docId: string; owner: string; duty: string }[],
replaceDirty = false,
): DutyDocDraft[] {
const byId = new Map(current.map(doc => [doc.docId, doc]))
const pageIds = new Set(docs.map(doc => doc.docId))
const next: DutyDocDraft[] = []
for (const doc of docs) {
const previous = byId.get(doc.docId)
const serverRoles = rolesFromCells(doc.docId, cells)
const keepDraft = !replaceDirty && previous?.dirty === true
next.push({
docId: doc.docId,
title: doc.title,
code: doc.code ?? doc.docId,
typeKey: doc.typeKey,
stage: doc.stage ?? '',
dutyVersion: keepDraft ? previous.dutyVersion : doc.dutyVersion,
activeRound: doc.activeRound ?? 0,
legacySectionOverrides: doc.legacySectionOverrides ?? 0,
dirty: keepDraft,
author: keepDraft ? previous.author : serverRoles.author,
reviewers: keepDraft ? previous.reviewers : serverRoles.reviewers,
approver: keepDraft ? previous.approver : serverRoles.approver,
})
}
for (const doc of current) {
if (!replaceDirty && doc.dirty && !pageIds.has(doc.docId)) next.push(doc)
}
return next
}
export function editCell(docs: DutyDocDraft[], docId: string, owner: string, duty: CellDuty): DutyDocDraft[] {
return docs.map(doc => {
if (doc.docId !== docId) return doc
const next = applyCell(doc, owner, duty)
return { ...doc, ...next.roles, dirty: true }
})
}
export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
return docs.filter(doc => doc.dirty)
}
export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): { docs: DutySaveItem[]; issues: string[] } {
const dirty = dirtyDocs(docs)
const issues: string[] = []
if (dirty.length === 0) issues.push('没有未保存的职责')
if (dirty.length > 20) issues.push('一次保存最多 20 份文档')
for (const doc of dirty) {
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,
})),
issues,
}
}
+203
View File
@@ -0,0 +1,203 @@
import type { CellDuty, DutyDocDraft, DutyMember } from './draft'
import { cellDuty, completeness, replacementNote, saveIssues } from './draft'
const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [
{ value: 'none', label: '无职责' },
{ value: 'author', label: '编制' },
{ value: 'reviewer', label: '审核' },
{ value: 'approver', label: '批准' },
]
function DutySelect({
doc, owner, disabled, onChange,
}: {
doc: DutyDocDraft
owner: string
disabled: boolean
onChange: (duty: CellDuty) => void
}) {
const current = cellDuty(doc, owner)
return (
<label className="duty-cell">
<select
aria-label={`${owner} 在 ${doc.title} 的职责`}
value={current}
disabled={disabled}
onChange={event => {
const value = event.target.value
if (value === 'none' || value === 'author' || value === 'reviewer' || value === 'approver') onChange(value)
}}
>
{DUTY_OPTIONS.map(option => {
const hint = replacementNote(doc, owner, option.value)
return <option key={option.value} value={option.value}>{hint ? `${option.label}(${hint})` : option.label}</option>
})}
</select>
</label>
)
}
export function DutyByMemberView({
member, members, docs, disabled, onMember, onCell,
}: {
member: string
members: DutyMember[]
docs: DutyDocDraft[]
disabled: boolean
onMember: (owner: string) => void
onCell: (docId: string, owner: string, duty: CellDuty) => void
}) {
return (
<div>
<div className="setting-row">
<label htmlFor="duty-member">成员</label>
<select id="duty-member" aria-label="选择成员" value={member} onChange={event => onMember(event.target.value)}>
<option value="">选择成员</option>
{members.map(person => <option key={person.owner} value={person.owner}>{person.owner}</option>)}
</select>
</div>
{!member ? <p className="ad-hint">先选择成员,再为每份文档指定无职责、编制、审核或批准。</p> : (
<table className="ad-table">
<thead><tr><th>文档</th><th>类型 / 阶段</th><th>职责</th><th>完整性</th></tr></thead>
<tbody>
{docs.map(doc => {
const score = completeness(doc)
return (
<tr key={doc.docId}>
<td>{doc.title}<span className="ad-dim ad-mono"> · {doc.code}</span></td>
<td className="ad-dim">{doc.typeKey}{doc.stage ? ` / ${doc.stage}` : ''}</td>
<td><DutySelect doc={doc} owner={member} disabled={disabled} onChange={duty => onCell(doc.docId, member, duty)} /></td>
<td className="ad-dim">编 {score.authors}/1 · 审 {score.reviewers} · 批 {score.approvers}/1</td>
</tr>
)
})}
{docs.length === 0 && <tr><td colSpan={4} className="ad-dim">没有符合筛选的文档</td></tr>}
</tbody>
</table>
)}
</div>
)
}
export function DutyByDocumentView({
docId, docs, members, disabled, onDoc, onCell,
}: {
docId: string
docs: DutyDocDraft[]
members: DutyMember[]
disabled: boolean
onDoc: (id: string) => void
onCell: (docId: string, owner: string, duty: CellDuty) => void
}) {
const doc = docs.find(item => item.docId === docId)
const score = doc ? completeness(doc) : null
const issues = doc ? saveIssues(doc) : []
return (
<div>
<div className="setting-row">
<label htmlFor="duty-doc">文档</label>
<select id="duty-doc" aria-label="选择文档" value={docId} onChange={event => onDoc(event.target.value)}>
<option value="">选择整份文档</option>
{docs.map(item => <option key={item.docId} value={item.docId}>{item.title}</option>)}
</select>
</div>
{score && <p className="ad-hint">编制 {score.authors}/1、审核 {score.reviewers} 人、批准 {score.approvers}/1、冲突 {score.conflicts}{issues.length > 0 ? `。${issues.join(',')}` : ''}</p>}
{!doc ? <p className="ad-hint">先选择整份文档。这里不能选择章节,也没有生成入口。</p> : (
<table className="ad-table">
<thead><tr><th>成员</th><th>项目身份</th><th>职责</th></tr></thead>
<tbody>
{members.map(person => (
<tr key={person.owner}>
<td className="ad-mono">{person.owner}{person.valid ? '' : '(无效)'}</td>
<td className="ad-dim">{person.role === 'owner' ? '项目负责人' : '项目成员'}</td>
<td><DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} /></td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
export function DutyMatrixView({
docs, members, disabled, narrow, onCell,
}: {
docs: DutyDocDraft[]
members: DutyMember[]
disabled: boolean
narrow: boolean
onCell: (docId: string, owner: string, duty: CellDuty) => void
}) {
if (narrow) {
return <p className="ad-hint">当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。</p>
}
return (
<div className="duty-matrix-wrap">
<table className="ad-table duty-matrix">
<thead>
<tr>
<th className="duty-sticky">成员</th>
{docs.map(doc => {
const score = completeness(doc)
const ready = score.authors === 1 && score.reviewers >= 1 && score.approvers === 1 && score.conflicts === 0
return <th key={doc.docId}>{doc.title}<span className={ready ? 'ad-chip' : 'ad-chip ad-chip-warn'}>{ready ? '三权齐' : '未齐'}</span></th>
})}
</tr>
</thead>
<tbody>
{members.map(person => (
<tr key={person.owner}>
<td className="duty-sticky ad-mono">{person.owner}</td>
{docs.map(doc => (
<td key={doc.docId}>
<DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export function DutyChangePreview({
impacts, confirm, onConfirm,
}: {
impacts: {
docId: string
title?: string
replacedAuthor?: string
replacedApprover?: string
needsConfirm?: boolean
reasons?: string[]
}[]
confirm: boolean
onConfirm: (value: boolean) => void
}) {
if (impacts.length === 0) return null
const needs = impacts.some(item => item.needsConfirm)
return (
<div className="settings-group">
<h4>保存前预览</h4>
<ul>
{impacts.map(item => (
<li key={item.docId}>
{item.title || item.docId}
{item.replacedAuthor ? `:编制将替换${item.replacedAuthor}` : ''}
{item.replacedApprover ? `:批准将替换${item.replacedApprover}` : ''}
{item.needsConfirm ? ';在途审签将失效,历史名单保持不变' : ''}
{item.reasons && item.reasons.length > 0 ? `;${item.reasons.join(',')}` : ''}
</li>
))}
</ul>
{needs && (
<label>
<input type="checkbox" checked={confirm} onChange={event => onConfirm(event.target.checked)} />
确认使受影响的在途审签失效
</label>
)}
</div>
)
}
+86 -5
View File
@@ -3,8 +3,9 @@
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定。
// 参数增量(编写中)一到即算工具,解说不用等工具真正开始执行才离开回复区。
import type { Message, Part } from './contracts'
import type { Message, Part, StreamEvent } from './contracts'
import type { LiveState } from './store'
import { splitTimeline, type ReplayPart } from './messageparts'
export type ToolLiveState = 'composing' | 'running' | 'done'
@@ -12,6 +13,7 @@ export type ToolLiveState = 'composing' | 'running' | 'done'
export type LiveItem =
| { kind: 'thinking'; text: string; startedAt: number; endedAt: number }
| { kind: 'text'; index: number; text: string }
| { kind: 'agent'; text: string; agentRun?: string; agentPhase?: 'spawn' | 'progress' | 'result' }
| {
kind: 'tool'
callId?: string
@@ -118,13 +120,13 @@ export function applyToolCall(items: LiveItem[], name: string, callId?: string,
}
// applyToolOutput 把执行中的输出追加到对应工具,只保留尾部,避免长日志撑住内存。
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string): LiveItem[] {
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string, maxChars = OUTPUT_TAIL): LiveItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
if (!isTool(it) || it.state === 'done') continue
if (callId && it.callId && it.callId !== callId) continue
let output = it.output + text
if (output.length > OUTPUT_TAIL) output = output.slice(output.length - OUTPUT_TAIL)
if (output.length > maxChars) output = output.slice(output.length - maxChars)
const out = items.slice()
out[i] = { ...it, output }
return out
@@ -149,6 +151,27 @@ export function applyCheck(items: LiveItem[], passed: boolean, reason: string):
return [...items, { kind: 'check', passed, reason }]
}
/** A child streams cumulative previews. Replace that child's latest preview in
* place, while preserving real phase changes and the order of other children. */
function applyAgentProgress(items: LiveItem[], agentRun: string, note: string): LiveItem[] {
const text = note.trim()
if (!text) return items
for (let i = items.length - 1; i >= 0; i--) {
const previous = items[i]
if (previous.kind !== 'agent' || previous.agentRun !== agentRun) continue
if (previous.agentPhase !== 'progress') break
const value = previous.text.slice('协作进展:'.length)
if (value === text || value.startsWith(text)) return items
if (text.startsWith(value) && !/^调用\s/.test(text)) {
const next = items.slice()
next[i] = { ...previous, text: `协作进展:${text}` }
return next
}
break
}
return [...items, { kind: 'agent', text: `协作进展:${text}`, agentRun, agentPhase: 'progress' }]
}
// applyFinalizing 标记收尾开始:此前的正文都是过程,此后的正文才是给用户的回复。
export function applyFinalizing(items: LiveItem[]): LiveItem[] {
if (items.some(it => it.kind === 'final')) return items
@@ -193,7 +216,9 @@ export function liveToMessage(live: LiveState): Message {
parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } })
continue
}
if (it.kind === 'thinking') {
if (it.kind === 'agent') {
parts.push({ type: 'text', text: it.text, process: true, replaySource: 'agent' } as ReplayPart)
} else if (it.kind === 'thinking') {
const durationMs = it.endedAt - it.startedAt
if (it.text) parts.push({
type: 'thinking', text: it.text, process: true,
@@ -221,7 +246,63 @@ export function liveToMessage(live: LiveState): Message {
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
}
}
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
const createdAt = live.startedAt > 0 && Number.isFinite(live.startedAt) ? new Date(live.startedAt).toISOString() : ''
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt }
}
/** Rebuild one run's process from its durable ordered events, without the live preview's output-tail limit. */
export function runEventsToTimeline(events: StreamEvent[], parentRunId = events[0]?.runId, completeRecord = false): ReplayPart[] {
let items: LiveItem[] = []
let finalizing = false
for (const event of [...events].sort((a, b) => a.seq - b.seq)) {
const p = event.payload as Record<string, unknown> | undefined
const now = Date.parse(event.ts) || 0
if (event.runId !== parentRunId && event.type !== 'agent.progress' && event.type !== 'agent.result') continue
switch (event.type) {
case 'agent.spawn':
if (typeof p?.name === 'string') items.push({ kind: 'agent', text: `协作代理「${p.name}」已开始`,
agentRun: String(p.agentRun ?? event.runId ?? ''), agentPhase: 'spawn' })
break
case 'agent.progress':
if (typeof p?.note === 'string') items = applyAgentProgress(items, String(p.agentRun ?? event.runId ?? ''), p.note)
break
case 'agent.result':
items.push({ kind: 'agent', text: `协作结束:${typeof p?.summary === 'string' && p.summary.trim() ? p.summary : String(p?.status ?? '已结束')}`,
agentRun: String(p?.agentRun ?? event.runId ?? ''), agentPhase: 'result' })
break
case 'thinking.delta':
if (typeof p?.text === 'string') items = applyThinkingDelta(items, p.text, now)
break
case 'message.delta':
if ((!finalizing || completeRecord) && typeof p?.text === 'string') items = applyTextDelta(items, typeof p.index === 'number' ? p.index : 0, p.text)
break
case 'tool.call.delta':
if (typeof p?.delta === 'string') items = applyToolCallDelta(items, typeof p.index === 'number' ? p.index : 0,
p.delta, typeof p.callId === 'string' ? p.callId : undefined, typeof p.name === 'string' ? p.name : undefined, now)
break
case 'tool.call':
items = applyToolCall(items, typeof p?.name === 'string' ? p.name : '工具',
typeof p?.callId === 'string' ? p.callId : undefined,
typeof p?.arguments === 'string' ? p.arguments : undefined, now)
break
case 'tool.output':
if (typeof p?.text === 'string') items = applyToolOutput(items,
typeof p.callId === 'string' ? p.callId : undefined, p.text, Number.POSITIVE_INFINITY)
break
case 'tool.result':
items = applyToolResult(items, typeof p?.callId === 'string' ? p.callId : undefined,
typeof p?.status === 'string' ? p.status : 'ok', typeof p?.summary === 'string' ? p.summary : undefined, now)
break
case 'run.phase':
if (p?.phase === 'finalizing') { finalizing = true; items = applyFinalizing(items) }
break
case 'run.check':
items = applyCheck(items, !!p?.passed, typeof p?.reason === 'string' ? p.reason : '')
break
}
}
const live: LiveState = { runId: events[0]?.runId ?? '', phase: 'done', items, agentNotes: {}, startedAt: 0 }
return completeRecord ? liveToMessage(live).parts : splitTimeline(liveToMessage(live).parts).timeline
}
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
+75 -10
View File
@@ -1,5 +1,4 @@
// 消息切分:技术记录留在过程时间线,正文、错误与文件入口始终直接可读。
// 两区各自保留落库顺序,后续工具或终轮检查不再把解说藏入折叠区。
// 消息切分:每轮过程按落库顺序留在可回看的时间线,终轮回复直接可读。
import type { Part } from './contracts'
export interface TimelineSplit {
@@ -7,20 +6,86 @@ export interface TimelineSplit {
final: Part[]
}
/** 最后一个 process 部件之前的技术记录留在时间线,其余为可见内容。
* 保留旧 promoteLastText 参数以兼容调用方;解说现在始终可见,不再依赖终态提升。 */
export function splitTimeline(parts: Part[], _promoteLastText = false): TimelineSplit {
/** Client-only provenance. It is never written to the Core message contract. */
export interface ReplayPart extends Part { replaySource?: 'agent' }
/** 最后一个 process 部件之前的记录属于该轮过程;错误与文件入口始终可见。
* 终态没有回复时,只把最后一段过程解说提升为可见回复。 */
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
let last = -1
for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i
if (last < 0) return { timeline: [], final: parts }
// text 是公开解说;thinking、工具参数与结果、终轮检查不提升为正文。
const visible = (part: Part) => part.type === 'text' || part.type === 'error' || part.type === 'artifact_ref'
const before = parts.slice(0, last + 1)
const timeline = before.filter(part => !visible(part))
const final = [...before.filter(visible), ...parts.slice(last + 1)]
const timeline: Part[] = []
const final: Part[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (i > last || part.type === 'error' || part.type === 'artifact_ref') final.push(part)
else timeline.push(part)
}
if (promoteLastText && !final.some(part => part.type === 'text' || part.type === 'error')) {
let idx = -1
for (let i = timeline.length - 1; i >= 0; i--) {
if (timeline[i].type === 'text') { idx = i; break }
}
if (idx >= 0) final.unshift(...timeline.splice(idx, 1))
}
return { timeline, final }
}
const normalized = (text?: string) => (text ?? '').replace(/\s+/g, ' ').trim()
function sameProcessPart(saved: Part, replayed: Part): boolean {
if (saved.type !== replayed.type) return false
switch (saved.type) {
case 'tool_call': return !!saved.toolCall?.id && saved.toolCall.id === replayed.toolCall?.id
case 'tool_result': return !!saved.toolResult?.callId && saved.toolResult.callId === replayed.toolResult?.callId
case 'check': return saved.check?.passed === replayed.check?.passed && saved.check?.reason === replayed.check?.reason
case 'thinking':
case 'text': return normalized(saved.text) === normalized(replayed.text)
default: return false
}
}
/** Keep the settled message as the content authority; add only event-only records
* and full tool output. Event deltas can be missing, while terminal parts can
* contain retry notes or parsed inline thinking that the event stream cannot reconstruct. */
export function mergeProcessTimeline(saved: Part[], replayed: ReplayPart[], final: Part[]): Part[] {
if (replayed.length === 0) return saved
const finalTexts = final.filter(part => part.type === 'text').map(part => normalized(part.text)).filter(Boolean)
const merged: Part[] = []
let cursor = 0
let pending: Part[] = []
for (const part of replayed) {
const text = normalized(part.text)
const agentNote = part.replaySource === 'agent'
if (part.type === 'text' && !agentNote && ((text && finalTexts.some(value => value.includes(text) || text.includes(value)))
|| /<\/?think\b/i.test(part.text ?? ''))) continue
let match = -1
for (let i = cursor; i < saved.length; i++) {
if (sameProcessPart(saved[i], part)) { match = i; break }
}
if (match < 0) {
// A missing delta batch can leave only a prefix in events. The complete
// terminal part is authoritative; never render that prefix beside it.
if (!agentNote && (part.type === 'text' || part.type === 'thinking') && text && saved.some(item => {
if (item.type !== part.type) return false
const value = normalized(item.text)
return value && (value.includes(text) || text.includes(value))
})) continue
pending.push(part)
continue
}
merged.push(...saved.slice(cursor, match), ...pending)
pending = []
const output = (part as Part & { toolOutput?: string }).toolOutput
merged.push(output && saved[match].type === 'tool_call'
? { ...saved[match], toolOutput: output } as Part : saved[match])
cursor = match + 1
}
merged.push(...saved.slice(cursor), ...pending)
return merged
}
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
export function dedupeDeliverables(parts: Part[]): { artifact: NonNullable<Part['artifact']>; updates: number }[] {
const order: string[] = []
+6 -1
View File
@@ -1,17 +1,22 @@
// 输入排队(纯数据):回合进行中先入本地队列,终态再逐条发送。
export interface WorkReference { cardId: string; resultVersion: number; title: string }
export interface QueuedMessage {
id: string
text: string
attachmentIds: string[]
attachmentNames: string[]
taskRef?: WorkReference
deliveryError?: string
}
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[]): QueuedMessage {
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference): QueuedMessage {
return {
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
text,
attachmentIds,
attachmentNames,
...(taskRef ? { taskRef } : {}),
}
}
-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
}
+47 -38
View File
@@ -26,7 +26,7 @@ export function rdmProjectReadError(project: Project | undefined): string | null
const PROJECT_READ_COMMANDS = new Set([
'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
'rdm.duty.tree', 'rdm.role.options', 'rdm.account.list', 'rdm.project.member.impact',
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan',
])
export function rdmProjectCommandError(project: Project, command: string): string | null {
@@ -47,26 +47,20 @@ export const RDM_MEMBER_COMMANDS = [
'rdm.project.member.list',
'rdm.project.member.add',
'rdm.project.member.remove',
'rdm.project.member.set-role', 'rdm.project.member.impact', 'rdm.account.list', 'rdm.role.options',
] as const
/** 项目设置「项目权限」页消费的命令面(矩阵解释 + 成员清单)。 */
export const RDM_PERM_MATRIX_COMMANDS = [
'rdm.perm.check',
'rdm.project.member.list',
'rdm.perm.matrix', 'rdm.perm.set', 'rdm.perm.unset', 'rdm.role.options',
] as const
/** 项目设置「文档职责 · 整文审签」页消费的命令面。 */
export const RDM_DOC_REVIEW_COMMANDS = [
/** 项目设置「文档职责」页消费的命令面。审签命令留在文档工作区,不作为本页门控。 */
export const RDM_DUTY_COMMANDS = [
'rdm.duty.matrix',
'rdm.duty.preview',
'rdm.review.assign.doc',
'rdm.review.doc.submit',
'rdm.review.doc.approve',
'rdm.review.doc.reject',
'rdm.review.doc.final',
'rdm.review.doc.status',
'rdm.duty.catalog', 'rdm.duty.tree', 'rdm.duty.get', 'rdm.project.member.list',
'rdm.role.options', 'rdm.review.assign', 'rdm.review.assign.doc.batch',
'rdm.review.assign.doc.batch',
] as const
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
@@ -84,15 +78,15 @@ export const rdmRolesReady = (cmds: CommandListItem[] | null | undefined) =>
export const rdmMembersReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_MEMBER_COMMANDS)
/** 文档职责/整文审签页是否就绪。 */
export const rdmDocReviewReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_DOC_REVIEW_COMMANDS)
/** 文档职责页是否就绪。命令未齐则 fail-closed。 */
export const rdmDutyReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_DUTY_COMMANDS)
/** 项目权限矩阵页是否就绪。 */
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_PERM_MATRIX_COMMANDS)
/** 旧动作目录用于只读占位;可编辑目录及当前权限由服务端返回。 */
/** RDM 动作清单(12 项,与 RDM perms.AllActions 对齐;角色动作编辑器选项)。 */
export const ROLE_ACTIONS = [
'project.create', 'project.archive', 'member.manage', 'duty.assign',
'stage.advance', 'doc.read', 'doc.write', 'doc.publish', 'review.participate',
@@ -114,8 +108,37 @@ export const memberSetRolePayload = (projectId: string, member: string, role: 'o
export const assignDocDutiesPayload = (
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
expectedDutyVersion: number, confirmInvalidation = false,
) => ({ projectId, docId, author, reviewers, approver, expectedDutyVersion, confirmInvalidation })
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean },
) => ({
projectId, docId, author, reviewers, approver,
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
})
export const dutyMatrixPayload = (
projectId: string,
query: {
view: 'member' | 'document' | 'matrix'
memberOwner?: string
docId?: string
query?: string
stage?: string
typeKey?: string
assignment?: string
memberQuery?: string
memberCursor?: string
docCursor?: string
memberLimit?: number
docLimit?: number
},
) => ({ projectId, ...query })
export const dutyPreviewPayload = (
projectId: string,
docs: { docId: string; author: string; reviewers: string[]; approver: string; expectedDutyVersion: number; confirmInvalidation: boolean }[],
) => ({ projectId, docs })
export const dutyLegacyPayload = (projectId: string) => ({ projectId })
export const docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId })
export const docApprovePayload = (projectId: string, docId: string, note?: string) =>
@@ -129,7 +152,7 @@ export const docStatusPayload = (projectId: string, docId: string) => ({ project
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
/** 内部来源码只进入按需技术详情。 */
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
export function describeSource(source: string): string {
switch (source) {
case 'admin': return '系统管理员'
@@ -137,26 +160,10 @@ export function describeSource(source: string): string {
case 'override': return '项目内覆盖'
case 'relation-default': return '项目关系默认'
case 'non-member': return '非项目成员'
case 'scope': return '配置范围不符'
case 'error': return '暂时无法核验'
case 'cap': case 'cap-denied': return '管理员权限上限'
case 'project': return '项目内配置'
case 'default': return '角色默认'
default: return '来源未提供'
default: return source
}
}
const ACTION_LABELS: Record<string, string> = {
'project.create': '创建项目', 'project.archive': '归档项目', 'member.manage': '管理项目成员',
'duty.assign': '分配文档职责', 'stage.advance': '推进项目阶段', 'doc.read': '读取文档',
'doc.write': '编制与编辑文档', 'doc.publish': '发布受控文档', 'review.participate': '参与文档审签',
'trace.edit': '编辑追溯关系', 'knowledge.publish': '发布项目知识', 'project.settings': '配置项目设置',
}
export function actionLabel(action: string, label?: string): string {
return ACTION_LABELS[action] || (label && label !== action ? label : '其他项目操作')
}
/** 角色清单表格占位行文案:加载中仅在无错误时成立——rdm.role.list 被拒后
* 停留「加载中…」是 2026-10-13 8788 锁死事故的误导性表象(表格永远在转圈)。 */
export function rolesListPlaceholder(roles: unknown[] | null, err: string): string {
@@ -164,10 +171,12 @@ export function rolesListPlaceholder(roles: unknown[] | null, err: string): stri
return err ? '角色清单未加载(见下方提示)' : '加载中…'
}
/** 角色管理须有独立系统权限;不引导使用已退出此链路的台账管理员移交。 */
/** rdm.role.list 错误文案映射:域管理员权限拒绝给人话 + 恢复通道;其余错误
* 原样保留(服务端 reason 变化不至于空白)。台账单管理员制见 RDM G1-4/U1。 */
export function describeRoleListError(raw: string): string {
if (raw.includes('不是系统管理员')) {
return '当前账号无权管理系统专业角色,请联系具有角色管理权限的系统管理员'
return '当前账号不是 RDM 域管理员(台账单管理员制),无法读取角色清单;'
+ '请联系现任管理员经 rdm.perm.transfer 移交,或由部署侧受信通道恢复'
}
return raw
}
+17
View File
@@ -0,0 +1,17 @@
import { api } from './api'
import type { StreamEvent } from './contracts'
/** Read the durable record in bounded pages; callers own cancellation and projection. */
export async function loadRunEvents(conversationId: string, runId: string, signal: AbortSignal): Promise<StreamEvent[]> {
const events: StreamEvent[] = []
let after = 0
while (true) {
const page = await api.runEventsPage(conversationId, runId, after, signal)
const rows = page.events ?? []
events.push(...rows)
if (rows.length < 500) return events
const next = rows[rows.length - 1].seq
if (next <= after) throw new Error('process event cursor did not advance')
after = next
}
}
+1 -21
View File
@@ -1,24 +1,4 @@
// 中栏 → 右栏单向滚动同步,以及互定位后的 1.2s 闪烁。
// 比例按可滚动范围计算;任一侧没有溢出时不同步,避免把静止栏拽动。
export interface ScrollBox {
scrollTop: number
scrollHeight: number
clientHeight: number
}
export function scrollRatio(box: ScrollBox): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
return box.scrollTop / range
}
export function scrollTopForRatio(box: Pick<ScrollBox, 'scrollHeight' | 'clientHeight'>, ratio: number): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
const clamped = Math.min(1, Math.max(0, ratio))
return clamped * range
}
// Explicit message/card location feedback. The middle and work panes scroll independently.
/** 给已定位的节点加上 mk-flash,1.2s 后摘掉。 */
export function flashElement(el: Element | null) {
+90 -36
View File
@@ -6,13 +6,13 @@ import { auth } from './auth'
import { projectActionError, projectWorkError } from './projectmeta'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox'
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 { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement'
import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement'
type FontSizeChoice = 13 | 14 | 16 | 18
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
@@ -34,7 +34,8 @@ import type {
StreamEvent,
SurfaceMeta,
SurfaceSnapshot,
TodoList
TodoList,
WorkCardAction
} from './contracts'
export interface ContextObs {
@@ -113,6 +114,8 @@ interface AppState {
projectCards: ActivityCard[]
/** 同一操作的双入口反馈;未知结果必须先对账,不重新发送副作用。 */
cardActionStates: Record<string, 'submitting' | 'unknown'>
workRecord: { cardId: string; runId: string; conversationId: string; title: string } | null
workReference: WorkReference | null
inboxScope: 'conv' | 'project'
/** runtime.mock:只控制演示指令提示,不改变对话能力。 */
runtimeMock: boolean
@@ -121,6 +124,7 @@ interface AppState {
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
rdmAuditAvailable: boolean
rdmCockpitAvailable: boolean
/** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */
rdmExportAvailable: boolean
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
@@ -193,9 +197,10 @@ let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
rdmAuditAvailable: false,
rdmCockpitAvailable: false,
rdmExportAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
adminSections: [],
@@ -306,6 +311,7 @@ let cardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
let projectCardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
const actionInflight = new Set<string>()
const actionFeedbackOwners: Record<string, symbol> = {}
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
const version = ++detailVersion
@@ -366,7 +372,7 @@ function actionFeedback(key: string, value?: 'submitting' | 'unknown', ids: stri
if (value ? current[id] === value : !(id in current)) continue
states ??= { ...state.cardActionStates }
if (value) states[id] = value
else { delete states[id]; delete actionFeedbackOwners[id] }
else { delete states[id]; delete actionFeedbackOwners[id]; workActionExpectations.delete(id) }
}
if (states) set({ cardActionStates: states })
}
@@ -381,13 +387,22 @@ function startActionFeedback(key: string, ids: string[] = []) {
function settleCardFeedback(cards: ActivityCard[]) {
let states: AppState['cardActionStates'] | undefined
for (const card of cards) {
if (card.status === 'action_required') continue
const expected = workActionExpectations.get(card.id)
if (expected) {
const decision = card.work?.decision?.state
const settled = expected.actionId === 'work.read' ? (card.work?.readVersion ?? 0) >= expected.payload.resultVersion
: expected.actionId === 'work.defer' ? decision === 'deferred'
: expected.actionId === 'work.reopen' ? decision === 'open' : decision === 'submitted'
if (!settled) continue
}
if (!expected && card.status === 'action_required') continue
const source = hitlSource(card)
for (const id of [card.id, ...(source ? [`hitl:${source}`] : [])]) {
if (!(id in (states ?? state.cardActionStates))) continue
states ??= { ...state.cardActionStates }
delete states[id]
delete actionFeedbackOwners[id]
workActionExpectations.delete(id)
}
}
if (states) set({ cardActionStates: states })
@@ -419,6 +434,7 @@ export const store = {
cardVersions = {}; projectCardVersions = {}; cardPatches = {}; projectCardPatches = {}
runObservations = {}; messageObservations = {}; hitlObservations = {}
actionInflight.clear()
workActionExpectations.clear()
for (const key of Object.keys(actionFeedbackOwners)) delete actionFeedbackOwners[key]
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
convRestoreDone = true
@@ -426,9 +442,9 @@ export const store = {
set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv',
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
@@ -513,7 +529,7 @@ export const store = {
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: [], plan: null, todos: null, surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
artifacts: [], outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false, filesView: 'list' })
}
@@ -603,7 +619,7 @@ export const store = {
const epoch = ++conversationEpoch
streamSeq = 0; 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: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
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 })
persistConvId(convId)
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
@@ -716,6 +732,9 @@ export const store = {
if (e.runId && liveEventTypes.has(e.type)) observeDetail(runObservations, e.runId, e.seq)
const now = eventTime(e)
const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now)
const belongsToLive = (agentRun: string) => !!state.live && (e.runId === state.live.runId
|| Object.prototype.hasOwnProperty.call(state.live.agentNotes, agentRun)
|| state.runs.some(run => run.id === agentRun && run.parentRunId === state.live?.runId))
switch (e.type) {
case 'message.delta': {
const p = e.payload as { index: number; text: string }
@@ -777,17 +796,17 @@ export const store = {
}
case 'agent.spawn': {
const p = e.payload as { agentRun: string; name: string; task: string }
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
if (state.live && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
break
}
case 'agent.progress': {
const p = e.payload as { agentRun: string; note?: string }
if (state.live && p.note) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
if (state.live && p.note && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
break
}
case 'agent.result': {
const p = e.payload as { agentRun: string; summary?: string }
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.summary ?? '任务已结束' } } })
if (state.live && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.summary ?? '任务已结束' } } })
store.reconcile()
break
}
@@ -888,7 +907,7 @@ export const store = {
}
},
async sendMessage(text: string, attachmentIds: string[], requestId?: string): Promise<'ok' | 'busy'> {
async sendMessage(text: string, attachmentIds: string[], requestId?: string, taskRef?: WorkReference): Promise<'ok' | 'busy' | 'rejected'> {
const convId = state.currentConvId
const epoch = conversationEpoch
const sameContext = () => state.currentConvId === convId && conversationEpoch === epoch
@@ -896,7 +915,7 @@ export const store = {
const id = requestId ?? newQueuedMessage(text, attachmentIds, []).id
set({ posting: true })
try {
const out = await api.sendMessage(convId, text, attachmentIds, id)
const out = await (taskRef ? api.sendMessage(convId, text, attachmentIds, id, taskRef) : api.sendMessage(convId, text, attachmentIds, id))
if (!sameContext()) return 'ok'
if (out.command) {
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
@@ -922,8 +941,8 @@ export const store = {
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
const live = !state.live || state.live.runId !== started.id
? { runId: started.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
: state.live
? { runId: started.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: eventTime({ ts: started.createdAt }) }
: { ...state.live, startedAt: eventTime({ ts: started.createdAt }, state.live.startedAt) }
set({ live, runs, messages })
store.connectIfNeeded()
}
@@ -931,19 +950,22 @@ export const store = {
} catch (e) {
if (!sameContext()) return 'ok'
if (isConversationBusy(e)) {
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [] }) })
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [], ...(taskRef ? { taskRef } : {}) }) })
return 'busy'
}
store.toast(`发送失败:${e}`)
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds,
attachmentNames: attachmentIds.map(attachmentId => state.artifacts.find(artifact => artifact.id === attachmentId)?.name ?? '已添加材料'),
...(taskRef ? { taskRef } : {}), deliveryError: '发送未成功,内容已保留;可修改后重试或移除' }) })
await store.reconcile()
return 'ok'
return 'rejected'
} finally {
if (sameContext()) set({ posting: false })
}
},
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[]) {
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames)) })
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference) {
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames, taskRef)) })
},
updateOutbox(id: string, text: string) {
@@ -954,6 +976,10 @@ export const store = {
set({ outbox: removeQueuedMessage(state.outbox, id) })
},
clearOutboxReference(id: string) {
set({ outbox: state.outbox.map(item => item.id === id ? { ...item, taskRef: undefined } : item) })
},
async sendOutboxNow(id: string) {
const item = state.outbox.find(q => q.id === id)
if (!item) return
@@ -964,23 +990,23 @@ export const store = {
return
}
set({ outbox: rest })
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item))
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item), item.taskRef)
},
async flushOutbox() {
if (state.outboxDraining) return
if (!canFlushOutbox(shellPhase())) return
const next = state.outbox[0]
const next = state.outbox.find(item => !item.deliveryError)
if (!next) return
set({ outbox: state.outbox.slice(1), outboxDraining: true })
let result: 'ok' | 'busy' = 'ok'
set({ outbox: removeQueuedMessage(state.outbox, next.id), outboxDraining: true })
let result: 'ok' | 'busy' | 'rejected' = 'ok'
try {
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next))
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next), next.taskRef)
} finally {
set({ outboxDraining: false })
}
if (result === 'busy') return
if (canFlushOutbox(shellPhase()) && state.outbox.length > 0) {
if (canFlushOutbox(shellPhase()) && state.outbox.some(item => !item.deliveryError)) {
await store.flushOutbox()
}
},
@@ -1065,7 +1091,8 @@ export const store = {
// 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项);
// 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。
async cardAction(card: ActivityCard, actionId: string, freeText?: string) {
async cardAction(card: ActivityCard, actionId: string, payload?: string | WorkCardAction) {
const freeText = typeof payload === 'object' ? payload.freeText : payload
const source = hitlSource(card)
if (source) {
await store.resolveHITL(source, actionId ? { choiceId: actionId } : { freeText })
@@ -1080,8 +1107,9 @@ export const store = {
const sameContext = () => identity === identityEpoch && state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
actionInflight.add(card.id)
const feedbackOwner = startActionFeedback(card.id)
if (actionId.startsWith('work.') && typeof payload === 'object') workActionExpectations.set(card.id, { actionId, payload })
try {
const out = await api.cardAction(card.id, actionId, freeText)
const out = await api.cardAction(card.id, actionId, payload)
if (!sameContext()) return
const newer = (card.conversationId === state.currentConvId ? cardVersions[card.id] : projectCardVersions[card.id]) > startedVersion
if (!newer && card.conversationId === state.currentConvId) store.upsertCard(out.card)
@@ -1090,7 +1118,7 @@ export const store = {
const cards = state.projectCards
set({ projectCards: cards.some(c => c.id === out.card.id) ? cards.map(c => c.id === out.card.id ? out.card : c) : [out.card, ...cards] })
}
if (out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
if (!actionId.startsWith('work.') && out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
observeDetail(messageObservations, out.receipt.id)
set({ messages: [...state.messages, out.receipt as Message] })
}
@@ -1101,7 +1129,7 @@ export const store = {
}
if (sameContext()) {
const current = [...state.cards, ...state.projectCards].find(c => c.id === card.id)
actionFeedback(card.id, actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
actionFeedback(card.id, !actionId.startsWith('work.') && actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
}
} catch (e) {
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
@@ -1171,15 +1199,16 @@ export const store = {
if (epoch !== identityEpoch) return
set({
rdmAuditAvailable: rdmAuditReady(cmds),
rdmCockpitAvailable: cockpitReady(cmds),
rdmExportAvailable: rdmExportReady(cmds),
rdmRolesAvailable: rdmRolesReady(cmds),
rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDocReviewReady(cmds),
rdmDocReviewAvailable: rdmDutyReady(cmds),
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
})
} catch {
if (epoch !== identityEpoch) return
set({ rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false })
}
try {
@@ -1327,9 +1356,33 @@ export const store = {
setUI({ rightCollapsed: false, rightTab: 'work', inboxFilter: 'todo' })
},
openInbox(cardId?: string) {
setUI({ rightCollapsed: false, rightTab: 'work' })
if (state.workRecord) set({ workRecord: null })
if (cardId) {
const scope = state.cards.some(card => card.id === cardId) ? 'conv'
: state.projectCards.some(card => card.id === cardId) ? 'project' : 'conv'
set({ inboxScope: scope })
}
setUI({ rightCollapsed: false, rightTab: 'work', ...(cardId ? { inboxFilter: 'all' as const } : {}) })
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
},
openWorkRecord(card: ActivityCard) {
if (!card.sourceRunId || !card.conversationId || card.conversationId !== state.currentConvId || card.projectId !== state.currentProjectId) {
store.toast('请先打开这项工作所属的对话,再查看执行记录')
return
}
set({ workRecord: { cardId: card.id, runId: card.sourceRunId, conversationId: card.conversationId, title: card.title } })
},
closeWorkRecord() { set({ workRecord: null }) },
referenceWork(card: ActivityCard) {
if (card.projectId !== state.currentProjectId || card.conversationId !== state.currentConvId || !card.work?.result?.trim()) {
store.toast('请在这项工作所属的对话中引用结果')
return
}
set({ workReference: { cardId: card.id, resultVersion: card.work.resultVersion, title: card.title } })
if (card.work.resultVersion > card.work.readVersion) void store.cardAction(card, 'work.read', { resultVersion: card.work.resultVersion })
if (typeof document !== 'undefined') document.querySelector<HTMLTextAreaElement>('.input-row textarea')?.focus()
},
clearWorkReference() { set({ workReference: null }) },
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
openFiles() { setUI({ rightTab: 'files', rightCollapsed: false }) },
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
@@ -1419,7 +1472,8 @@ auth.onIdentityChange(() => store.resetIdentity())
function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null {
if (state.runs.find(r => r.id === _runId)?.kind === 'subagent') return state.live
if (state.live && state.live.runId !== _runId && !isTerminal(state.runs.find(r => r.id === state.live?.runId)?.status)) return state.live
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: now }
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {},
startedAt: eventTime({ ts: state.runs.find(run => run.id === _runId)?.createdAt }, now) }
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
mutate(next)
return next
+82 -11
View File
@@ -194,6 +194,10 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tl-tools-sum { margin-left: 8px; color: var(--text-dim); }
.tl-check { color: var(--text-dim); font-size: var(--fs-ui-sm); margin: 4px 0; }
.tl-tools .status-error { margin-left: 8px; color: var(--err-text); }
.tl-tool-detail { margin-top: 4px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.tl-thinking { margin: 8px 0; padding: 8px 12px; border-left: 2px solid var(--border-strong); color: var(--text-dim); }
.tl-thinking-label { display: block; margin-bottom: 4px; font-size: var(--fs-ui-sm); font-weight: 600; }
.tl-thinking-body { white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--fs-ui-sm); line-height: 1.6; }
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .right-panel .preview-pane) */
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
@@ -226,6 +230,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.process-collapse .process-collapse-meta { color: var(--text-dim); }
.process-collapse .process-collapse-excerpt { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
.process-collapse .body { padding: 4px 12px 10px; }
.process-load-note { padding: 8px 0; color: var(--text-dim); font-size: var(--fs-ui-sm); }
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
@@ -512,17 +517,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
.ad-table tr:last-child td { border-bottom: none; }
.rdm-settings .ad-row { flex-wrap: wrap; align-items: center; }
.rdm-settings .ad-row .lg-input { flex: 1 1 160px; }
.rdm-settings .ad-row .picker { flex: 2 1 220px; min-width: 180px; }
.rdm-settings .ui-setting-control .picker { width: 100%; }
.rdm-filter-row { margin: 12px 0; }
.rdm-table-scroll { overflow-x: auto; margin: 12px 0; }
.rdm-table-scroll th { white-space: nowrap; }
.rdm-table-scroll td { vertical-align: top; min-width: 100px; }
.rdm-table-scroll td:first-child { min-width: 48px; }
.rdm-duty-tree { list-style: none; padding-left: 16px; margin: 8px 0; }
.rdm-duty-tree li { margin: 8px 0; }
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.ad-dim { color: var(--text-dim); }
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
@@ -650,3 +644,80 @@ 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; }
.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); }
+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)
+73 -8
View File
@@ -69,6 +69,29 @@ describe('conversation work reading', () => {
expect(html).not.toContain('data-testid="input-status"')
})
it('renders stable semantic timestamps on user and assistant messages', () => {
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理材料' }]), id: 'user', role: 'user' }, reply([{ type: 'text', text: '材料已收到' }])]
const html = render()
expect(html.match(/<time class="message-timestamp"/g)).toHaveLength(2)
expect(html.match(/dateTime="2026-10-12T10:00:00.000Z"/g)).toHaveLength(2)
expect(html).toContain('title="2026/10/12')
})
it('announces unread work without inserting its full result into the chat', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card({ status: 'done', work: { result: 'background-result-full-text', resultVersion: 2, readVersion: 1 } })]
const html = render()
expect(html).toContain('1 项工作有新结果')
expect(html).not.toContain('background-result-full-text')
})
it('shows an explicit removable result reference without copying its content into the draft', () => {
snapshot.state!.workReference = { cardId: 'work', resultVersion: 2, title: '材料整理' }
const html = render()
expect(html).toContain('基于「材料整理」的第 2 版结果')
expect(html).toContain('aria-label="移除工作结果引用"')
})
it('shows the current real work with the auxiliary pane closed', () => {
snapshot.state!.cards = [card()]
const html = render()
@@ -114,25 +137,30 @@ describe('conversation work reading', () => {
if (pinned) expect(html).not.toContain('data-card-id="work_active"')
})
it('keeps the newest completed result as a compact anchor with the auxiliary pane open', () => {
it('leaves completed results to the visible right work pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = completedResults()
const html = renderWork()
expect(html).toContain('最新整理成果 · 查看摘要')
expect(html).not.toContain('最新成果的主要结论')
expect(html).not.toContain('先前整理成果')
expect(html).toBe('')
})
it('keeps a compact work anchor when the same summary is visible in the right pane', () => {
it('keeps the work card in one detail location when the right work pane is visible', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card()]
const html = render()
expect(html).toContain('整理项目材料')
expect(html).not.toContain('data-conversation-work')
expect(html).not.toContain('核对会议结论')
snapshot.state!.ui.rightCollapsed = true
expect(render()).toContain('核对会议结论')
})
it('keeps the conversation detail when the right pane is reading files', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.ui.rightTab = 'files'
snapshot.state!.cards = [card()]
expect(render()).toContain('核对会议结论')
})
it('keeps secondary message operations available through progressive disclosure', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '已经整理好要点' }])]
const html = render()
@@ -190,6 +218,29 @@ describe('conversation work reading', () => {
expect(html).toContain('确认新增')
})
it('shows only a location link for anchored confirmation when the work pane owns its actions', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理资料' }]), role: 'user' }]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
const html = render()
expect(html).toContain('新增一份项目草稿')
expect(html).toContain('在工作栏处理')
expect(html).not.toContain('确认新增')
})
it('keeps a submitted card as a location link when its full body is in the work pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card({ id: 'result_1', kind: 'result', title: '材料核对结果', status: 'done',
body: [{ type: 'markdown', markdown: '材料核对的完整正文' }] })]
snapshot.state!.messages = [reply([{ type: 'card', card: { cardId: 'result_1', kind: 'result', data: [] } }])]
const html = render()
expect(html).toContain('材料核对结果')
expect(html).not.toContain('展开全文')
snapshot.state!.ui.rightCollapsed = true
expect(render()).toContain('展开全文')
})
it('keeps a required confirmation accessible when its anchor is an assistant message', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '准备新增项目草稿' }])]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
@@ -197,12 +248,26 @@ describe('conversation work reading', () => {
expect(render()).toContain('确认新增')
})
it('does not reveal internal thinking when records are deliberately opened', () => {
it('keeps a thinking-only record available for deliberate review', () => {
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'thinking', process: true, text: 'private-internal-deliberation' }],
live: true, idKey: 'thinking-test', renderPart: () => null,
}))
expect(html).not.toContain('private-internal-deliberation')
expect(html).toContain('private-internal-deliberation')
snapshot.state!.messages = [reply([{ type: 'thinking', process: true, text: 'private-internal-deliberation' },
{ type: 'text', text: '已完成核对' }])]
expect(render()).toContain('执行记录')
})
it('lets readers reopen the full output of a grouped read action', () => {
setPersistedOpen('tool-record:read-history:call-read:0', true)
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'tool_call', process: true,
toolCall: { id: 'call-read', name: 'fs_read_file', arguments: '{"path":"材料.txt"}' },
toolOutput: 'full-read-output' } as Message['parts'][number]],
live: false, idKey: 'read-history', renderPart: () => null,
}))
expect(html).toContain('full-read-output')
})
it('blocks repeated HITL choices when the shared action result is unknown', () => {
+19 -3
View File
@@ -1,6 +1,6 @@
// 设计系统守护测试(父仓 DESIGN.md §11 禁止清单 · 宿主侧,2026-09-30)。
// 棘轮基线:现存豁免登记在下方;新增同类违规直接失败。收紧 = 修掉违规后删基线条目。
// 范围:src/styles.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
// 范围:src/**/*.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
@@ -36,8 +36,14 @@ describe('DESIGN.md 宪法在位', () => {
// ---- 2. 动效唯一所有者 ----
describe('动效唯一所有者', () => {
it('styles.css 无 @keyframes(全部走 @ui/motion.css 的 mk-* 类)', () => {
expect(stripCssComments(read(join(SRC, 'styles.css')))).not.toContain('@keyframes')
it('宿主 CSS 无 @keyframes 或私有 transition(全部走 @ui/motion.css 的 mk-* 类)', () => {
for (const path of walk(SRC).filter(p => p.endsWith('.css'))) {
const css = stripCssComments(read(path))
expect(css, `${relative(SRC, path)} 不得定义 @keyframes`).not.toContain('@keyframes')
const transitions = css.split('\n').filter(line => /transition\s*:/.test(line) && !/transition\s*:\s*none\b/.test(line))
if (path.endsWith('styles.css')) continue // 历史 transition 由现有棘轮逐步收紧;新分层 CSS 不再扩散。
expect(transitions, `${relative(SRC, path)} 动效必须消费 mk-*`).toEqual([])
}
})
})
@@ -53,6 +59,16 @@ describe('裸色值棘轮', () => {
const extra = found.filter(h => !allow.has(h))
expect(extra, `新增裸色值 ${[...new Set(extra)].join(', ')}——改用 tokens 令牌;确需豁免更新 HEX_BASELINE_STYLES 并写明理由`).toEqual([])
})
it('分层组件 CSS 只消费令牌,不派生私有颜色', () => {
const componentCss = walk(SRC).filter(p => p.endsWith('.css') && !p.endsWith('styles.css'))
const extra: string[] = []
for (const path of componentCss) {
const css = stripCssComments(read(path))
for (const hex of collectHex(css)) extra.push(`${relative(SRC, path)}: ${hex}`)
if (/color-mix\s*\(/.test(css)) extra.push(`${relative(SRC, path)}: color-mix`)
}
expect(extra, '分层组件 CSS 的颜色须由 @ui/tokens.css 提供').toEqual([])
})
it('组件 TSX 无裸色值', () => {
const extra: string[] = []
for (const p of walk(SRC).filter(p => /\.(tsx|ts)$/.test(p) && !p.endsWith('.d.ts'))) {
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import {
absorbServerDocs, applyCell, cellDuty, completeness, editCell, replacementNote, saveIssues, savePayload,
type DutyDocDraft,
} from '../src/duties/draft'
function doc(over: Partial<DutyDocDraft> = {}): DutyDocDraft {
return {
docId: 'd1', title: '需求', code: 'UR-1', typeKey: 'design', stage: 'BD',
dutyVersion: 3, activeRound: 0, legacySectionOverrides: 0, dirty: false,
author: 'alice', reviewers: ['bob'], approver: 'carol',
...over,
}
}
describe('文档职责草稿', () => {
it('单元格由三权反推,三人视图读到同一份角色', () => {
const roles = { author: 'alice', reviewers: ['bob', 'dave'], approver: 'carol' }
expect(cellDuty(roles, 'alice')).toBe('author')
expect(cellDuty(roles, 'bob')).toBe('reviewer')
expect(cellDuty(roles, 'dave')).toBe('reviewer')
expect(cellDuty(roles, 'carol')).toBe('approver')
expect(cellDuty(roles, 'erin')).toBe('none')
expect(completeness(roles)).toEqual({ authors: 1, reviewers: 2, approvers: 1, conflicts: 0 })
})
it('替换编制前先记住原编制,并保证一人一责', () => {
const next = applyCell({ author: 'alice', reviewers: ['bob'], approver: 'carol' }, 'bob', 'author')
expect(next.replacedAuthor).toBe('alice')
expect(next.roles).toEqual({ author: 'bob', reviewers: [], approver: 'carol' })
expect(replacementNote({ author: 'alice', reviewers: ['bob'], approver: 'carol' }, 'bob', 'author')).toBe('将替换alice')
})
it('清掉唯一编制后允许留在草稿,但禁止保存', () => {
const cleared = editCell([doc()], 'd1', 'alice', 'none')
expect(cleared[0].author).toBe('')
expect(cleared[0].dirty).toBe(true)
expect(saveIssues(cleared[0])).toContain('需指定编制人')
expect(savePayload(cleared, false).issues.join(' ')).toContain('需指定编制人')
})
it('过期服务端页不覆盖未保存草稿', () => {
const dirty = doc({ author: 'erin', dirty: true, dutyVersion: 3 })
const absorbed = absorbServerDocs([dirty], [{
docId: 'd1', title: '需求', typeKey: 'design', dutyVersion: 4,
}], [{ docId: 'd1', owner: 'alice', duty: 'author' }, { docId: 'd1', owner: 'bob', duty: 'reviewer' }, { docId: 'd1', owner: 'carol', duty: 'approver' }])
expect(absorbed[0].author).toBe('erin')
expect(absorbed[0].dutyVersion).toBe(3)
})
it('写入结果未知时采用服务端版本,不再留着旧草稿重放', () => {
const dirty = doc({ author: 'erin', dirty: true, dutyVersion: 3 })
const absorbed = absorbServerDocs([dirty], [{
docId: 'd1', title: '需求', typeKey: 'design', dutyVersion: 4,
}], [
{ docId: 'd1', owner: 'alice', duty: 'author' },
{ docId: 'd1', owner: 'bob', duty: 'reviewer' },
{ docId: 'd1', owner: 'carol', duty: 'approver' },
], true)
expect(absorbed[0]).toMatchObject({ author: 'alice', dutyVersion: 4, dirty: false })
})
it('保存载荷是每份脏文档的完整目标,超过 20 份被拒绝', () => {
const many = Array.from({ length: 21 }, (_, index) => doc({ docId: `d${index}`, dirty: true }))
const blocked = savePayload(many, true)
expect(blocked.issues).toContain('一次保存最多 20 份文档')
const one = savePayload([doc({ dirty: true, activeRound: 2 })], true)
expect(one.docs[0]).toMatchObject({
docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'carol',
expectedDutyVersion: 3, confirmInvalidation: true,
})
})
})
+57
View File
@@ -5,7 +5,9 @@ import {
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated,
applyCheck, applyFinalizing,
hasSettledReply, liveToMessage, livePhaseLabel, showProcessInline, type LiveItem,
runEventsToTimeline,
} from '../src/live'
import type { StreamEvent } from '../src/contracts'
import type { LiveState } from '../src/store'
function live(over: Partial<LiveState> = {}): LiveState {
@@ -159,6 +161,61 @@ describe('liveToMessage', () => {
})
})
it('reopens the ordered complete process from persisted run events', () => {
const output = 'x'.repeat(5000)
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
({ seq, type, payload, runId: 'r1', conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 })
const parts = runEventsToTimeline([
event(1, 'thinking.delta', { index: 0, text: '先核对' }),
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
event(3, 'tool.call', { callId: 'call1', name: 'fs_read_file', arguments: '{"path":"a.txt"}' }),
event(4, 'tool.output', { callId: 'call1', text: output }),
event(5, 'tool.result', { callId: 'call1', status: 'ok', summary: '读取成功' }),
event(6, 'run.phase', { phase: 'finalizing' }),
event(7, 'message.delta', { index: 1, text: '最终回复' }),
])
expect(parts.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call', 'tool_result'])
expect((parts[2] as typeof parts[number] & { toolOutput: string }).toolOutput).toBe(output)
expect(parts.some(part => part.text === '最终回复')).toBe(false)
})
it('retains collaboration notes from a child after the parent has replied', () => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'parent'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 })
const parts = runEventsToTimeline([
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(2, 'run.phase', { phase: 'finalizing' }),
event(3, 'message.delta', { index: 0, text: '最终回复' }),
event(4, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
event(5, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
], 'parent')
expect(parts.map(part => part.text)).toEqual([
'协作代理「资料核对」已开始', '协作进展:正在核对附录', '协作结束:附录已核对',
])
expect(parts.every(part => part.replaySource === 'agent')).toBe(true)
})
it('coalesces cumulative child updates without mixing tasks or erasing tool phases', () => {
const notes = [
['one', '我来检索'], ['two', '核对第二份材料'], ['one', '我来检索一下材料'],
['one', '调用 knowledge_search'], ['one', '根据检索结果,开始整理'],
...Array.from({ length: 80 }, () => ['one', '根据检索结果,开始整理完整报告']),
]
const events: StreamEvent[] = notes.map(([agentRun, note], seq) => ({
seq: seq + 1, type: 'agent.progress', payload: { agentRun, note }, runId: agentRun,
conversationId: 'c', ts: '2026-10-15T10:00:00Z', v: 1,
}))
expect(runEventsToTimeline(events, 'parent').map(part => part.text)).toEqual([
'协作进展:我来检索一下材料', '协作进展:核对第二份材料',
'协作进展:调用 knowledge_search', '协作进展:根据检索结果,开始整理完整报告',
])
})
it('uses the stable run start time for the temporary assistant message', () => {
expect(liveToMessage(live({ startedAt: Date.parse('2026-10-15T10:00:00Z') })).createdAt)
.toBe('2026-10-15T10:00:00.000Z')
})
describe('livePhaseLabel', () => {
it('names the current activity in Chinese', () => {
let items: LiveItem[] = applyThinkingDelta([], '想', 0)
+15 -8
View File
@@ -1,7 +1,7 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
import { humanChangePasswordError, humanLoginError, isPasswordChangeMode, requiresCurrentPassword } from '../src/components/LoginPage'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
@@ -31,14 +31,21 @@ describe('humanChangePasswordError(改密门)', () => {
})
})
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
describe('requiresCurrentPassword(强制改密门模式)', () => {
it('current_required(管理员指定/生成的初始密码首登)必须要当前密码,grant_setup 免当前密码', () => {
expect(requiresCurrentPassword('current_required')).toBe(true)
expect(requiresCurrentPassword('grant_setup')).toBe(false)
})
it('未知错误剥状态码前缀透传', () => {
expect(humanRedeemError('400: bad')).toBe('bad')
it('one_time_password(一次性登录密码受限会话)只需新密码与确认,免当前密码', () => {
expect(requiresCurrentPassword('one_time_password')).toBe(false)
})
it('设密门只接受服务端声明的三种模式,其余一律拒绝', () => {
expect(isPasswordChangeMode('grant_setup')).toBe(true)
expect(isPasswordChangeMode('current_required')).toBe(true)
expect(isPasswordChangeMode('one_time_password')).toBe(true)
expect(isPasswordChangeMode(undefined)).toBe(false)
expect(isPasswordChangeMode('')).toBe(false)
expect(isPasswordChangeMode('temporary_password')).toBe(false)
})
})
+54 -19
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
import { splitTimeline, mergeProcessTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolOutput, liveToMessage, showProcessInline } from '../src/live'
import type { Part } from '../src/contracts'
@@ -8,7 +8,7 @@ function p(partial: Partial<Part> & { type: Part['type'] }): Part {
}
describe('splitTimeline', () => {
it('keeps public Markdown before, between and after tools in reading order', () => {
it('keeps intermediate narration with its tools in reading order and the reply outside', () => {
const parts: Part[] = [
p({ type: 'text', text: '我会先核对**两份可读材料**。', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
@@ -18,12 +18,11 @@ describe('splitTimeline', () => {
p({ type: 'text', text: '接下来确认日期冲突。' }),
]
const split = splitTimeline(parts)
expect(split.final.map(part => part.text)).toEqual([
'我会先核对**两份可读材料**。',
'评审意见未能读取,完整结论尚未生成。',
'接下来确认日期冲突。',
expect(split.timeline.map(part => part.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call'])
expect(split.timeline.filter(part => part.type === 'text').map(part => part.text)).toEqual([
'我会先核对**两份可读材料**。', '评审意见未能读取,完整结论尚未生成。',
])
expect(split.timeline.map(part => part.type)).toEqual(['tool_call', 'tool_result', 'tool_call'])
expect(split.final.map(part => part.text)).toEqual(['接下来确认日期冲突。'])
})
it('keeps live scope limits visible while execution records are folded by default', () => {
@@ -36,9 +35,8 @@ describe('splitTimeline', () => {
expect(message.parts[1].process).toBe(true)
expect(showProcessInline(live, true)).toBe(false)
const split = splitTimeline(message.parts)
expect(split.final.map(part => part.text)).toEqual(['**范围限制:**目前仅整理两份材料,评审意见尚未读取。'])
expect(split.final.every(part => part.type === 'text')).toBe(true)
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call'])
expect(split.final).toEqual([])
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call'])
})
it('keeps finalizing checks and internal thinking out of visible text without duplicating explanations', () => {
@@ -52,8 +50,8 @@ describe('splitTimeline', () => {
]
for (const finished of [false, true]) {
const split = splitTimeline(parts, finished)
expect(split.final.map(part => part.text)).toEqual(['评审意见尚未读取。', '已整理两份材料,请补充清晰的评审意见。'])
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call', 'tool_result', 'check'])
expect(split.final.map(part => part.text)).toEqual(['已整理两份材料,请补充清晰的评审意见。'])
expect(split.timeline.map(part => part.type)).toEqual(['text', 'thinking', 'tool_call', 'tool_result', 'check'])
expect(split.final.length + split.timeline.length).toBe(parts.length)
}
})
@@ -69,7 +67,7 @@ describe('splitTimeline', () => {
expect(split.timeline.map(x => x.type)).toEqual(['tool_call'])
expect(split.final.map(x => x.type)).toEqual(['error', 'artifact_ref', 'text'])
})
it('keeps only execution records folded when narration precedes the last process part', () => {
it('keeps narration beside execution records when it precedes the last process part', () => {
const parts: Part[] = [
p({ type: 'text', text: '先找资料', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
@@ -78,8 +76,8 @@ describe('splitTimeline', () => {
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
]
const split = splitTimeline(parts)
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
expect(split.final.map(x => x.type)).toEqual(['text', 'text', 'artifact_ref'])
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
expect(split.final.map(x => x.type)).toEqual(['text', 'artifact_ref'])
})
it('keeps a card sandwiched between process parts inside the timeline', () => {
@@ -94,7 +92,7 @@ describe('splitTimeline', () => {
expect(split.final.map(x => x.text)).toEqual(['最终'])
})
it('keeps all narrations visible even when a finished run has no final text', () => {
it('promotes only the last narration when a finished run has no final text', () => {
const parts: Part[] = [
p({ type: 'text', text: '先写一版', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'run_python' } }),
@@ -102,9 +100,9 @@ describe('splitTimeline', () => {
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'run_python' } }),
]
const split = splitTimeline(parts, true)
expect(split.final.map(x => x.text)).toEqual(['先写一版', '纪要已经整理好'])
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_call'])
expect(splitTimeline(parts).final).toEqual(split.final)
expect(split.final.map(x => x.text)).toEqual(['纪要已经整理好'])
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_call'])
expect(splitTimeline(parts).final).toEqual([])
})
it('puts a message with no process parts entirely in the final section', () => {
@@ -115,6 +113,43 @@ describe('splitTimeline', () => {
})
})
it('enriches saved process without losing retry notes or parsed inline thinking', () => {
const saved: Part[] = [
p({ type: 'text', process: true, text: '模型在输出前失败,正在重试' }),
p({ type: 'thinking', process: true, text: '检查附件边界' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
]
const replayed: Part[] = [
p({ type: 'text', process: true, text: '<think>检查附件边界</think>' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' }, toolOutput: '完整输出' } as Part),
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
p({ type: 'text', process: true, text: '最终回复' }),
p({ type: 'text', process: true, text: '协作进展:附录已核对' }),
]
const merged = mergeProcessTimeline(saved, replayed, [p({ type: 'text', text: '最终回复' })])
expect(merged.map(part => part.text).filter(Boolean)).toEqual([
'模型在输出前失败,正在重试', '检查附件边界', '协作进展:附录已核对',
])
expect((merged[2] as Part & { toolOutput: string }).toolOutput).toBe('完整输出')
expect(merged.filter(part => part.type === 'tool_call')).toHaveLength(1)
})
it('does not repeat a partial event delta beside the complete saved narration', () => {
const saved = [p({ type: 'text', process: true, text: '开始读取材料' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
const replayed = [p({ type: 'text', process: true, text: '开始读取' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
expect(mergeProcessTimeline(saved, replayed, []).map(part => part.text).filter(Boolean)).toEqual(['开始读取材料'])
})
it('keeps a collaboration result even when the final reply contains its short status', () => {
const note = p({ type: 'text', process: true, text: '协作结束:已完成', replaySource: 'agent' } as Part)
const merged = mergeProcessTimeline([p({ type: 'text', process: true, text: '完成' })], [note],
[p({ type: 'text', text: '已完成' })])
expect(merged.map(part => part.text)).toEqual(['完成', '协作结束:已完成'])
})
describe('artifact ids in reply text', () => {
it('extracts unique ids in order', () => {
expect(artifactIdsIn('见 art_0123456789ab 和 `art_abcdefabcdef`,再提 art_0123456789ab'))
+107
View File
@@ -0,0 +1,107 @@
// 忘记密码唯一处置守护:管理员生成一次性登录密码 → 用户登录 → 强制改密。
// 恢复提案 UI/API 已整体移除;登录页只保留用户名 + 密码一种凭据入口。
import { describe, expect, it } from 'vitest'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const SRC = join(dirname(fileURLToPath(import.meta.url)), '..', 'src')
const read = (...parts: string[]) => readFileSync(join(SRC, ...parts), 'utf8')
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, out)
else if (/\.(ts|tsx)$/.test(p)) out.push(p)
}
return out
}
describe('恢复提案已移除', () => {
it('src 不再调用恢复提案或登录 Key 兑换端点', () => {
const hits = walk(SRC).filter(p => /recovery-proposals|RecoveryProposal|login-key\/redeem|redeemLoginKey/.test(readFileSync(p, 'utf8')))
expect(hits.map(p => relative(SRC, p))).toEqual([])
})
})
describe('一次性登录密码 API 契约', () => {
const apiSrc = read('api.ts')
it('POST /api/admin/users/{owner}/one-time-password 并消费 oneTimePassword 字段', () => {
const line = apiSrc.split('\n').find(l => l.includes('adminIssueOneTimePassword:')) ?? ''
expect(line).toContain('/api/admin/users/${encodeURIComponent(owner)}/one-time-password`')
expect(line).toContain("method: 'POST'")
expect(line).toContain('oneTimePassword: string')
})
it('旧的临时密码重置端点与字段已移除', () => {
expect(apiSrc).not.toContain('adminResetUserPassword')
expect(apiSrc).not.toContain('temporaryPassword')
expect(apiSrc).not.toMatch(/\/api\/admin\/users\/\$\{[^}]+\}\/password`/)
})
})
describe('管理员用户管理:唯一忘记密码动作', () => {
const panel = read('components', 'adminSections.tsx')
it('调用一次性登录密码端点并展示 oneTimePassword', () => {
expect(panel.match(/api\.adminIssueOneTimePassword\(/g)).toHaveLength(1)
expect(panel).toContain('out.oneTimePassword')
expect(panel).not.toContain('temporaryPassword')
})
it('每行只有「生成一次性登录密码」一个按钮,无恢复提案或重置临时密码入口', () => {
expect(panel.match(/>生成一次性登录密码</g)).toHaveLength(1)
expect(panel).not.toContain('恢复提案')
expect(panel).not.toContain('重置临时密码')
})
it('一次性密码文案标明使用后强制改密,明文只在一次性展示框出现', () => {
expect(panel).toContain('使用后必须强制改密')
expect(panel.match(/aria-label="一次性密码"/g)).toHaveLength(1)
expect(panel).not.toMatch(/localStorage|sessionStorage|clipboard/)
})
it('文案标明生成不使原密码失效;原密码登录则作废未用的一次性密码且不强制改密', () => {
expect(panel).not.toMatch(/原密码(已|立即|随生成即)失效/)
expect(panel).toContain('原密码仍可正常登录')
expect(panel).toContain('未使用的一次性登录密码随即作废')
expect(panel).toContain('不会被要求改密')
})
})
describe('登录页:仅用户名 + 密码', () => {
const login = read('components', 'LoginPage.tsx')
const loginPage = login.slice(login.indexOf('export function LoginPage'), login.indexOf('export function requiresCurrentPassword'))
it('只有一个用户名输入与一个密码输入,无第二凭据入口', () => {
expect(loginPage.match(/<input /g)).toHaveLength(2)
expect(loginPage.match(/type="password"/g)).toHaveLength(1)
expect(loginPage).toContain('api.login(')
expect(loginPage).not.toMatch(/role="tab"|login-key|alk_|redeem/i)
})
it('忘记密码指引指向一次性登录密码且标明须改密', () => {
expect(loginPage).toContain('生成一次性登录密码,使用后须立即改密')
})
})
describe('强制改密门:one_time_password 模式', () => {
const login = read('components', 'LoginPage.tsx')
const gate = login.slice(login.indexOf('export function ChangePasswordGate'), login.indexOf('export function humanLoginError'))
it('门放行判定经 isPasswordChangeMode,接受 one_time_password', () => {
expect(gate).toContain('isPasswordChangeMode(me.passwordChangeMode)')
expect(gate).toContain("mode === 'one_time_password'")
})
it('当前密码输入只在 current_required 下渲染,且提示不再指向一次性登录密码', () => {
const currentBlock = gate.slice(gate.indexOf("{mode === 'current_required' && <>"), gate.indexOf('</>}'))
expect(currentBlock).toContain('id="forced-current"')
expect(currentBlock).not.toContain('一次性登录密码')
expect(gate.match(/id="forced-current"/g)).toHaveLength(1)
})
})
describe('我的账号:无恢复提案确认', () => {
const settings = read('components', 'MyAccountSettings.tsx')
it('只给出联系管理员生成一次性登录密码的指引,没有可操作的恢复按钮', () => {
expect(settings).toContain('请联系管理员生成一次性登录密码')
expect(settings).toContain('强制改密')
expect(settings).not.toMatch(/由本人确认|重新生成设密能力|兑换/)
})
it('不声称生成一次性登录密码会使原密码失效', () => {
expect(settings).not.toMatch(/原密码(已|立即|随生成即)失效/)
expect(settings).toContain('原密码仍可正常登录')
})
})
+10 -5
View File
@@ -56,7 +56,8 @@ describe('project command identity', () => {
it.each(['archived', 'closed'])('preserves canonical member, permission and duty reads for a %s project', async state => {
const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
const commands = ['rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get']
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan']
for (const command of commands) await api.rdmProjectCommand(historical, command, { docId: 'doc-2' })
expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
{ path: '/api/commands/rdm.project.member.list', params: { projectId: 'domain-7', docId: 'doc-2' } },
@@ -65,13 +66,17 @@ describe('project command identity', () => {
{ path: '/api/commands/rdm.review.doc.status', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.catalog', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.get', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.preview', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.legacy.scan', params: { projectId: 'domain-7', docId: 'doc-2' } },
])
})
it.each(['archived', 'closed'])('still blocks project and document mutations for a %s project', async state => {
const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
for (const command of ['rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.setRole',
'rdm.review.assign.doc', 'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final']) {
'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.apply',
'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final']) {
await expect(api.rdmProjectCommand(historical, command)).rejects.toThrow()
}
expect(requests).toEqual([])
@@ -86,10 +91,10 @@ describe('project command identity', () => {
it('sends member and duty commands to the canonical domain project from service metadata', async () => {
await api.rdmProjectCommand(ready, 'rdm.project.member.add', memberAddPayload(ready.id, 'bob'))
await api.rdmProjectCommand(ready, 'rdm.review.assign.doc', assignDocDutiesPayload(ready.id, 'doc-2', 'alice', ['bob'], 'carol', 3))
await api.rdmProjectCommand(ready, 'rdm.review.assign.doc', assignDocDutiesPayload(ready.id, 'doc-2', 'alice', ['bob'], 'carol'))
expect(requests.map(r => JSON.parse(String(r.init?.body)).params)).toEqual([
{ projectId: 'domain-7', member: 'bob', role: 'member' },
{ projectId: 'domain-7', docId: 'doc-2', author: 'alice', reviewers: ['bob'], approver: 'carol', expectedDutyVersion: 3, confirmInvalidation: false },
{ projectId: 'domain-7', docId: 'doc-2', author: 'alice', reviewers: ['bob'], approver: 'carol' },
])
})
@@ -179,7 +184,7 @@ describe('project work and mutation failures', () => {
it('checks a denied settings save instead of resolving success', async () => {
route = async () => response({ error: 'denied' }, 403)
await expect(api.putProjectSettings('host-1', {}, 3)).rejects.toThrow(/403/)
await expect(api.putProjectSettings('host-1', {})).rejects.toThrow(/403/)
})
})
+12 -9
View File
@@ -3,8 +3,8 @@
//(未登录/请求失败/null)一律视为未就绪(fail-closed),普通基座不出现 RDM 页面。
import { describe, expect, it } from 'vitest'
import {
RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DOC_REVIEW_COMMANDS, ROLE_ACTIONS,
rdmRolesReady, rdmMembersReady, rdmDocReviewReady,
RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DUTY_COMMANDS, ROLE_ACTIONS,
rdmRolesReady, rdmMembersReady, rdmDutyReady, dutyMatrixPayload, dutyPreviewPayload,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload, assignDocDutiesPayload,
docSubmitPayload, docApprovePayload, docRejectPayload, docFinalPayload, docStatusPayload,
@@ -19,19 +19,19 @@ describe('RDM 页面能力门控', () => {
it('命令齐备且 enabled 才就绪', () => {
expect(rdmRolesReady(all(RDM_ROLES_COMMANDS))).toBe(true)
expect(rdmMembersReady(all(RDM_MEMBER_COMMANDS))).toBe(true)
expect(rdmDocReviewReady(all(RDM_DOC_REVIEW_COMMANDS))).toBe(true)
expect(rdmDutyReady(all(RDM_DUTY_COMMANDS))).toBe(true)
})
it('缺一命令即未就绪', () => {
const drop = (ids: readonly string[], skip: string) => ids.filter(i => i !== skip).map(id => cmd(id))
expect(rdmRolesReady(drop(RDM_ROLES_COMMANDS, 'rdm.role.list'))).toBe(false)
expect(rdmMembersReady(drop(RDM_MEMBER_COMMANDS, 'rdm.project.member.add'))).toBe(false)
expect(rdmDocReviewReady(drop(RDM_DOC_REVIEW_COMMANDS, 'rdm.review.doc.final'))).toBe(false)
expect(rdmDutyReady(drop(RDM_DUTY_COMMANDS, 'rdm.duty.matrix'))).toBe(false)
})
it('disabled / null / 非数组 / 请求失败均未就绪(fail-closed)', () => {
expect(rdmRolesReady(RDM_ROLES_COMMANDS.map(id => cmd(id, false)))).toBe(false)
expect(rdmRolesReady(null)).toBe(false)
expect(rdmMembersReady(undefined)).toBe(false)
expect(rdmDocReviewReady('not-array' as unknown as never)).toBe(false)
expect(rdmDutyReady('not-array' as unknown as never)).toBe(false)
})
})
@@ -60,14 +60,17 @@ describe('命令载荷构造(身份只经认证会话——载荷不含任何
expect(memberRemovePayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol' })
})
it('文档职责与整文审签载荷', () => {
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave', 7, true))
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave', expectedDutyVersion: 7, confirmInvalidation: true })
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave'))
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave' })
expect(docSubmitPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
expect(docApprovePayload('p1', 'd1', '通过')).toEqual({ projectId: 'p1', docId: 'd1', note: '通过' })
expect(docRejectPayload('p1', 'd1', '依据不足')).toEqual({ projectId: 'p1', docId: 'd1', reason: '依据不足' })
expect(docFinalPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
expect(docFinalPayload('p1', 'd1', 3)).toEqual({ projectId: 'p1', docId: 'd1', round: 3 })
expect(docStatusPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
expect(dutyMatrixPayload('p1', { view: 'matrix', docLimit: 10 })).toEqual({ projectId: 'p1', view: 'matrix', docLimit: 10 })
expect(dutyPreviewPayload('p1', [{ docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2, confirmInvalidation: true }]))
.toEqual({ projectId: 'p1', docs: [{ docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2, confirmInvalidation: true }] })
})
it('审阅人 CSV 解析:去空白、去空项、保序去重', () => {
expect(parseReviewers('bob, carol,,bob')).toEqual(['bob', 'carol'])
@@ -87,12 +90,12 @@ describe('项目权限矩阵页(rdm.perm.check 消费)', () => {
it('矩阵查询载荷(身份只经认证会话)', () => {
expect(permCheckPayload('p1', 'bob')).toEqual({ projectId: 'p1', member: 'bob' })
})
it('来源标签:解释读面的人话映射;未知来源不暴露内部代码', () => {
it('来源标签:解释读面的人话映射;未知来源回退原文', () => {
expect(describeSource('admin')).toBe('系统管理员')
expect(describeSource('prof-role-default')).toContain('角色')
expect(describeSource('override')).toContain('覆盖')
expect(describeSource('relation-default')).toContain('关系')
expect(describeSource('non-member')).toContain('成员')
expect(describeSource('weird')).toBe('来源未提供')
expect(describeSource('weird')).toBe('weird')
})
})
-80
View File
@@ -1,80 +0,0 @@
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { ActionDetailTable, overrideMode, projectActions, type MatrixAction } from '../src/components/RdmPermMatrixSection'
import { DutyTree, dutyValidation, normalizeDuties, documentAssignmentImpact } from '../src/components/RdmDutiesSection'
import { personItem, readableError, RequestFence } from '../src/components/rdmSettingsShared'
const action: MatrixAction = { action: 'doc.write', scope: 'project', allowed: false, defaultAllowed: true,
capAllowed: false, override: true, source: 'cap', sourceLabel: '管理员权限上限' }
describe('project settings authorization presentation', () => {
it('preserves the explicit override even when a cap makes effective permission false', () => {
expect(overrideMode(action)).toBe('allow')
expect(overrideMode({ ...action, allowed: true, override: false })).toBe('deny')
expect(overrideMode({ ...action, override: undefined })).toBe('unavailable')
const html = renderToStaticMarkup(<ActionDetailTable actions={[action]} canManage />)
expect(html).toMatch(/value="allow"[^>]*disabled/)
expect(html).toContain('管理员上限禁止授予')
})
it('excludes all system actions including misleadingly scoped project creation from every project detail', () => {
const actions = [{ ...action, action: 'project.create' }, { ...action, scope: 'system' }, { ...action, scope: undefined }, action]
expect(projectActions(actions)).toEqual([action])
const html = renderToStaticMarkup(<ActionDetailTable actions={actions} canManage />)
expect(html).not.toContain('project.create')
expect(html).not.toContain('创建项目')
expect(html).toContain('编制与编辑文档')
})
it('joined or unverified accounts cannot be added through candidates', () => {
expect(personItem({ owner: 'bob', valid: true, joined: true }, true)).toMatchObject({ disabled: true, badge: '已加入' })
expect(personItem({ owner: 'bob' }, true)).toMatchObject({ disabled: true, disabledReason: '账号有效状态尚未核验' })
expect(personItem({ owner: 'bob', name: '李工', valid: true, joined: false }, true)).toMatchObject({ title: '李工', disabled: false })
})
it('only document nodes can enter document selection; chapters are visible context', () => {
const html = renderToStaticMarkup(<DutyTree selected="" onSelect={() => {}} nodes={[
{ id: 's', kind: 'section', label: '章节 A', selectable: true, docId: 'mistaken-doc', children: [] },
{ id: 'd', kind: 'doc', label: '方案文档', selectable: true, docId: 'real-doc', children: [] },
]} />)
expect(html).toContain('章节 A · 章节</span>')
expect(html).toMatch(/<button[^>]*>方案文档<\/button>/)
expect(html).not.toMatch(/<button[^>]*>章节 A/)
})
it('normalizes frozen domain roles without using project identity as signing authority', () => {
expect(normalizeDuties({ Author: 'author', Reviewers: ['reviewer'], Approver: 'approver' })).toEqual({ author: 'author', reviewers: ['reviewer'], approver: 'approver' })
expect(dutyValidation({ author: 'alice', reviewers: ['alice'], approver: 'bob' })).toContain('不同成员')
})
it('distinguishes unavailable capabilities, permission denial and readable service reasons', () => {
expect(readableError(new Error('400: unknown or disabled command: rdm.duty.tree'))).toContain('尚未装配')
expect(readableError(new Error('403: denied'))).toContain('没有权限')
expect(readableError(new Error('409: {"error":"职责版本冲突,请刷新"}'))).toBe('职责版本冲突,请刷新')
})
it('shows the command rejection reason without exposing its raw receipt envelope', () => {
const error = new Error('400: {"receipt":{"commandId":"rdm.review.assign.doc","state":"rejected","reason":"rejected: 职责版本冲突,请刷新"}}')
expect(readableError(error)).toBe('职责版本冲突,请刷新')
})
it('does not warn that unchanged duties or independently assigned chapters will be invalidated', () => {
const roles = { author: 'a', reviewers: ['r'], approver: 'p' }
const document = { docId: 'doc', title: '方案', assigned: true, roles, active: { round: 2 },
sections: [{ sectionId: 'chapter', title: '独立章节', roles, overridden: true, active: { state: 'in-review' } }] }
expect(documentAssignmentImpact(document, roles)).toBe(false)
expect(documentAssignmentImpact({ ...document, active: null }, { ...roles, author: 'b' })).toBe(false)
expect(documentAssignmentImpact({ ...document, active: null, sections: [{ ...document.sections[0], overridden: false }] }, { ...roles, author: 'b' })).toBe(true)
})
})
describe('late response fences used by all settings sections', () => {
it('discards pending reads after a context switch before effects can clear the form', async () => {
const fence = new RequestFence('alice:project-a')
const old = fence.begin('read')
const result = Promise.resolve().then(() => old() ? 'private-a' : 'discarded')
fence.setContext('bob:project-a')
expect(await result).toBe('discarded')
expect(fence.begin('read')()).toBe(true)
})
it('cancels old document reads while preserving an independent current request', () => {
const fence = new RequestFence('alice:project-a')
const first = fence.begin('detail')
const list = fence.begin('catalog')
const second = fence.begin('detail')
expect(first()).toBe(false); expect(second()).toBe(true); expect(list()).toBe(true)
fence.cancel('detail'); expect(second()).toBe(false)
fence.invalidate(); expect(list()).toBe(false)
})
})
-52
View File
@@ -1,52 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { RdmDutiesSection, RdmMembersSection, RdmPermMatrixSection, RdmRolesPanel } from '../src/components/adminRdmSections'
import { describeSource } from '../src/rdmManagement'
import type { Project } from '../src/contracts'
vi.mock('../src/store', () => ({
useStore: (select: (state: unknown) => unknown) => select({ me: { owner: 'alice', name: 'Alice', isAdmin: true } }),
}))
const project: Project = {
id: 'host-project', name: '研发项目', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'rdm-project', 'rdm.state': 'ready' },
}
describe('approved project settings interactions', () => {
it('offers a professional role filter separate from project identity when adding a member', () => {
const html = renderToStaticMarkup(<RdmMembersSection project={project} />)
expect(html).toContain('aria-label="专业角色筛选"')
expect(html).toContain('项目负责人')
expect(html).toContain('账号或姓名')
})
it('offers document ID/title searching and a tree/list view without manual ID assignment', () => {
const html = renderToStaticMarkup(<RdmDutiesSection project={project} />)
expect(html).toContain('aria-label="搜索文档"')
expect(html).toContain('树形')
expect(html).toContain('列表')
})
it('keeps the multi-member matrix disabled until caller management capability is known', () => {
const html = renderToStaticMarkup(<RdmPermMatrixSection project={project} />)
expect(html).toMatch(/<option[^>]*(?:value="matrix"[^>]*disabled|disabled[^>]*value="matrix")[^>]*>/)
})
it('renders readable action names in system role configuration', () => {
const html = renderToStaticMarkup(<RdmRolesPanel />)
expect(html).toMatch(/value="project.create"[^>]*>创建项目<\/option>/)
expect(html).toMatch(/value="duty.assign">分配文档职责<\/option>/)
})
it('never offers the system creation privilege as an editable project role action', () => {
const html = renderToStaticMarkup(<RdmRolesPanel />)
expect(html).toMatch(/<option[^>]*(?:value="project.create"[^>]*disabled|disabled[^>]*value="project.create")[^>]*>创建项目<\/option>/)
})
it('does not show raw permission source codes when the server adds a known scope denial', () => {
expect(describeSource('scope')).toBe('配置范围不符')
expect(describeSource('error')).toBe('暂时无法核验')
expect(describeSource('unrecognized-source')).toBe('来源未提供')
})
})
+3 -3
View File
@@ -26,10 +26,10 @@ describe('describeRoleListError', () => {
const denied = 'Error: 400: {"receipt":{"commandId":"rdm.role.list","state":"rejected",'
+ '"reason":"rejected: 权限拒绝:weichen 不是系统管理员(全局动作 project.settings)"}}'
it('explains denied system-role authority without suggesting the retired transfer bypass', () => {
it('maps the domain-admin denial to a readable message with the transfer channel', () => {
const got = describeRoleListError(denied)
expect(got).toContain('当前账号无权管理系统专业角色')
expect(got).not.toContain('rdm.perm.transfer')
expect(got).toContain('不是 RDM 域管理员')
expect(got).toContain('rdm.perm.transfer')
expect(got).not.toContain('"receipt"')
})
+26
View File
@@ -0,0 +1,26 @@
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { loadRunEvents } from '../src/runEvents'
import type { StreamEvent } from '../src/contracts'
afterEach(() => vi.restoreAllMocks())
function event(seq: number): StreamEvent {
return { seq, type: 'message.delta', runId: 'run', conversationId: 'conversation', ts: '2026-10-15T07:00:00Z', v: 1, payload: { index: 0, text: `已保存输出${seq}` } }
}
it('reads a complete saved record across the 500-event boundary in order', async () => {
const query = vi.spyOn(api, 'runEventsPage')
.mockResolvedValueOnce({ events: Array.from({ length: 500 }, (_, i) => event(i + 1)) })
.mockResolvedValueOnce({ events: [event(501), event(502)] })
const controller = new AbortController()
const result = await loadRunEvents('conversation', 'run', controller.signal)
expect(result.map(item => item.seq)).toEqual(Array.from({ length: 502 }, (_, i) => i + 1))
expect(query).toHaveBeenNthCalledWith(1, 'conversation', 'run', 0, controller.signal)
expect(query).toHaveBeenNthCalledWith(2, 'conversation', 'run', 500, controller.signal)
})
it('stops an invalid nonadvancing record cursor instead of fetching forever', async () => {
const query = vi.spyOn(api, 'runEventsPage').mockResolvedValue({ events: Array.from({ length: 500 }, (_, i) => event(i + 1)) })
await expect(loadRunEvents('conversation', 'run', new AbortController().signal)).rejects.toThrow('cursor did not advance')
expect(query).toHaveBeenCalledTimes(2)
})
-16
View File
@@ -1,16 +0,0 @@
import { describe, expect, it } from 'vitest'
import { scrollRatio, scrollTopForRatio } from '../src/scrollsync'
describe('scrollsync', () => {
it('没有溢出时不同步', () => {
expect(scrollRatio({ scrollTop: 0, scrollHeight: 100, clientHeight: 100 })).toBeNull()
expect(scrollTopForRatio({ scrollHeight: 80, clientHeight: 80 }, 0.5)).toBeNull()
})
it('按可滚动范围的比例映射,并夹在 0 到 1', () => {
expect(scrollRatio({ scrollTop: 50, scrollHeight: 200, clientHeight: 100 })).toBe(0.5)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, 0.5)).toBe(100)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, 2)).toBe(200)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, -1)).toBe(0)
})
})
-33
View File
@@ -1,33 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
afterEach(() => vi.restoreAllMocks())
describe('versioned shared settings API', () => {
it('sends the loaded project revision and settings in the strict envelope', async () => {
let url: unknown, init: RequestInit | undefined
vi.spyOn(auth, 'fetch').mockImplementation(async (path, options) => {
url = path; init = options
return new Response(JSON.stringify({ ok: true, projectRevision: 8 }))
})
await api.putProjectSettings('host/id', { deliverables: { style: 'formal' } }, 7)
expect(url).toBe('/api/settings/project/host%2Fid')
expect(JSON.parse(String(init?.body))).toEqual({ values: { deliverables: { style: 'formal' } }, expectedRevision: 7 })
})
it('rejects permission and conflict failures for all settings writers', async () => {
vi.spyOn(auth, 'fetch').mockResolvedValue(new Response('changed', { status: 409 }))
await expect(api.putSystemSettings({}, 3)).rejects.toThrow('409')
vi.spyOn(auth, 'fetch').mockResolvedValue(new Response('denied', { status: 403 }))
await expect(api.putUserSettings({})).rejects.toThrow('403')
})
it('sends genuine inheritance removal without guessing a default value', async () => {
let init: RequestInit | undefined
vi.spyOn(auth, 'fetch').mockImplementation(async (_path, options) => {
init = options
return new Response(JSON.stringify({ ok: true, projectRevision: 8 }))
})
await api.putProjectSettings('host/id', {}, 7, ['deliverables.style'])
expect(JSON.parse(String(init?.body))).toEqual({ values: {}, expectedRevision: 7, unset: ['deliverables.style'] })
})
})
-45
View File
@@ -1,45 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { SettingInput } from '../src/components/SettingsModal'
import { scopedSchema } from '../src/components/SettingsFields'
import { BorrowPanels } from '../src/components/BorrowPanels'
import type { SettingDesc } from '../src/contracts'
vi.mock('../src/store', () => ({ useStore: (select: (state: unknown) => unknown) => select({ ui: {} }) }))
const field = (type: SettingDesc['type'], options?: string[]): SettingDesc => ({
key: 'deliverables.style', group: 'deliverables', label: '交付风格', type, options, effect: 'new-run',
})
describe('setting editors preserve types and readable values', () => {
it('keeps legacy personal plugin settings visible only in personal scopes', () => {
const schema: SettingDesc[] = [
{ ...field('string'), key: 'plugin.legacy', scope: undefined },
{ ...field('string'), key: 'plugin.user', scope: 'user' },
{ ...field('string'), key: 'plugin.project-personal', scope: 'project-personal' },
{ ...field('string'), key: 'plugin.project', scope: 'project' },
{ ...field('string'), key: 'plugin.system', scope: 'system' },
]
const keys = (scope: string) => scopedSchema(schema, scope).map(desc => desc.key)
expect(keys('user')).toEqual(['plugin.legacy', 'plugin.user'])
expect(keys('project-personal')).toEqual(['plugin.legacy', 'plugin.user', 'plugin.project-personal'])
expect(keys('project')).toEqual(['plugin.project'])
expect(keys('system')).toEqual(['plugin.system'])
})
it('uses a number input for numeric values rather than submitting arbitrary strings', () => {
expect(renderToStaticMarkup(<SettingInput desc={field('number')} value={12} onChange={() => {}} />)).toContain('type="number"')
})
it('shows readable enum names and a distinct inherited choice', () => {
const html = renderToStaticMarkup(<SettingInput desc={field('enum', ['modern', 'formal', 'minimal'])} value="" onChange={() => {}} />)
expect(html).toContain('沿用默认')
expect(html).toContain('>现代</option>')
expect(html).toContain('>正式</option>')
expect(html).toContain('>简洁</option>')
})
it('personal supplementary panels no longer offer system credentials', () => {
const html = renderToStaticMarkup(<BorrowPanels />)
expect(html).not.toContain('新增密钥')
expect(html).not.toContain('主模型')
expect(html).toContain('条件注入')
expect(html).toContain('用户命令')
})
})
-26
View File
@@ -1,26 +0,0 @@
import { describe, expect, it } from 'vitest'
import { personalSettingsMutation, settingsMutation } from '../src/components/SettingsFields'
import type { SettingDesc } from '../src/contracts'
const schema: SettingDesc[] = [
{ key: 'tools.limit', group: 'tools', label: '上限', type: 'number', effect: 'new-run', scope: 'system' },
{ key: 'models.chat.apiKey', group: 'models', label: '密钥', type: 'secret', secret: true, effect: 'hot', scope: 'system' },
]
describe('settings merge patch behavior', () => {
it('omits unchanged masked credentials and removes a numeric override without storing its default', () => {
expect(settingsMutation({ 'tools.limit': 20, 'models.chat.apiKey': '••••••' }, { 'tools.limit': undefined, 'models.chat.apiKey': '••••••' }, schema))
.toEqual({ values: {}, unset: ['tools.limit'] })
})
it('sends explicit secret revocation as an empty string rather than losing it through flattening', () => {
expect(settingsMutation({ 'models.chat.apiKey': '••••••' }, { 'models.chat.apiKey': '' }, schema))
.toEqual({ values: { models: { chat: { apiKey: '' } } }, unset: [] })
})
it('does not forward fields outside the visible scoped schema', () => {
expect(settingsMutation({}, { 'tools.limit': 0, 'project.create': true }, schema))
.toEqual({ values: { tools: { limit: 0 } }, unset: [] })
})
it('uses explicit null deletion for personal inheritance instead of omitting a merged field', () => {
expect(personalSettingsMutation({ 'tools.limit': 20 }, { 'tools.limit': undefined }, schema))
.toEqual({ tools: { limit: null } })
})
})
+24
View File
@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
afterEach(() => vi.restoreAllMocks())
describe('versioned work request bodies', () => {
it('preserves multiple selections and free input on the card action endpoint', async () => {
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify({ card: { id: 'task' } })))
await api.cardAction('task', 'work.submit', { resultVersion: 3, selectedIds: ['summary', 'parameters'], freeText: '请说明依据' })
const [url, options] = request.mock.calls[0]
expect(url).toBe('/api/cards/task/actions/work.submit')
expect(JSON.parse(String(options?.body))).toEqual({ resultVersion: 3, selectedIds: ['summary', 'parameters'], freeText: '请说明依据' })
})
it('sends a result identity separately from the user message and discards client display metadata', async () => {
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify({ message: { id: 'reply' } })))
const reference = { cardId: 'task', resultVersion: 3, title: '说明书整理', result: 'must-not-be-client-context' }
await api.sendMessage('conversation', '比较这些重点', [], 'request-1', reference)
expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual({
text: '比较这些重点', attachmentIds: [], requestId: 'request-1', taskRef: { cardId: 'task', resultVersion: 3 },
})
})
})
+99
View File
@@ -43,7 +43,42 @@ beforeEach(async () => {
})
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('preserves rejected referenced input without retrying it or blocking later queued chat', async () => {
const reference = { cardId: 'saved-result', resultVersion: 1, title: '整理结果' }
const submit = vi.spyOn(api, 'sendMessage').mockRejectedValueOnce(new Error('400: reference no longer valid'))
.mockResolvedValue({ message: { id: 'accepted', conversationId: 'a', role: 'user', parts: [], createdAt: '' } })
expect(await store.sendMessage('请解释重点', ['saved-upload'], 'retry-safe-id', reference)).toBe('rejected')
const failed = store.getState().outbox[0]
expect(failed).toMatchObject({ id: 'retry-safe-id', text: '请解释重点', attachmentIds: ['saved-upload'], taskRef: reference })
expect(failed.deliveryError).toBeTruthy()
await store.flushOutbox()
expect(submit).toHaveBeenCalledTimes(1)
store.enqueueOutbox('先讨论其他事情', [], [])
await store.flushOutbox()
expect(submit).toHaveBeenCalledTimes(2)
expect(store.getState().outbox).toHaveLength(1)
store.clearOutboxReference(failed.id)
await store.sendOutboxNow(failed.id)
expect(submit).toHaveBeenLastCalledWith('a', '请解释重点', ['saved-upload'], 'retry-safe-id')
expect(store.getState().outbox).toEqual([])
})
describe('work projection ordering and context', () => {
it('keeps an earlier background task out of a newer foreground process', async () => {
const current: Run = { id: 'new-topic', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
const child: Run = { ...current, id: 'old-child', kind: 'subagent', parentRunId: 'old-topic' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [current, child]))
await store.openConversation('a')
store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在回答新的问题' }, current.id))
store.applyEvent(event(2, 'agent.progress', { agentRun: child.id, note: '旧材料仍在整理' }, child.id))
expect(store.getState().live?.agentNotes[child.id]).toBeUndefined()
store.applyEvent(event(3, 'agent.spawn', { agentRun: 'new-child', name: '新任务', task: '核对新问题' }, current.id))
store.applyEvent(event(4, 'agent.progress', { agentRun: 'new-child', note: '核对当前问题' }, 'new-child'))
expect(store.getState().live?.agentNotes['new-child']).toBe('核对当前问题')
store.applyEvent(event(5, 'agent.result', { agentRun: child.id, summary: '旧材料已整理' }, child.id))
expect(store.getState().live?.runId).toBe(current.id)
expect(store.getState().live?.agentNotes[child.id]).toBeUndefined()
})
it('settles foreground completion and queued input when background events overtake its detail response', async () => {
vi.useFakeTimers()
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
@@ -248,6 +283,70 @@ describe('work projection ordering and context', () => {
})
describe('shared action feedback', () => {
it('sends a versioned optional choice once and keeps it out of the conversation', async () => {
const task = { ...card('work-choice', 'done'), work: { resultVersion: 2, readVersion: 0,
result: '完整材料结果', decision: { mode: 'multiple' as const, state: 'open' as const } } }
store.upsertCard(task)
const response = deferred<Awaited<ReturnType<typeof api.cardAction>>>()
const action = vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
const payload = { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '请保留来源' }
const pending = store.cardAction(task, 'work.submit', payload)
await store.cardAction(task, 'work.submit', payload)
expect(action).toHaveBeenCalledOnce()
expect(action).toHaveBeenCalledWith(task.id, 'work.submit', payload)
response.resolve({ card: { ...task, work: { ...task.work, decision: { ...task.work.decision, state: 'submitted' } } },
receipt: { id: 'unexpected-work-receipt', conversationId: 'a', role: 'assistant', parts: [], createdAt: '' } })
await pending
expect(store.getState().messages).toEqual([])
expect(store.getState().cardActionStates[task.id]).toBeUndefined()
expect(store.getState().cards.find(c => c.id === task.id)?.work?.decision?.state).toBe('submitted')
})
it('does not unlock an uncertain optional submission merely because its result is done', async () => {
const task = { ...card('work-unknown', 'done'), work: { resultVersion: 1, readVersion: 0,
result: '结果', decision: { mode: 'single' as const, state: 'open' as const } } }
store.upsertCard(task)
vi.spyOn(api, 'cardAction').mockRejectedValue(new TypeError('connection lost'))
vi.mocked(api.listCards).mockResolvedValue([task])
await store.cardAction(task, 'work.submit', { resultVersion: 1, selectedIds: ['summary'] })
expect(store.getState().cardActionStates[task.id]).toBe('unknown')
store.upsertCard({ ...task, work: { ...task.work, decision: { ...task.work.decision, state: 'submitted' } } })
expect(store.getState().cardActionStates[task.id]).toBeUndefined()
})
it('settles successful metadata actions while an ordinary card still needs action', async () => {
const task = { ...card('work-read', 'action_required'), work: { resultVersion: 1, readVersion: 0 } }
store.upsertCard(task)
vi.spyOn(api, 'cardAction').mockResolvedValue({ card: { ...task, work: { ...task.work, readVersion: 1 } } })
await store.cardAction(task, 'work.read', { resultVersion: 1 })
expect(store.getState().cardActionStates[task.id]).toBeUndefined()
})
it('retains an explicit result reference across a queued send without adding it to chat text', async () => {
const task = { ...card('work-ref', 'done'), work: { result: '完整结果', resultVersion: 3, readVersion: 3 } }
store.upsertCard(task)
store.referenceWork(task)
const reference = store.getState().workReference!
store.enqueueOutbox('比较重点', [], [], reference)
store.clearWorkReference()
expect(store.getState().outbox[0].taskRef).toEqual({ cardId: 'work-ref', resultVersion: 3, title: '整理材料' })
const send = vi.spyOn(api, 'sendMessage').mockResolvedValue({ message: { id: 'queued-ref', conversationId: 'a', role: 'user', parts: [], createdAt: '' } })
await store.flushOutbox()
expect(send).toHaveBeenCalledWith('a', '比较重点', [], expect.any(String), reference)
expect(store.getState().workReference).toBeNull()
})
it('clears record selection and result reference when navigating to another conversation', async () => {
const task = { ...card('work-record', 'done'), sourceRunId: 'child', work: { result: '完整结果', resultVersion: 1, readVersion: 1 } }
store.upsertCard(task)
store.openWorkRecord(task)
store.referenceWork(task)
expect(store.getState().workRecord?.runId).toBe('child')
await store.openConversation('b')
expect(store.getState().workRecord).toBeNull()
expect(store.getState().workReference).toBeNull()
})
it.each(['card', 'hitl'] as const)('clears its definite rejected %s action after A to B navigation', async kind => {
const confirm = { ...card(`reject-${kind}`, 'action_required'), kind: 'confirm' as const,
...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:reject-${kind}`]] as [string, string][] }] } : {}) }