Compare commits

..
Author SHA1 Message Date
weichen a601bf9239 Fix the UI checkout SSH port and known_hosts entry
governance / governance (pull_request) Successful in 17s
ci / test-and-build (pull_request) Successful in 1m48s
Why: the URL was migrated but the separate -p port and the
known_hosts entry still referenced the old endpoint shape.

Evidence: host-verified; see Dev PR #706

Impact: CI configuration only.

Refs: Agent-System-Dev PR #706
2026-10-22 15:14:25 +00:00
weichen d4a1d1577f Bump migrated dependency pins for the endpoint migration
governance / governance (pull_request) Successful in 38s
ci / test-and-build (pull_request) Failing after 2m15s
Why: recursive submodule checkout reads pinned dep commits; their
.gitmodules now carries the new Gitea endpoint base only in the
migrated mains.

Evidence: host-verified; see Dev PR #706

Impact: pin updates only, part of the coordinated endpoint migration.

Refs: Agent-System-Dev PR #706
2026-10-22 14:36:16 +00:00
weichen bda546472d Bump the development-manager pin for the endpoint migration
governance / governance (pull_request) Successful in 45s
ci / test-and-build (pull_request) Failing after 2m17s
Why: governance checks validate candidate .gitmodules URLs against the
pinned development-manager scaffold, which now carries the new Gitea
endpoint base after its own migration.

Evidence: host-verified; see Dev PR #706

Impact: pin update only, part of the coordinated endpoint migration.

Refs: Agent-System-Dev PR #706
2026-10-22 14:25:44 +00:00
weichen 8fcbda4f6d Update Gitea endpoints after server migration
governance / governance (pull_request) Failing after 34s
ci / test-and-build (pull_request) Failing after 2m22s
Why: the Gitea server moved to https://gitea.naitangbaobao.cn with SSH
on port 52222; endpoint references must follow or superproject CI
checkout refuses the old URLs.

Evidence: host-verified API reach and SSH auth (Dev task card
fix/gitea-endpoint-migration)

Impact: configuration only; no runtime behavior change.

Refs: Dev PR #706
2026-10-22 14:16:56 +00:00
weichen ac5cf6b322 Merge pull request 'Deliver increment C Frontend work: formal RDM audit workspace' (#125) from feature/rdm-two-trees-followup-w10 into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 1m19s
2026-10-22 06:25:22 +00:00
weichen a82a1e18de Merge remote-tracking branch 'origin/main' into feature/rdm-two-trees-followup-w10
governance / governance (pull_request) Successful in 18s
ci / test-and-build (pull_request) Successful in 52s
2026-10-22 06:23:25 +00:00
weichen 279b2d02a2 Merge pull request 'Keep the scratch flag out of the Core-generated contracts' (#124) from fix/scratch-conversation-view-type into main
governance / governance (push) Successful in 27s
ci / test-and-build (push) Successful in 1m8s
2026-10-22 06:07:28 +00:00
weichenandCursor 6e611c5de0 Keep the scratch flag out of the Core-generated contracts
governance / governance (pull_request) Successful in 19s
ci / test-and-build (pull_request) Successful in 49s
Why: src/contracts.ts is regenerated from Agent-System-Core by the Dev
composite; the scratch field added there is dropped on regeneration and
tsc then fails in make build.

Evidence: contracts.ts equals the Core-generated output; npx tsc -b
--noEmit, npx vitest run (685 tests) and check-knip pass locally (SELF).

Impact: ConversationView (Conversation & { scratch?: boolean }) in
api.ts carries the server-local flag, mirroring the Backend
conversationView; no behavior change.

Refs: Agent-System-Frontend#123, Agent-System-Dev#691;
agent-exec receipt u3f-run (cursor, success)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 06:06:01 +00:00
agent-exec-transport 4882e0ae25 agent-exec transport a9ccb6178c1cd1ffb0e66f9ad77b6b65/c2d99ab92f51 2026-10-22 05:57:33 +00:00
agent-exec-transport a61eaac9ae agent-exec transport 793bf9727d04cae50d0b270d9064534b/21372630236f 2026-10-22 05:50:45 +00:00
agent-exec-transport fa08be4145 agent-exec transport 3e6b9205399daae9ea6ea6069d96fecd/ab1e6aa64e0f 2026-10-22 05:29:31 +00:00
weichen ec1ced3050 Merge pull request 'Add the no-project conversation view backed by scratch sessions' (#123) from feature/scratch-session-general-agent into main
governance / governance (push) Successful in 22s
ci / test-and-build (push) Successful in 42s
2026-10-22 04:04:52 +00:00
weichenandCursor b02ff83dfc Hide project-only surfaces in scratch conversations
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 39s
Why: Independent review found the cross-read toggle, the project inbox
scope and the archive-to-project action still reachable from an open
scratch (no-project) conversation.

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

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

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

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

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

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

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

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

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

Refs: plan revision 3 U5-Frontend; /tmp/iam-link-u5-frontend-pi-verify-20261019.json exit=0.
2026-10-20 06:50:53 +00:00
agent-exec-transport d997d4a4d0 agent-exec transport 85c00f1e2274be1a84b73a421987fc69/ba12fd06dc72
governance / governance (pull_request) Successful in 16s
ci / test-and-build (pull_request) Successful in 34m20s
2026-10-20 06:21:55 +00:00
weichen 0b388e010d Merge pull request 'IAM U4 frontend-auto-discover (5s polling + capability-group UI + creator/owner/member duties)' (#115) from feature/iam-link-u4-frontend-auto-discover into main
ci / test-and-build (push) Successful in 3m37s
governance / governance (push) Successful in 15s
2026-10-20 03:31:25 +00:00
weichen 2bd5116f58 Merge Frontend main into U4 branch for L1→main sync
governance / governance (pull_request) Successful in 13s
ci / test-and-build (pull_request) Successful in 2m21s
2026-10-20 03:26:56 +00:00
agent-exec-transport f01579890d agent-exec transport 280102ed04383156d99d681a10a5b302/b5e3788d8c79
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 27s
2026-10-19 15:14:12 +00:00
46 changed files with 3932 additions and 554 deletions
+3 -3
View File
@@ -37,11 +37,11 @@ jobs:
umask 077
trap 'rm -f "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"' EXIT
printf '%s' "${UI_DEPLOY_KEY}" > "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key"
printf '%s\n' '[git-ssh.u1680832.nyat.app]:31411 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK55QSxDqMMZ1HIWHSPkQ7fyJkDKbdg98TeIv6SrR3qx' > "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"
export GIT_SSH_COMMAND="ssh -F /dev/null -i ${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts -p 31411"
printf '%s\n' '[gitea.naitangbaobao.cn]:52222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK55QSxDqMMZ1HIWHSPkQ7fyJkDKbdg98TeIv6SrR3qx' > "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"
export GIT_SSH_COMMAND="ssh -F /dev/null -i ${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts -p 52222"
cd "${RUNNER_TEMP}/iam-frontend-ci/ui"
git init -q
git remote add origin "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git"
git remote add origin "ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-System-UI.git"
git fetch --depth=1 origin "${UI_SHA}"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "${UI_SHA}"
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
SSH = "ssh://git@gitea.naitangbaobao.cn:52222/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines",
+2 -2
View File
@@ -1,6 +1,6 @@
[submodule "deps/development-guidelines"]
path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Guidelines.git
[submodule "deps/development-manager"]
path = deps/development-manager
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Manager.git
+3 -3
View File
@@ -24,9 +24,9 @@
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
见到一律按过期处理,不要再试。当前权威通道:
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
- **Web / API(HTTPS)**:`https://gitea.naitangbaobao.cn`(Gitea 1.27.3,
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
- **Git over SSH**:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/<Repo>.git`
(各仓 origin 与 gitlink 均为此格式)。
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
@@ -34,7 +34,7 @@
(baseline 硬边界 2:只记录来源、字段与可用状态)。
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
`~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。
## Agent 执行与新项目准入(强制)
@@ -0,0 +1,90 @@
# 增量 C 前端独立评审(W10 审计工作台 + 依据/候选确认路径)
- 评审对象:`feature/rdm-two-trees-followup-w10` @ `fa08be4`(基线 `ec1ced3`,dev 无本地改动)
- 范围:`src/components/RdmAuditWorkspace.tsx(+test)`、`Conversation.tsx`、`ConversationWork.tsx`、`Workbench.tsx`、`src/api.ts`、`src/styles.css`、`src/companion-conversation.css`、`e2e/rdm-audit.playwright.ts`
- 增量:`+1048 / -7`,9 文件(`git diff --stat ec1ced3 fa08be4`)
- 评审性质:只读评审。代码零改动;本文件是唯一写入。契约核对基线:RDM 侧 `Agent-System-RDM-wt-feature-rdm-two-trees-followup-c-w8-w10` @ `c4c6df7`(本地检出的 W8b/W5 命令面);计划基线:`modules/documents` `rdm-two-trees-stage5-implementation-test-plan-r1.md` §W10
- 环境限制:本仓为独立检出,组合仓 `../ui`(`@ui` 源码别名)缺失、子模块未初始化 ⇒ 依赖 `@ui` 的 22 个测试文件与 `tsc -b` 无法在本工作区运行(见 §验证)。`npm ci --offline` 可用,故守护测试与 knip 实跑。
## 1. 逐项结论
| # | 检查项 | 结论 | 依据 |
|---|---|---|---|
| 1 | 设计门禁(令牌/动效/折叠) | **FAIL(部分)** | 裸色值、私有 keyframes、行内字号、`transition`、`--fs-ui-*` 全部通过守护测试(7/7 PASS);但两处折叠未用 `@ui Collapsible`(MAJOR-3) |
| 2 | DTO 边界(只消费服务端已裁剪 DTO、无新增全量拉取) | **PASS** | 页面只渲染 `display/recorded/disclosure`;服务端 `redactOperationBodies`(`inspection_visibility.go:70-88`)确实把无权正文的 `summary.display` 换成「受限」并清空 `object.current`,前端未越权补正文;列表单页 `limit:30`,证据/影响分析均为按需单条读取,无全量拉取 |
| 3 | 异步纪律(单一 owner、seq 丢迟到响应) | **PASS(有 1 处冗余镜像,见 MINOR-4)** | `createOperationsFeed` 每请求取 `++seq`,非最新 seq 的成功与失败一并丢弃,`dispose()` 亦作废在途;续页回传首页 `cutoffSeq`;其余面板沿用既有 `startLatestFetch` 单 owner 模式 |
| 4 | 四态可读 + 游标失效显式 | **PASS** | 加载/空/错误(denied·cursor·query·capability·auth 分流)/旧记录「当时未记录」/正文受限/部分覆盖/影响截断均有独立文案;错误分类正则与服务端真实串一致(`权限拒绝:…` 出自 `perms_api.go:234`;`操作记录游标已失效…` 出自 `operation_projection.go:42`),非臆测匹配 |
| 5 | 既有行为兼容(树/卡片/对话/导出预览;右栏收起的确认路径) | **FAIL** | 树/卡片/对话/导出预览无回归(页签按名定位,`审计记录` 与既有 `操作记录` 不冲突,`.rdm-audit-card` 计数断言互不干扰);但右栏收起后的「对话确认路径」在最需要它的工作卡场景下是空操作(MAJOR-1) |
| 6 | e2e 扩展质量(skip 如实) | **PASS(附 MINOR-6)** | `test.skip(!SVC_REAL_RDM_BIN, …)` 与注释如实说明「缺真实 RDM 二进制则整组跳过、不伪造」;但当前 CI 与 Dev `scripts/e2e.sh` 均不注入该变量 ⇒ 4 条新用例在任何现有执行入口下都永不运行(零证据) |
| 7 | 知识多挂载为 TODO 未实现、未虚报 | **PASS(附 MINOR-7)** | `RdmAuditWorkspace.tsx:591` 仅留 TODO,未见任何实现或「已完成」表述;TODO 给出的理由经核实为真:W8b 分支 RDM 侧 `grep '"mounts"'` 零命中,`rdm.inspect.tree/object` 确实无挂载字段(域层已有 `MountsOf`,仅未出 DTO) |
## 2. BLOCKING
无。
## 3. MAJOR
### MAJOR-1 右栏收起后的「对话确认路径」在目标场景下是空操作(承诺与实现不符)
- 位置:`src/components/Conversation.tsx:571-572, 734-740`;关联 `src/components/ConversationWork.tsx:65, 91`;承诺文案 `src/components/Workbench.tsx:82`
- 理由:新提示条仅做 DOM 定位滚动:
`body.querySelector('[data-card-id="…"]') ?? body.querySelector('[data-conversation-work]')`。
但显示条件恰是 `!workPaneOwnsCards`(右栏收起或非工作页签),此时:
1. `ConversationWork` 命中 `if (!detailHere) return null`(`ConversationWork.tsx:91`)——`[data-conversation-work]` 整个子树不存在;
2. 工作卡在对话正文里只经 `CardAnchor` 出现,而 `Conversation.tsx:56` 对带 `card.work` 的卡显式传 `card={undefined}`,UI 侧 `data-card-id={card?.id}`(`modules/ui/src/CardAnchor.tsx:25`)因此不落属性。
结果:W10 重点场景(候选工作卡待确认)点「N 项待你确认 · 在对话中处理 ↓」→ `target` 为 `null` → `target?.scrollIntoView` 静默无动作,而收起按钮的 title 却承诺「N 项待确认会在对话中继续处理」。
附带:`pendingHere`(含 `work.decision.state === 'open'`)与 `pendingCardId`(仅 `status === 'action_required'`,`Conversation.tsx:572`)口径不同,decision-open 卡即使能定位也算不出 `id`。
- 修复建议:改走仓内既有的唯一定位路径 `store.openInbox(pendingCardId)`(`src/store.ts:1625-1633`:展开右栏、切工作页签、`flashLocate('.right-panel','data-card-id',…)`),或在 `!workPaneOwnsCards` 时把候选/决议控件渲染进对话正文;同时让 `pendingCardId` 与 `pendingConfirmations` 用同一谓词。
### MAJOR-2 新增「审计记录」页签无能力门禁,缺命令面的构建上恒为错误态
- 位置:`src/components/RdmAuditWorkspace.tsx:52, 125-137`
- 理由:页签无条件挂载,而仓内既有约定是「能力缺失即不暴露」(`src/store.ts:1462-1475` 依 `api.listCommands()` 刷新能力位;`src/workspaces.ts:52` `rdmAuditReady`)。当前就绪门 `RDM_AUDIT_COMMANDS` 只含 7 个 `rdm.inspect.*`(`workspaces.ts:23-31`),不含 W8b/W5 的 `rdm.audit.operations`、`rdm.trace.coverage`、`rdm.trace.pending-recheck`、`rdm.compliance.impact.*`。因此在已合入的 RDM 上审计工作区入口照常可见,而新页签必然落到 `classifyOperationsError → capability`(「当前服务未提供审计能力」),与 `e2e/plain-base.playwright.ts:38-51` 记录的「零暴露、不进入即 400」原则相悖。
- 修复建议:在 `workspaces.ts` 增加 `rdmAuditOperationsReady(cmds)`(上述 5 条命令)并在页签列表按能力过滤;覆盖面板与影响分析入口同样受此门控;若坚持常显,则须在 PR 描述中登记为已知降级并由 RDM 发布节奏兜底。
### MAJOR-3 两处折叠未使用 `@ui Collapsible`(违反 DESIGN.md §9.2)
- 位置:`src/components/RdmAuditWorkspace.tsx:1200`(`EvidenceLink`)、`:1266`(`ImpactAnalysisBlock`)
- 理由:两者用 `<button aria-expanded>` + 条件挂载自建展开,未消费全项目唯一折叠原语;DESIGN.md §9.2「一切折叠区…用它:外壳高度动画 + 内容 fade 两层分离、300ms 延迟卸载、展开态记忆」。守护测试只拦 `<details>`,故测试全绿但违宪——本次评审门禁「折叠只用 @ui Collapsible」据此判 FAIL。
- 修复建议:改为 `<Collapsible id={…} trigger={…}>`(这两处都有稳定的 id 语义:证据 `${pid}/${kind}/${id}`、影响分析 `${pid}/${analysisId}`),可顺带获得展开态记忆与卸载动画。若以「同文件 `:408`、`RegCitation.tsx:157` 已有先例」为由豁免,请在 PR 中显式登记豁免理由,而非默认放行。
## 4. MINOR
| # | 位置 | 问题 | 建议 |
|---|---|---|---|
| MINOR-1 | `src/components/ConversationWork.tsx:22-24` | `candidateActions` 未排除已有决议表单的卡:@ui `WorkCardBody` 对 `work.decision` 开放态渲染 `DecisionForm`,若该卡 `actions` 同时含 confirm/reject,则同一决议出现两个入口,且新按钮只提交 `{resultVersion}`(不含 `selectedIds`/`freeText`),存在「未选选项即提交」风险 | 首行加 `if (card.work?.decision?.state === 'open') return []`,保持一个动作一个入口 |
| MINOR-2 | `src/components/Workbench.tsx:38-41` | `pendingConfirmations` 与 @ui `pendingCount`(`modules/ui/src/InboxList.tsx:103-106`)口径不一致:新函数未排除 `status === 'resolved'`,「resolved + decision open」的卡会被计入输入区提示与收起提示,却不计入收件箱/顶栏计数 ⇒ 同屏两个数字打架 | 直接复用 `pendingCount` 语义,或在注释中说明差异并统一文案 |
| MINOR-3 | `src/components/RdmAuditWorkspace.tsx:1303` | 覆盖面板用 `Promise.all([rdmTraceCoverage, rdmTracePendingRecheck])`:任一命令缺失(较旧构建只有 coverage)即整面板降级,正式/候选覆盖数字一并消失 | 改 `Promise.allSettled`,coverage 可用即渲染,pending 不可用时只在待复核一栏给说明 |
| MINOR-4 | `src/components/RdmAuditWorkspace.tsx:1051, 1060` | `dict` 是 `state.dictionary` 的第二份异步状态镜像;切项目时旧项目词典保留到新页落地,可能把「新项目不存在的筛选值」静默带上,且 `<select>` 无匹配 option 时显示为空白 | 随 `pid` 变化清空 `dict`,或词典未就绪前禁用两个 select |
| MINOR-5 | `src/components/RdmAuditWorkspace.tsx:1090` | `.rdm-audit-ops` 在任何 CSS 中都无规则(本次只补了 `.rdm-audit-wrap`、`.rdm-audit-op .rdm-audit-opgroup-head/.rdm-audit-op-pending`、`.rdm-audit-op-object`),列表容器无布局约束 | 补一条容器规则或复用既有类名,避免长对象名/窄屏下出现不可预期的块级堆叠 |
| MINOR-6 | `e2e/rdm-audit.playwright.ts:150` | 整组 skip 条件 `SVC_REAL_RDM_BIN` 在 Dev `scripts/e2e.sh` 与本仓 CI 中均无注入(`grep SVC_REAL_RDM_BIN scripts/e2e.sh` 零命中;CI 只跑 `npm test` + `npm run build`)⇒ 4 条新用例(含游标翻页、游标失效重查、无授权三态)当前零执行证据 | 组合验证时由 e2e.sh 导出该变量(它已在 `$OUT/agent-system-rdm` 构建出二进制,`e2e.sh:88`),或在 PR 中把「e2e 未运行」显式登记为 NOT_RUN 缺口 |
| MINOR-7 | `src/components/RdmAuditWorkspace.tsx:591` | 知识多挂载降级只以代码 TODO 记录;计划 §W10 把「知识多挂载」列入实施范围(`rdm-two-trees-stage5-implementation-test-plan-r1.md:154`),缺登记的降级记录,容易在跨仓验收时被当作已交付 | 在 Dev 任务卡/证据条目登记该验收项为「待 RDM DTO 增补」,并回指此 TODO;代码内无需再改 |
## 5. 已核实的正面结论(附证据)
1. **DTO 边界真实成立**:`internal/rdmapp/inspection_visibility.go:70-88` 在无权时把 `change.summary.display` 置为「受限」、`disclosure='restricted'`、`object.current=nil`;前端 `OperationRow`(`:1125-1170`)只渲染服务端 `display/recorded`,`refsText` 只拼 `kind.label+id`,未渲染任何前端臆造的正文或补全的「当前」名称。
2. **契约字段与 RDM 实际 DTO 对齐**:`OperationRecordDTO`/`OperationPage`/`ImpactAnalysis`/`TraceCoverageResult`/`TracePendingEdge` 的 JSON tag 与 `src/api.ts:73-175` 的类型逐字段核对一致(含 `cutoffSeq/nextCursor/total`、`ObjectRef.revision` 为字符串、`CGap{rule,node,reason}`)。
3. **错误分类与服务端真实串匹配**:`权限拒绝:`(`perms_api.go:234`)→ `denied`;`操作记录游标已失效`(`operation_projection.go:42`)/`操作记录游标不合法`(`:454`)→ `cursor` 且隐藏翻页按钮并给「从第一页重新查询」。
4. **异步纪律的测试覆盖到位**:`createOperationsFeed` 的 seq、dispose、续页 `cutoffSeq`、迟到失败响应均有断言(`RdmAuditWorkspace.test.tsx:145-215`),且实现与断言一致(`request()` 成功/失败双路都比对 `mine === seq`)。
5. **未引入 knip 基线压力**:`node scripts/check-knip.mjs` → `knip unused exports 58 <= 58`;逐项核对 knip JSON,本次改动的 5 个文件未新增 unused exports/types。
6. **既有回归面检查通过**:既有 `操作记录` 页签(TimelineTab)与新 `审计记录` 页签按 aria 名定位互不冲突;既有 e2e 的 `.rdm-audit-card` 计数断言作用于概览页签,与新覆盖卡片不同时渲染;导出预览入口(`exportNeedsDraft`/`DocumentRenderWorkspace`)未改动。
## 6. 验证命令与结果(本工作区实跑)
| 命令 | 结果 |
|---|---|
| `git diff --stat ec1ced3 fa08be4` | 9 files, +1048/-7(评审对象确认) |
| `npm ci --offline --no-audit --no-fund` | 176 packages(仅本地缓存,未联网) |
| `npx vitest run test/design-guard.test.ts` | **PASS** — Test Files 1 passed, Tests 7 passed(① 门禁实测) |
| `npx vitest run` | 43 files passed / 22 files failed;398 tests passed, 1 failed。22 个失败文件与该 1 个失败全部为 `Error: Cannot find package '@ui'`(组合仓 `../ui` 未检出),无其他错误类别 |
| `npx vitest run src/components/RdmAuditWorkspace.test.tsx` | **NOT_RUN** — 同一 `@ui` 解析失败(新增单测在本工作区无法执行) |
| `npx tsc -b` | **NOT_RUN** — 全部为 `TS2307 Cannot find module '@ui'` 及其引发的隐式 any,无新增类型错误信号 |
| `node scripts/check-knip.mjs` | **PASS** — `knip unused exports 58 <= 58` |
| `npm run e2e` / `npx playwright test` | **NOT_RUN** — 无 `PLAYWRIGHT_BASE_URL` 且无浏览器/后端组合环境;`SVC_REAL_RDM_BIN` 未注入时新组按声明跳过 |
| 浅/深主题、窄屏、长对象名视觉检查 | **NOT_RUN** — 需真实组合浏览器;静态侧只能确认新增样式全部消费令牌 |
## 7. 合入建议
1. **合入门禁**:MAJOR-1、MAJOR-2 建议在合入前修复(前者是承诺失效,后者是能力面 fail-closed 破口);MAJOR-3 可修或登记豁免。
2. **证据缺口**:本单元的 vitest/tsc/e2e 在组合检出(`../ui` 就绪 + auth-on 真实 RDM)下必须补跑;在补跑前,本评审对新增单测与 e2e 的结论仅为静态核对,不得记为「测试通过」。
3. **范围声明**:知识多挂载(MINOR-7)按计划 §W10 属未完成项,验收矩阵中应保持「待 RDM DTO 增补」状态,不得因代码内 TODO 而计为已交付。
+78
View File
@@ -144,3 +144,81 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
expect(file.suggestedFilename()).toMatch(/\.docx$/)
})
})
// W10 审计工作台(rdm.audit.operations + rdm.trace.coverage/pending-recheck)。
// 需真实组合:RDM 二进制含 W8b/W5 命令面(SVC_REAL_RDM_BIN 指向该构建)且 serve 以 auth on 运行;
// e2e.sh 默认从 RDM origin/main 构建,未必含这些命令——缺 SVC_REAL_RDM_BIN 时整组跳过,不伪造结果。
const OPS_ROUTE = '**/api/commands/rdm.audit.operations'
async function openOperations(page: Page) {
await login(page)
await openAudit(page)
await page.getByRole('tab', { name: '审计记录' }).click()
await expect(page.getByText('正在读取审计记录…')).toHaveCount(0, { timeout: 15_000 })
}
/** 改写请求体的 params(命令总线请求体为 { params, ... })。 */
const withParams = (raw: string | null, patch: Record<string, unknown>) => {
const body = JSON.parse(raw || '{}') as { params?: Record<string, unknown> }
return JSON.stringify({ ...body, params: { ...(body.params || {}), ...patch } })
}
test.describe.serial('W10 审计工作台:审计记录', () => {
test.skip(!process.env.SVC_REAL_RDM_BIN, '需真实 RDM 二进制(SVC_REAL_RDM_BIN,含 rdm.audit.operations / rdm.trace.*)与 auth-on 组合环境')
test('列表呈现业务摘要与受控覆盖,行展开见字段差异与来源链', async ({ page }) => {
await openOperations(page)
await expect(page.getByText('受控追溯覆盖').or(page.getByText(/受控覆盖统计暂不可用/))).toBeVisible()
const rows = page.locator('.rdm-audit-op')
await expect(rows.first()).toBeVisible()
await expect(page.getByText(/已显示 \d+ \/ 共 \d+ 条 · 截至水位 \d+/)).toBeVisible()
await rows.first().locator('.mk-col-trigger').click()
await expect(rows.first().getByText('字段差异')).toBeVisible()
await expect(rows.first().getByText('来源与确认链')).toBeVisible()
await expect(rows.first().getByText('影响(当时与当前)')).toBeVisible()
})
test('游标翻页:每页一条时加载更多追加下一条', async ({ page }) => {
await page.route(OPS_ROUTE, route => route.continue({ postData: withParams(route.request().postData(), { limit: 1 }) }))
await openOperations(page)
const rows = page.locator('.rdm-audit-op')
await expect(rows).toHaveCount(1)
const meta = await page.getByText(/已显示 \d+ \/ 共 \d+ 条/).textContent()
const total = Number(/共 (\d+) 条/.exec(meta || '')?.[1] || 0)
test.skip(total < 2, `种子项目仅 ${total} 条审计记录,无法验证翻页`)
await page.getByRole('button', { name: '加载更多记录' }).click()
await expect(rows).toHaveCount(2)
})
test('游标失效:续页报明确错误,可从第一页重新查询', async ({ page }) => {
await page.route(OPS_ROUTE, async route => {
const body = JSON.parse(route.request().postData() || '{}') as { params?: { cursor?: string } }
if (body.params?.cursor) {
await route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ receipt: { state: 'rejected', error: '操作记录游标已失效:游标绑定主体不符——请从首页重新查询' } }) })
return
}
const res = await route.fetch({ postData: withParams(route.request().postData(), { limit: 1 }) })
const json = await res.json() as { payload?: Record<string, unknown> } & Record<string, unknown>
const pageBody = (json.payload ?? json) as Record<string, unknown>
pageBody.hasMore = true
pageBody.nextCursor = 'stale-cursor'
await route.fulfill({ response: res, json })
})
await openOperations(page)
await page.getByRole('button', { name: '加载更多记录' }).click()
await expect(page.getByText(/翻页位置已失效/)).toBeVisible()
await expect(page.getByRole('button', { name: '加载更多记录' })).toHaveCount(0)
await page.getByRole('button', { name: '从第一页重新查询' }).click()
await expect(page.getByText(/翻页位置已失效/)).toHaveCount(0)
await expect(page.locator('.rdm-audit-op')).toHaveCount(1)
})
test('无审计授权:首页错误态可读', async ({ page }) => {
await page.route(OPS_ROUTE, route => route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ receipt: { state: 'rejected', error: '权限拒绝:无项目审计授权' } }) }))
await openOperations(page)
await expect(page.getByText(/你没有该项目的审计授权/)).toBeVisible()
await expect(page.getByText('原始回执')).toBeVisible()
})
})
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { loadSignedInWorkspace } from './App'
import { store } from './store'
afterEach(() => { vi.restoreAllMocks() })
function stubLoad() {
const order: string[] = []
vi.spyOn(store, 'refreshMe').mockImplementation(async () => { order.push('me') })
vi.spyOn(store, 'refreshProfiles').mockImplementation(async () => {})
vi.spyOn(store, 'refreshPlugins').mockImplementation(async () => {})
vi.spyOn(store, 'refreshProjects').mockImplementation(async () => { order.push('projects') })
vi.spyOn(store, 'startProjectSync').mockImplementation(() => { order.push('sync') })
return order
}
describe('AutoDiscover ownership after sign-in', () => {
it('checks projects immediately on sign-in, then hands re-checks to the single sync owner', async () => {
const order = stubLoad()
await loadSignedInWorkspace({ current: true })
expect(order).toEqual(['me', 'projects', 'sync'])
expect(store.startProjectSync).toHaveBeenCalledTimes(1)
})
it('does not start a sync owner for a page that was torn down during loading', async () => {
const order = stubLoad()
const active = { current: true }
vi.mocked(store.refreshProjects).mockImplementation(async () => { order.push('projects'); active.current = false })
await loadSignedInWorkspace(active)
expect(store.startProjectSync).not.toHaveBeenCalled()
})
})
+13 -8
View File
@@ -25,6 +25,16 @@ const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
const RIGHT_MIN = 280, RIGHT_MAX = 560
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
/** 放行后的首轮装载:首次项目列表就是登录即时核对;其后由唯一的自动发现所有者接管重查。 */
export async function loadSignedInWorkspace(active: { current: boolean }) {
await store.refreshMe()
if (!active.current) return
void store.refreshProfiles()
void store.refreshPlugins()
await store.refreshProjects()
if (active.current) store.startProjectSync()
}
export function App() {
const ui = useStore(s => s.ui)
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
@@ -114,14 +124,9 @@ export function App() {
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
let active = true
store.refreshMe().then(() => {
if (!active) return
void store.refreshProjects()
void store.refreshProfiles()
void store.refreshPlugins()
})
return () => { active = false }
const active = { current: true }
void loadSignedInWorkspace(active)
return () => { active.current = false; store.stopProjectSync() }
}, [authState])
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
+33
View File
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from './api'
afterEach(() => { vi.unstubAllGlobals() })
describe('AutoDiscover list request', () => {
it('passes the abort signal so an over-budget project list request is cancelled', async () => {
const seen: (RequestInit | undefined)[] = []
vi.stubGlobal('fetch', async (_path: string, init?: RequestInit) => {
seen.push(init)
return new Response('[]', { status: 200 })
})
const controller = new AbortController()
await api.listProjects(controller.signal)
await api.listProjects()
expect(seen[0]?.signal).toBe(controller.signal)
expect(seen[1]).toBeUndefined()
})
it('reads the linked creation capability from the identity response', async () => {
vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ owner: 'bob', name: 'Bob', auth: true,
linkedCapabilities: { projectCreate: true, source: 'system-grant', linkedActions: ['project.members.manage'], authorityEpoch: 4 } }), { status: 200 }))
const me = await api.me()
expect(me.linkedCapabilities).toMatchObject({ projectCreate: true, source: 'system-grant' })
})
it('returns the linked expansion from a role permission write receipt', async () => {
vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ receiptId: 'r1', roleVersion: 3, authorityEpoch: 9,
linkedExpansion: ['project.members.manage'] }), { status: 200 }))
const receipt = await api.replaceRolePermissions('role_pm', 2, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key')
expect(receipt.linkedExpansion).toEqual(['project.members.manage'])
})
})
+137 -9
View File
@@ -25,7 +25,15 @@ export interface UserRoleBinding {
bindingId: string; userId: string; roleId: string; active: boolean; bindingVersion: number
assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number
}
export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord }
/** getUserRole 返回的角色摘要:实测不含 permissions,权限补齐由角色列表条目提供。 */
export interface UserRoleSummary extends Omit<RoleRecord, 'permissions'> { permissions?: RolePermissionRecord[] }
export interface UserRoleView { binding?: UserRoleBinding; role?: UserRoleSummary }
/** Backend 计算的本人 project.create 联动(/api/me linkedCapabilities)。 */
export interface LinkedCapabilities {
projectCreate: boolean; source?: string; linkedActions: string[]; conflicts?: string[]; authorityEpoch: number
}
/** 角色/系统授权写入回执:linkedExpansion 为服务端为 project.create 联动补齐的动作。 */
interface PolicyWriteReceipt { receiptId: string; authorityEpoch: number; linkedExpansion?: string[]; migrationNote?: string }
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
@@ -62,6 +70,109 @@ export interface ExecEnvView {
bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[]
}
// ---- W8b 操作记录投影(rdm.audit.operations):服务端已裁剪的业务 DTO ----
// 缺项 recorded=false 且 display 为「当时未记录」;current 只是当前状态补出的名称(marker「当前」),
// 不冒充历史。change.disclosure=restricted 时摘要已被服务端替换为「受限」,页面不得假设有正文。
export interface RdmOperationLabel { key: string; label: string }
export interface RdmOperationText { value?: string; recorded: boolean; display: string }
export interface RdmOperationCurrent { value: string; marker: string }
interface RdmOperationRevision { value?: number; recorded: boolean; display: string }
export interface RdmOperationRef { kind: RdmOperationLabel; id: string }
export interface RdmOperationRefs { items: RdmOperationRef[]; recorded: boolean; display: string }
export interface RdmOperationRecord {
id: string
operation: RdmOperationLabel
actor: { name: RdmOperationText; duty: RdmOperationText; current?: RdmOperationCurrent }
initiatedAt: string
effectiveAt: { value?: string; recorded: boolean; display: string }
object: { recorded: boolean; kind: RdmOperationLabel; id?: string; display: string; current?: RdmOperationCurrent }
change: { before: RdmOperationRevision; after: RdmOperationRevision; summary: RdmOperationText; disclosure: string }
basis: { reason: RdmOperationText; evidence: RdmOperationRefs }
outcome: RdmOperationLabel
impact: { direct: RdmOperationRefs; indirect: RdmOperationRefs; analyses: RdmOperationRefs }
run: { recorded: boolean; runId?: string; stepId?: string; attempt?: number; display: string }
}
export interface RdmOperationDictionary { types: RdmOperationLabel[]; outcomes: RdmOperationLabel[]; kinds: RdmOperationLabel[] }
export interface RdmOperationPage {
items: RdmOperationRecord[]
nextCursor?: string
hasMore: boolean
cutoffSeq: number
total: number
dictionary: RdmOperationDictionary
}
/** 游标绑定项目、主体、授权 epoch、筛选与截止水位;续页须带回首页的 cutoffSeq。 */
export interface RdmOperationQuery {
targetKind?: string; targetId?: string
types?: string[]; outcomes?: string[]
from?: string; to?: string
cutoffSeq?: number; cursor?: string; limit?: number
}
// ---- W5 受控影响分析 / 覆盖 / 待复核(rdm.compliance.impact.* / rdm.trace.*) ----
export interface RdmObjectRef { projectId?: string; kind: string; id: string; revision?: string; anchor?: string }
/** 不可变影响快照:后续边变化不改写历史结果;重算生成新 ID。 */
export interface RdmImpactAnalysis {
id: string
start: RdmObjectRef
direct: RdmObjectRef[]
layers: RdmObjectRef[][]
involved: RdmObjectRef[]
truncated: boolean
cursor?: { frontier: RdmObjectRef[]; depth: number }
maxNodes: number
maxDepth: number
at: string
actor?: string
}
interface RdmImpactStart { startKind: string; startId: string; startRevision: number; maxNodes?: number; maxDepth?: number }
export interface RdmTraceGap { rule: number; node: string; reason: string }
/** confirmed 为正式覆盖;candidate 分列且不计正式覆盖。 */
export interface RdmTraceCoverage { confirmed: number; candidate: number; gaps: RdmTraceGap[] }
export interface RdmPendingRecheckEdge {
kind: string
from: string
to: string
fromOldRevision: string
fromNewRevision: string
toOldRevision: string
toNewRevision: string
state: string
source?: string
edgeId?: string
}
const emptyRefs = (refs?: RdmOperationRefs | null): RdmOperationRefs =>
refs ? { ...refs, items: refs.items ?? [] } : { items: [], recorded: false, display: '当时未记录' }
function normalizeOperationPage(page: RdmOperationPage): RdmOperationPage {
const dictionary = page.dictionary ?? { types: [], outcomes: [], kinds: [] }
return {
...page,
items: (page.items ?? []).map(r => ({
...r,
basis: { ...r.basis, evidence: emptyRefs(r.basis?.evidence) },
impact: { direct: emptyRefs(r.impact?.direct), indirect: emptyRefs(r.impact?.indirect), analyses: emptyRefs(r.impact?.analyses) },
})),
hasMore: Boolean(page.hasMore),
dictionary: { types: dictionary.types ?? [], outcomes: dictionary.outcomes ?? [], kinds: dictionary.kinds ?? [] },
}
}
function normalizeImpact(a: RdmImpactAnalysis): RdmImpactAnalysis {
return { ...a, direct: a.direct ?? [], layers: (a.layers ?? []).map(l => l ?? []), involved: a.involved ?? [] }
}
function operationParams(projectId: string, q: RdmOperationQuery): Record<string, unknown> {
const params: Record<string, unknown> = { projectId }
for (const [k, v] of Object.entries(q)) {
if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) continue
if (k === 'cutoffSeq' && !v) continue
params[k] = v
}
return params
}
async function checked(res: Response): Promise<Response> {
if (!res.ok) {
const text = await res.text()
@@ -75,6 +186,8 @@ async function json<T>(res: Response): Promise<T> {
return res.json() as Promise<T>
}
export type ConversationView = Conversation & { /** 无项目对话:服务端内部项目只承载这一条会话,不出现在项目列表里。 */ scratch?: boolean }
export const api = {
permissionCatalog: () => auth.fetch(`${BASE}/api/admin/permissions/catalog`).then(r => json<{ permissions: PermissionDescriptor[] }>(r)).then(d => d.permissions ?? []),
listRoles: (request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; action?: string; cursor?: string; limit?: number } = {}) => {
@@ -97,7 +210,7 @@ export const api = {
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(r)),
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<{ receiptId: string; roleVersion: number; authorityEpoch: number }>(r)),
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<PolicyWriteReceipt & { roleVersion: number }>(r)),
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
@@ -105,16 +218,17 @@ export const api = {
getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)),
replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<{ receiptId: string; authorityEpoch: number }>(r)),
body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<PolicyWriteReceipt>(r)),
explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)),
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).then(r => json<Project[]>(r)),
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
createScratchSession: (title?: string) => auth.fetch(`${BASE}/api/scratch-sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(title ? { title } : {}) }).then(r => json<{ project: Project; conversation: ConversationView }>(r)),
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<ConversationView[]>(r)),
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<ConversationView[]>(r)),
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
@@ -126,7 +240,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; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[] }>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
@@ -147,7 +261,8 @@ export const api = {
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
// isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。
createAdminUser: (owner: string, name: string, password?: string, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST' }).then(r => json<AdminIssuedOneTimePassword>(r)),
// 服务端严格解码 JSON 对象体:无 Content-Type 与请求体的 POST 会被拒为 400 EOF。
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<AdminIssuedOneTimePassword>(r)),
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
@@ -179,7 +294,7 @@ export const api = {
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)),
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)),
json<{ conversation: ConversationView; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
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 })
@@ -287,6 +402,19 @@ export const api = {
if (envelope && envelope.payload !== undefined) return envelope.payload
return out as T
}),
rdmAuditOperations: (projectId: string, query: RdmOperationQuery = {}) =>
api.rdmCommand<RdmOperationPage>('rdm.audit.operations', operationParams(projectId, query)).then(normalizeOperationPage),
rdmImpactAnalyze: (projectId: string, start: RdmImpactStart) =>
api.rdmCommand<RdmImpactAnalysis>('rdm.compliance.impact.analyze', { projectId, ...start }).then(normalizeImpact),
rdmImpactGet: (projectId: string, analysisId: string) =>
api.rdmCommand<RdmImpactAnalysis>('rdm.compliance.impact.get', { projectId, analysisId }).then(normalizeImpact),
rdmImpactRecompute: (projectId: string, start: RdmImpactStart) =>
api.rdmCommand<RdmImpactAnalysis>('rdm.compliance.impact.recompute', { projectId, ...start }).then(normalizeImpact),
rdmTracePendingRecheck: (projectId: string) =>
api.rdmCommand<{ edges?: RdmPendingRecheckEdge[] | null }>('rdm.trace.pending-recheck', { projectId }).then(r => r?.edges ?? []),
rdmTraceCoverage: (projectId: string) =>
api.rdmCommand<RdmTraceCoverage>('rdm.trace.coverage', { projectId })
.then(r => ({ confirmed: r?.confirmed ?? 0, candidate: r?.candidate ?? 0, gaps: r?.gaps ?? [] })),
}
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
+12
View File
@@ -137,6 +137,18 @@
.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); }
.conversation-work-required { display: grid; gap: 8px; min-width: 0; }
.conversation-candidate { display: grid; gap: 8px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); font-size: var(--fs-ui-sm); min-width: 0; }
.conversation-candidate-head { color: var(--text); font-weight: 600; overflow-wrap: anywhere; }
.conversation-candidate-change { display: grid; gap: 6px; margin: 0; }
.conversation-candidate-change > div { display: grid; grid-template-columns: 4.5em minmax(0, 1fr); gap: 8px; }
.conversation-candidate-change dt { color: var(--text-dim); font-size: var(--fs-ui-caption); }
.conversation-candidate-change dd { margin: 0; color: var(--text); overflow-wrap: anywhere; }
.conversation-candidate .acard-actions { margin-top: 0; }
.conversation-basis { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 9px 14px; border-bottom: 1px solid var(--border-soft); color: var(--text-dim); font-size: var(--fs-ui-caption); min-width: 0; }
.conversation-basis-label { color: var(--text); font-weight: 600; }
.conversation-basis-chip { padding: 2px 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--bg-panel); overflow-wrap: anywhere; }
.conversation-basis-warn { color: var(--warn); }
.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) {
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import type { PermissionDescriptor } from '../api'
import { GrantChangePreview, GrantGroupEditor, ProjectCreateSourceNote, toggleGrantGroup } from './AccountUsersPanel'
import { buildCapabilityGroups } from '../rolePermissions'
const descriptor = (actionId: string, label: string, group: string, scope: 'system' | 'project' = 'system'): PermissionDescriptor =>
({ actionId, label, description: '', group, scope, protected: scope === 'system' && actionId !== 'project.create' })
const catalog = [
descriptor('system.users.view', '查看用户', '用户管理'),
descriptor('system.users.create', '创建用户', '用户管理'),
descriptor('system.roles.view', '查看角色', '角色管理'),
descriptor('project.create', '创建项目', '项目入口'),
descriptor('project.members.manage', '管理项目成员', '项目管理', 'project'),
]
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
describe('CapabilityGroup explicit grants', () => {
it('grants system capabilities by group instead of one checkbox per action', () => {
const html = renderToStaticMarkup(<GrantGroupEditor catalog={catalog} grants={['system.users.view']} disabled={false} onChange={vi.fn()} />)
expect(html.match(/type="checkbox"/g)).toHaveLength(3)
expect(html).toContain('授予 用户管理')
expect(html).toContain('用户管理(部分)')
expect(html).toContain('联动本人创建项目的设置、成员、项目权限与文档职责管理')
expect(html).not.toContain('管理项目成员')
})
it('toggles every action of a group together', () => {
const users = buildCapabilityGroups(catalog.filter(item => item.scope === 'system')).find(group => group.id === 'catalog:用户管理')!
expect(toggleGrantGroup(['system.roles.view'], users, true)).toEqual(['system.roles.view', 'system.users.create', 'system.users.view'])
expect(toggleGrantGroup(['system.users.view', 'system.roles.view'], users, false)).toEqual(['system.roles.view'])
})
it('previews the grant difference with its source and the single affected user', () => {
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['project.create']} labels={labels} owner="bob" />)
expect(html).toContain('影响来源:明确系统授权 · 影响 1 名用户(bob)')
expect(html).toContain('新增:创建项目(联动本人创建项目的管理资格)')
expect(html).toContain('撤销:查看用户')
const revoke = renderToStaticMarkup(<GrantChangePreview baseline={['project.create']} grants={[]} labels={labels} owner="bob" />)
expect(revoke).toContain('已创建项目的管理资格不被反向删除')
})
})
describe('CreatorOwnerMemberDuties capability source', () => {
it('shows where an account gets project creation and what it links to', () => {
const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={role} grants={['project.create']} />))
.toContain('创建项目来源:职位角色「项目经理」 + 明确系统授权')
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={[]} />))
.toContain('被加入项目后由成员关系提供基础协作')
})
it('lists each project creation source separately when only one is present', () => {
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={{ name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }} grants={[]} />))
.toContain('创建项目来源:职位角色「项目经理」')
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={['project.create']} />))
.toContain('创建项目来源:明确系统授权')
})
})
describe('CapabilityGroup empty preview', () => {
it('renders a no-change preview when baseline equals grants', () => {
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['system.users.view']} labels={labels} owner="bob" />)
expect(html).toContain('系统授权没有变化')
})
it('still names the affected user when nothing visible changes', () => {
const html = renderToStaticMarkup(<GrantChangePreview baseline={[]} grants={[]} labels={labels} owner="bob" />)
expect(html).toContain('系统授权没有变化')
expect(html).not.toContain('影响来源')
})
})
+90 -9
View File
@@ -6,6 +6,9 @@ import { useStore } from '../store'
import { Collapsible, Icon } from '@ui'
import { hasSystemAction } from './RolePermissionsPanel'
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
import {
PROJECT_CREATE_ACTION, buildCapabilityGroups, projectCreateSources, rebaseGrantDraft, type CapabilityGroup, type GroupLevel,
} from '../rolePermissions'
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
@@ -45,6 +48,67 @@ export function AdminAppointmentButton({ me, account, busy, onChange }: { me: Id
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
}
function grantLevel(grants: string[], group: CapabilityGroup): GroupLevel {
const held = group.actions.filter(id => grants.includes(id)).length
return held === 0 ? 'off' : held === group.actions.length ? 'on' : 'mixed'
}
export function toggleGrantGroup(grants: string[], group: CapabilityGroup, checked: boolean): string[] {
return checked ? [...new Set([...grants, ...group.actions])].sort() : grants.filter(id => !group.actions.includes(id))
}
/** 明确系统授权按业务组授予;创建项目组说明联动形成的本人项目管理资格。 */
export function GrantGroupEditor({ catalog, grants, disabled, onChange }: {
catalog: PermissionDescriptor[]
grants: string[]
disabled: boolean
onChange: (grants: string[]) => void
}) {
const groups = buildCapabilityGroups(catalog.filter(item => item.scope === 'system'))
return <div className="rp-grants">{groups.map(group => {
const state = grantLevel(grants, group)
return <label className="ad-check" key={group.id} title={`内部动作 ${group.actions.join('、')}`}>
<input type="checkbox" aria-label={`授予 ${group.label}`} checked={state === 'on'} disabled={disabled}
onChange={event => onChange(toggleGrantGroup(grants, group, event.target.checked))} />
{group.label}{state === 'mixed' ? '(部分)' : ''}
{group.actions.includes(PROJECT_CREATE_ACTION) && <span className="ad-dim">:联动本人创建项目的设置、成员、项目权限与文档职责管理,不涉及他人项目</span>}
</label>
})}</div>
}
/** 授权差异预览:受影响来源为明确系统授权,影响人数为当前账号。 */
export function GrantChangePreview({ baseline, grants, labels, owner }: {
baseline: string[]; grants: string[]; labels: Record<string, string>; owner: string
}) {
const added = grants.filter(id => !baseline.includes(id))
const removed = baseline.filter(id => !grants.includes(id))
if (added.length === 0 && removed.length === 0) return <p className="ad-hint">系统授权没有变化</p>
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
return <div>
<p className="ad-hint" role="note">影响来源:明确系统授权 · 影响 1 名用户({owner})</p>
<ul className="rp-diff">
{added.length > 0 && <li>新增:{names(added)}{added.includes(PROJECT_CREATE_ACTION) ? '(联动本人创建项目的管理资格)' : ''}</li>}
{removed.length > 0 && <li>撤销:{names(removed)}{removed.includes(PROJECT_CREATE_ACTION) ? '(仅停止未来创建,已创建项目的管理资格不被反向删除)' : ''}</li>}
</ul>
</div>
}
/** 账号的创建能力来源与联动说明(角色默认与明确授权可并存)。 */
export function ProjectCreateSourceNote({ role, grants }: {
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[]
}) {
const sources = projectCreateSources(role, grants)
if (sources.length === 0) return <p className="ad-hint">未获得创建项目能力:被加入项目后由成员关系提供基础协作</p>
return <p className="ad-hint">创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格</p>
}
/** getUserRole 的角色摘要不带 permissions:用已加载角色列表里同一 roleId 的条目补齐,
* 列表里也没有时按无角色默认权限处理(不新增请求,也不改写已有权限)。 */
export function roleWithPermissions(role: UserRoleView['role'], roles: RoleRecord[]): UserRoleView['role'] {
if (!role || role.permissions) return role
return roles.find(item => item.roleId === role.roleId) ?? role
}
export function AccountUsersPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
@@ -63,6 +127,7 @@ export function AccountUsersPanel() {
const [roleLoading, setRoleLoading] = useState(false)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [grants, setGrants] = useState<string[]>([])
const [grantBaseline, setGrantBaseline] = useState<string[]>([])
const [authorityEpoch, setAuthorityEpoch] = useState(0)
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
const [newOwner, setNewOwner] = useState('')
@@ -81,6 +146,7 @@ export function AccountUsersPanel() {
const [err, setErr] = useState('')
const sequence = useRef(0)
const roleSequence = useRef(0)
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
const search = async (pageCursor = cursor, afterWrite = false) => {
const request = ++sequence.current
@@ -123,8 +189,9 @@ export function AccountUsersPanel() {
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
else setCatalog([])
if (grantsResult.status === 'fulfilled' && grantsResult.value) {
setGrants(grantsResult.value.actions ?? []); setAuthorityEpoch(grantsResult.value.authorityEpoch)
} else { setGrants([]); setAuthorityEpoch(0) }
const actions = [...(grantsResult.value.actions ?? [])].sort()
setGrants(actions); setGrantBaseline(actions); setAuthorityEpoch(grantsResult.value.authorityEpoch)
} else { setGrants([]); setGrantBaseline([]); setAuthorityEpoch(0) }
}
useEffect(() => {
@@ -187,10 +254,25 @@ export function AccountUsersPanel() {
const saveGrants = async () => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
const userId = selected.userId
try {
const result = await api.replaceSystemGrants(selected.userId, grants, authorityEpoch, crypto.randomUUID())
setAuthorityEpoch(result.authorityEpoch); setNotice('系统权限已保存')
} catch (error) { setErr(String(error).includes('final permission manager') ? '不能撤销最后一个有效权限管理者' : String(error)) }
const result = await api.replaceSystemGrants(userId, grants, authorityEpoch, crypto.randomUUID())
setAuthorityEpoch(result.authorityEpoch); setGrantBaseline(grants)
const expanded = result.linkedExpansion ?? []
setNotice(expanded.length ? `系统权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '系统权限已保存')
} catch (error) {
const raw = String(error)
if (raw.includes('final permission manager')) setErr('不能撤销最后一个有效权限管理者')
else if (raw.includes('409')) {
// 版本冲突:读取最新授权并重放本地增删,草稿不丢。
try {
const fresh = await api.getSystemGrants(userId)
const server = [...(fresh.actions ?? [])].sort()
setGrants(rebaseGrantDraft(grantBaseline, grants, server)); setGrantBaseline(server); setAuthorityEpoch(fresh.authorityEpoch)
setErr('系统权限版本冲突;已基于最新授权重放本地修改,请核对后再次保存')
} catch (refreshError) { setErr(`${String(refreshError)};本地草稿已保留`) }
} else setErr(`${raw.replace(/^Error:\s*/, '')};本地草稿已保留`)
}
finally { setBusy(false) }
}
@@ -251,11 +333,10 @@ export function AccountUsersPanel() {
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
</div>}
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleWithPermissions(roleView?.role, roles)} grants={grantBaseline} />}
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
<div className="rp-grants">{catalog.filter(item => item.scope === 'system').map(item => <label className="ad-check" key={item.actionId}>
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || !policy.maintain || busy} onChange={event => setGrants(current => event.target.checked
? [...new Set([...current, item.actionId])] : current.filter(value => value !== item.actionId))} />{item.label}<span className="ad-dim ad-mono">{item.actionId}</span>
</label>)}</div>
<GrantGroupEditor catalog={catalog} grants={grants} disabled={!canManageGrants || !policy.maintain || busy} onChange={setGrants} />
<GrantChangePreview baseline={grantBaseline} grants={grants} labels={labels} owner={selected.owner} />
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
</Collapsible>}
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
+26 -4
View File
@@ -9,7 +9,7 @@ import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline }
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 { docBridge, openPendingConfirmation, pendingConfirmations } from './Workbench'
import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { ConversationWork } from './ConversationWork'
@@ -335,15 +335,32 @@ function MessageTools({ msg, convId }: { msg: Message; convId: string }) {
)
}
/** 本次实际依据:只列来源名称与 token 估算,不展开正文(正文不随观测下发)。 */
export function BasisStrip({ obs }: { obs: ContextObs }) {
const sources = obs.sections && obs.sections.length > 0
? obs.sections.map(s => ({ key: s.name, label: `${s.name} · 约 ${s.estTokens} token` }))
: obs.items.map(it => ({ key: it, label: it }))
if (sources.length === 0) return null
return (
<div className="conversation-basis" data-conversation-basis aria-label="本次实际依据">
<span className="conversation-basis-label">本次依据</span>
{sources.map(s => <span key={s.key} className="conversation-basis-chip">{s.label}</span>)}
<span>共约 {obs.estChars} token</span>
{obs.truncations && obs.truncations.length > 0 && <span className="conversation-basis-warn">有 {obs.truncations.length} 处内容被裁剪</span>}
</div>
)
}
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
function ContextObsView({ obs }: { obs: ContextObs }) {
return (
<div className="process-collapse">
<BasisStrip obs={obs} />
<Collapsible id={`ctxobs:${obs.estChars}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>🧩 上下文构成</span>
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} token)</span>
<span className="process-collapse-meta">查看构成明细</span>
</>}>
<div className="body">
<ul className="ui-list">
@@ -551,6 +568,7 @@ function ConversationSession() {
// 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)
const pendingHere = pendingConfirmations(cards, currentConvId, currentProjectId)
useEffect(() => {
if (!stickRef.current) return
@@ -637,14 +655,14 @@ function ConversationSession() {
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</label>
<div className="companion-setting"><span>读取同项目的其他对话</span><button className="btn ghost"
{!conversation?.scratch && <div className="companion-setting"><span>读取同项目的其他对话</span><button className="btn ghost"
title={conversation?.allowCrossRead === false
? '本会话不读取其他会话记录(点击开启)'
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
{conversation?.allowCrossRead === false ? '关闭' : '允许'}
</button>
<span className="companion-setting-note">实际读取范围还需项目设置允许</span></div>
<span className="companion-setting-note">实际读取范围还需项目设置允许</span></div>}
<label className="companion-setting"><span>这次工作的要求</span><SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} /></label>
</Collapsible>
<span title={connected ? '已连接' : '正在重新连接'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
@@ -712,6 +730,10 @@ function ConversationSession() {
<div className="input-bar">
<OutboxPanel />
{!workPaneOwnsCards && pendingHere > 0 && <button type="button" className="work-result-notice" data-pending-confirm
onClick={() => openPendingConfirmation(cards, currentConvId, currentProjectId)}>
<Icon name="message" size={14} /><span>{pendingHere} 项待你确认</span><span>在工作栏处理 ↗</span>
</button>}
{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>}
+52 -2
View File
@@ -5,11 +5,58 @@ import { docBridge } from './Workbench'
import type { WorkCardAction } from '../contracts'
import { WorkExecutionRecord } from './WorkExecutionRecord'
interface CandidateChange { key: string; before?: string; after: string; impact?: string }
/** 候选差异与影响:只取服务端已给出的发现字段(corrects 为更正前,text 为候选,impact 为影响),不读取正文。 */
export function candidateChanges(card: ActivityCard): CandidateChange[] {
if (card.status !== 'action_required') return []
return (card.task?.findings ?? [])
.filter(f => f.status === 'active' && (f.corrects || f.impact))
.map(f => ({ key: `${f.id}:${f.revision}`, before: f.corrects, after: f.text, impact: f.impact }))
}
const CONFIRM_IDS = new Set(['confirm', 'accept', 'approve'])
const REJECT_IDS = new Set(['reject', 'decline', 'deny'])
/** 确认/拒绝动作:沿用卡片自带 actions;非工作卡的动作行已由 ActivityCardView 渲染,这里只补工作卡。 */
export function candidateActions(card: ActivityCard) {
if (!card.work || card.status !== 'action_required') return []
return (card.actions ?? []).filter(a => CONFIRM_IDS.has(a.id) || REJECT_IDS.has(a.id))
.sort((a, b) => Number(REJECT_IDS.has(a.id)) - Number(REJECT_IDS.has(b.id)))
}
function CandidateReview({ card, locked, onAction }: { card: ActivityCard; locked: boolean; onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => void }) {
const changes = candidateChanges(card)
const actions = candidateActions(card)
if (changes.length === 0 && actions.length === 0) return null
return (
<div className="conversation-candidate" data-candidate-card={card.id}>
<div className="conversation-candidate-head">候选变更 · {card.title}</div>
{changes.map(c => (
<dl key={c.key} className="conversation-candidate-change">
{c.before && <div><dt>原内容</dt><dd>{c.before}</dd></div>}
<div><dt>{c.before ? '候选' : '候选内容'}</dt><dd>{c.after}</dd></div>
{c.impact && <div><dt>影响</dt><dd>{c.impact}</dd></div>}
</dl>
))}
{actions.length > 0 && (
<div className="acard-actions">
{actions.map(a => (
<button key={a.id} type="button" className={`btn${REJECT_IDS.has(a.id) ? ' ghost' : ''}`} disabled={locked}
onClick={() => { if (!locked) onAction(card, a.id, { resultVersion: card.work!.resultVersion }) }}>{a.label}</button>
))}
</div>
)}
</div>
)
}
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
export function ConversationWork() {
const cards = useStore(s => s.cards)
const conversationId = useStore(s => s.currentConvId)
const projectId = useStore(s => s.currentProjectId)
const scratch = useStore(s => !!s.tasks.find(t => t.id === s.currentConvId)?.scratch)
const messages = useStore(s => s.messages)
const actionStates = useStore(s => s.cardActionStates)
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
@@ -56,7 +103,7 @@ export function ConversationWork() {
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
onLocate: (card: ActivityCard) => { store.locateMessage(card.anchorMessageId) },
onArchive: (card: ActivityCard) => { void store.cardToProject(card) },
...(scratch ? {} : { onArchive: (card: ActivityCard) => { void store.cardToProject(card) } }),
}
const view = (card: ActivityCard) => <ActivityCardView key={card.id} card={card} callbacks={callbacks} docBridge={docBridge} />
@@ -64,7 +111,10 @@ export function ConversationWork() {
<section className="conversation-work" data-conversation-work aria-label="工作进展">
<div className="conversation-work-heading"><span>工作进展</span><span>{summary || '已处理的工作'}</span></div>
<div className="conversation-work-current">
{required.map(view)}
{required.map(card => <div key={card.id} className="conversation-work-required">
<CandidateReview card={card} locked={actionStates?.[card.id] !== undefined} onAction={callbacks.onAction} />
{view(card)}
</div>)}
{focused && <Collapsible id={`conversation-current:${conversationId}:${focused.id}`} open={summaryOpen} onToggle={setSummaryOpen}
triggerClassName="conversation-work-more" trigger={<><span className="process-collapse-chev">▸</span>{focused.title} · 查看摘要</>}>
{view(focused)}
+391 -1
View File
@@ -1,6 +1,18 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { startLatestFetch, TreeNodeRow } from './RdmAuditWorkspace'
import { setPersistedOpen } from '@ui'
import type { RdmImpactAnalysis, RdmOperationPage, RdmOperationQuery, RdmOperationRecord } from '../api'
import {
classifyOperationsError, createOperationsFeed, describeOperation, diffImpact, isLegacyOperation,
OperationRow, OperationsListView, startLatestFetch, TraceCoverageView, TreeNodeRow, visibleAuditTabs,
type OperationsFeedState,
} from './RdmAuditWorkspace'
import type { ActivityCard } from '../contracts'
import { store } from '../store'
import { RDM_AUDIT_COMMANDS, RDM_AUDIT_OPERATIONS_COMMANDS, rdmAuditOperationsReady } from '../workspaces'
import { BasisStrip } from './Conversation'
import { candidateActions, candidateChanges } from './ConversationWork'
import { openPendingConfirmation, pendingConfirmations } from './Workbench'
function deferred<T>() {
let resolve!: (v: T) => void
@@ -82,3 +94,381 @@ describe('TreeNodeRow 无障碍属性', () => {
expect(html).not.toContain('rdm-audit-tree-caret')
})
})
// ---- W10 审计记录 ----
const NOT_RECORDED = { recorded: false, display: '当时未记录' }
const refs = (items: { key: string; label: string; id: string }[]) => ({
items: items.map(i => ({ kind: { key: i.key, label: i.label }, id: i.id })),
recorded: items.length > 0,
display: items.length > 0 ? `共 ${items.length} 项` : '当时未记录',
})
function record(id: string, patch: Partial<RdmOperationRecord> = {}): RdmOperationRecord {
return {
id,
operation: { key: 'domain.mutate', label: '领域变更' },
actor: { name: { value: '张三', recorded: true, display: '张三' }, duty: { value: '设计负责人', recorded: true, display: '设计负责人' } },
initiatedAt: '2026-10-20T08:00:00Z',
effectiveAt: { value: '2026-10-20T08:00:01Z', recorded: true, display: '2026-10-20T08:00:01Z' },
object: { recorded: true, kind: { key: 'section', label: '章节' }, id: 'S-1', display: '章节 S-1' },
change: {
before: { value: 1, recorded: true, display: 'r1' },
after: { value: 2, recorded: true, display: 'r2' },
summary: { value: '补充风险控制措施', recorded: true, display: '补充风险控制措施' },
disclosure: 'ok',
},
basis: { reason: { value: '评审意见', recorded: true, display: '评审意见' }, evidence: refs([{ key: 'slice', label: '笔记切片', id: 'SL-9' }]) },
outcome: { key: 'succeeded', label: '成功' },
impact: { direct: refs([{ key: 'doc', label: '文档', id: 'D-1' }]), indirect: refs([]), analyses: refs([{ key: 'impact-analysis', label: '影响分析', id: 'IA-1' }]) },
run: { recorded: true, runId: 'run-7', stepId: 'step-2', attempt: 1, display: '宿主可信回执' },
...patch,
}
}
function page(ids: string[], extra: Partial<RdmOperationPage> = {}): RdmOperationPage {
return {
items: ids.map(id => record(id)), hasMore: false, cutoffSeq: 42, total: ids.length,
dictionary: { types: [], outcomes: [], kinds: [] }, ...extra,
}
}
const READY: OperationsFeedState = {
phase: 'ready', items: [], nextCursor: '', hasMore: false, cutoffSeq: 42, total: 0,
dictionary: null, loadingMore: false, error: null,
}
const listHtml = (state: OperationsFeedState) =>
renderToStaticMarkup(<OperationsListView pid="p1" state={state} onMore={() => {}} onRestart={() => {}} />)
describe('classifyOperationsError', () => {
it('游标失效给出明确的从首页重查文案', () => {
const e = classifyOperationsError(new Error('操作记录游标已失效:游标绑定主体不符——请从首页重新查询'))
expect(e.kind).toBe('cursor')
expect(e.message).toContain('翻页位置已失效')
})
it('无审计授权与能力缺失分开呈现', () => {
expect(classifyOperationsError(new Error('权限拒绝:无项目审计授权')).kind).toBe('denied')
expect(classifyOperationsError(new Error('400: {"error":"unknown or disabled command"}')).kind).toBe('capability')
expect(classifyOperationsError(new Error('boom')).kind).toBe('query')
})
})
describe('createOperationsFeed(审计列表单一异步所有者)', () => {
it('首页加载后续页带回游标与截止水位并追加', async () => {
const calls: RdmOperationQuery[] = []
const feed = createOperationsFeed(async q => {
calls.push(q)
return q.cursor ? page(['b']) : page(['a'], { hasMore: true, nextCursor: 'c1', total: 2 })
})
feed.first()
await flush()
expect(feed.getState().items.map(r => r.id)).toEqual(['a'])
feed.more()
expect(feed.getState().loadingMore).toBe(true)
await flush()
expect(calls[1]).toEqual({ cursor: 'c1', cutoffSeq: 42 })
expect(feed.getState().items.map(r => r.id)).toEqual(['a', 'b'])
expect(feed.getState().hasMore).toBe(false)
})
it('按 seq 丢弃迟到响应:重查后旧请求的结果不写回', async () => {
const older = deferred<RdmOperationPage>()
const newer = deferred<RdmOperationPage>()
const queue = [older, newer]
const feed = createOperationsFeed(() => queue.shift()!.promise)
const seen = vi.fn()
feed.subscribe(seen)
feed.first()
feed.first()
newer.resolve(page(['new']))
await flush()
older.resolve(page(['old']))
await flush()
expect(feed.getState().items.map(r => r.id)).toEqual(['new'])
older.reject(new Error('late'))
await flush()
expect(feed.getState().error).toBeNull()
})
it('翻页途中重查:迟到的续页不混入新列表', async () => {
const more = deferred<RdmOperationPage>()
let n = 0
const feed = createOperationsFeed(q => {
n++
if (q.cursor) return more.promise
return Promise.resolve(n === 1 ? page(['a'], { hasMore: true, nextCursor: 'c1' }) : page(['fresh']))
})
feed.first()
await flush()
feed.more()
feed.first()
await flush()
more.resolve(page(['stale-b']))
await flush()
expect(feed.getState().items.map(r => r.id)).toEqual(['fresh'])
})
it('dispose 后在途响应被丢弃', async () => {
const pending = deferred<RdmOperationPage>()
const feed = createOperationsFeed(() => pending.promise)
feed.first()
feed.dispose()
pending.resolve(page(['late']))
await flush()
expect(feed.getState().phase).toBe('loading')
})
it('续页游标失效:保留已读记录并给出游标错误', async () => {
const feed = createOperationsFeed(async q => {
if (q.cursor) throw new Error('操作记录游标已失效:游标绑定项目不符')
return page(['a'], { hasMore: true, nextCursor: 'c1' })
})
feed.first()
await flush()
feed.more()
await flush()
const s = feed.getState()
expect(s.items.map(r => r.id)).toEqual(['a'])
expect(s.error?.kind).toBe('cursor')
expect(s.loadingMore).toBe(false)
})
})
describe('OperationsListView 状态文案', () => {
it('加载态', () => {
expect(listHtml({ ...READY, phase: 'loading' })).toContain('正在读取审计记录')
})
it('首页错误:无权限态可读且附原始回执', () => {
const html = listHtml({ ...READY, phase: 'error', error: classifyOperationsError(new Error('权限拒绝:x')) })
expect(html).toContain('你没有该项目的审计授权')
expect(html).toContain('原始回执')
expect(html).not.toContain('<details')
})
it('空列表', () => {
expect(listHtml(READY)).toContain('当前筛选下没有审计记录')
})
it('列表含业务摘要、计数与翻页按钮', () => {
const html = listHtml({ ...READY, items: [record('a')], total: 3, hasMore: true, nextCursor: 'c1' })
expect(html).toContain('张三')
expect(html).toContain('领域变更')
expect(html).toContain('章节 S-1')
expect(html).toContain('已显示 1 / 共 3 条 · 截至水位 42')
expect(html).toContain('加载更多记录')
})
it('续页游标失效:显示错误与从第一页重新查询,不再提供续页按钮', () => {
const html = listHtml({ ...READY, items: [record('a')], hasMore: true, nextCursor: 'c1', error: classifyOperationsError(new Error('操作记录游标已失效')) })
expect(html).toContain('翻页位置已失效')
expect(html).toContain('从第一页重新查询')
expect(html).not.toContain('加载更多记录')
})
})
describe('OperationRow 展开内容', () => {
const row = (r: RdmOperationRecord) => renderToStaticMarkup(<OperationRow pid="p1" record={r} defaultOpen />)
it('字段差异、来源/确认链、当时影响与证据跳转入口', () => {
const html = row(record('a'))
expect(html).toContain('字段差异')
expect(html).toContain('r1')
expect(html).toContain('r2')
expect(html).toContain('补充风险控制措施')
expect(html).toContain('来源与确认链')
expect(html).toContain('评审意见')
expect(html).toContain('运行 run-7')
expect(html).toContain('查看证据:笔记切片 SL-9')
expect(html).toContain('文档 D-1')
expect(html).toContain('查看影响分析 IA-1')
})
it('人物以当时名称为主,当前名称只带「当前」标记', () => {
const r = record('a', { actor: { name: { value: '张三', recorded: true, display: '张三' }, duty: NOT_RECORDED, current: { value: '张三丰', marker: '当前' } } })
const v = describeOperation(r)
expect(v.who).toBe('张三')
expect(v.whoNow).toBe('当前:张三丰')
expect(row(r)).toContain('当前:张三丰')
})
it('旧数据:缺项显示「当时未记录」并标旧记录', () => {
const r = record('a', { basis: { reason: NOT_RECORDED, evidence: refs([]) }, change: { ...record('a').change, before: NOT_RECORDED } })
expect(isLegacyOperation(r)).toBe(true)
const html = row(r)
expect(html).toContain('旧记录')
expect(html).toContain('当时未记录')
})
it('无正文权:只显示服务端给出的「受限」元数据', () => {
const r = record('a', { change: { ...record('a').change, summary: { recorded: true, display: '受限' }, disclosure: 'restricted' } })
const html = row(r)
expect(html).toContain('正文受限')
expect(html).toContain('受限')
expect(html).not.toContain('补充风险控制措施')
})
it('长对象名可换行(不截断)', () => {
const long = '章节 ' + 'X'.repeat(200)
const html = row(record('a', { object: { recorded: true, kind: { key: 'section', label: '章节' }, id: 'X', display: long } }))
expect(html).toContain(long)
expect(html).toContain('class="rdm-audit-op-object"')
expect(html).toContain('rdm-audit-event-summary rdm-audit-wrap')
})
})
describe('证据与影响分析折叠(@ui Collapsible,稳定 id)', () => {
const row = () => renderToStaticMarkup(<OperationRow pid="p1" record={record('a')} defaultOpen />)
const trigger = (expanded: boolean, text: string) =>
new RegExp(`<button type="button" class="mk-col-trigger\\s*" aria-expanded="${expanded}"><span class="rdm-audit-err-toggle rdm-audit-wrap">${text}</span></button>`)
it('默认收起:触发钮由 Collapsible 渲染,详情未挂载(不发起读取)', () => {
const html = row()
expect(html).toMatch(trigger(false, '查看证据:笔记切片 SL-9'))
expect(html).toMatch(trigger(false, '查看影响分析 IA-1'))
expect(html).not.toContain('btn v-ghost rdm-audit-wrap')
expect(html).not.toContain('加载中…')
})
it('展开态按稳定 id 记忆:重挂载后恢复展开并挂载详情', () => {
setPersistedOpen('rdm-audit-evidence:p1/slice/SL-9', true)
setPersistedOpen('rdm-audit-impact:p1/IA-1', true)
try {
const html = row()
expect(html).toMatch(trigger(true, '查看证据:笔记切片 SL-9'))
expect(html).toMatch(trigger(true, '查看影响分析 IA-1'))
expect(html.match(/加载中…/g)).toHaveLength(2)
} finally {
setPersistedOpen('rdm-audit-evidence:p1/slice/SL-9', false)
setPersistedOpen('rdm-audit-impact:p1/IA-1', false)
}
})
})
describe('审计记录页签能力门禁', () => {
const enabled = (ids: readonly string[]) => ids.map(id => ({ id, enabled: true }))
it('五条 W8b/W5 命令齐备且启用才就绪;缺一、停用或清单不可用均不就绪', () => {
expect(RDM_AUDIT_OPERATIONS_COMMANDS).toHaveLength(5)
const all = enabled(RDM_AUDIT_OPERATIONS_COMMANDS)
expect(rdmAuditOperationsReady(all)).toBe(true)
for (const missing of RDM_AUDIT_OPERATIONS_COMMANDS) {
expect(rdmAuditOperationsReady(all.filter(c => c.id !== missing))).toBe(false)
expect(rdmAuditOperationsReady(all.map(c => (c.id === missing ? { ...c, enabled: false } : c)))).toBe(false)
}
expect(rdmAuditOperationsReady(null)).toBe(false)
})
it('只有 rdm.inspect.* 七命令的构建上不就绪', () => {
expect(rdmAuditOperationsReady(enabled(RDM_AUDIT_COMMANDS))).toBe(false)
})
it('能力缺失时不暴露「审计记录」页签,其余页签不受影响', () => {
expect(visibleAuditTabs(false).map(t => t.label)).toEqual(['概览', '资料与文档', '关联追溯', '操作记录'])
expect(visibleAuditTabs(true).map(t => t.label)).toEqual(['概览', '资料与文档', '关联追溯', '操作记录', '审计记录'])
})
})
describe('TraceCoverageView 部分覆盖', () => {
it('候选不计正式覆盖;缺项与待复核提示部分覆盖', () => {
const html = renderToStaticMarkup(<TraceCoverageView pid="p1" data={{
coverage: { confirmed: 3, candidate: 2, gaps: [{ rule: 4, node: 'UN-1@1', reason: '缺执行结果' }] },
pending: [{ kind: 'verifies', from: 'SR:1', to: 'VR:2', fromOldRevision: '1', fromNewRevision: '2', toOldRevision: '1', toNewRevision: '', state: 'pending-recheck', source: 'tracenet', edgeId: 'e1' }],
}} />)
expect(html).toContain('正式覆盖')
expect(html).toContain('候选(不计正式覆盖)')
expect(html).toContain('部分覆盖')
expect(html).toContain('缺项链(1)')
expect(html).toContain('待复核受控边(1)')
})
it('全部确认时不提示部分覆盖', () => {
const html = renderToStaticMarkup(<TraceCoverageView pid="p1" data={{ coverage: { confirmed: 5, candidate: 0, gaps: [] }, pending: [] }} />)
expect(html).not.toContain('部分覆盖')
})
})
describe('diffImpact(当时与当前影响)', () => {
const analysis = (involved: string[]): RdmImpactAnalysis => ({
id: 'x', start: { kind: 'UR', id: '1', revision: '1' }, direct: [], layers: [],
involved: involved.map(id => ({ kind: 'SR', id })), truncated: false, maxNodes: 100, maxDepth: 5, at: '2026-10-20T00:00:00Z',
})
it('按 kind:id 比较新增与不再涉及', () => {
const d = diffImpact(analysis(['a', 'b']), analysis(['b', 'c']))
expect(d.added.map(r => r.id)).toEqual(['c'])
expect(d.removed.map(r => r.id)).toEqual(['a'])
})
})
describe('对话侧:候选差异、确认路径与依据条', () => {
const card = (over: Partial<ActivityCard>): ActivityCard => ({
id: 'c1', projectId: 'p1', conversationId: 'v1', kind: 'progress', title: '追溯候选', status: 'action_required',
createdAt: '2026-10-20T00:00:00Z', updatedAt: '2026-10-20T00:00:00Z', ...over,
})
const finding = (over: Record<string, unknown>) => ({ id: 'f1', text: '需求 UR-1 由 SR-2 覆盖', kind: 'analysis', status: 'active', revision: 1, sources: [], ...over })
const task = (findings: unknown[]) => ({ findings } as unknown as ActivityCard['task'])
it('候选差异只取服务端发现字段:更正前、候选与影响', () => {
const changes = candidateChanges(card({ task: task([
finding({ corrects: '需求 UR-1 无覆盖', impact: '验证 VR-3 需复核' }),
finding({ id: 'f2', status: 'superseded', corrects: '旧' }),
finding({ id: 'f3' }),
]) }))
expect(changes).toEqual([{ key: 'f1:1', before: '需求 UR-1 无覆盖', after: '需求 UR-1 由 SR-2 覆盖', impact: '验证 VR-3 需复核' }])
})
it('非待处理卡不呈现候选差异', () => {
expect(candidateChanges(card({ status: 'done', task: task([finding({ impact: 'x' })]) }))).toEqual([])
})
it('确认/拒绝只为工作卡补齐(非工作卡动作行由卡片自身渲染),确认在前', () => {
const actions = [{ id: 'reject', label: '拒绝' }, { id: 'confirm', label: '确认' }, { id: 'recheck', label: '重新检查' }]
expect(candidateActions(card({ actions }))).toEqual([])
const work = { resultVersion: 2, readVersion: 2 }
expect(candidateActions(card({ actions, work })).map(a => a.id)).toEqual(['confirm', 'reject'])
expect(candidateActions(card({ actions, work, status: 'resolved' }))).toEqual([])
})
it('待确认计数只算本会话待处理卡,供右栏收起后的对话确认路径', () => {
const cards = [
card({ id: 'a' }),
card({ id: 'b', status: 'active', work: { resultVersion: 1, readVersion: 1, decision: { mode: 'single', state: 'open' } } }),
card({ id: 'c', conversationId: 'other' }),
card({ id: 'd', status: 'done' }),
]
expect(pendingConfirmations(cards, 'v1', 'p1')).toBe(2)
expect(pendingConfirmations(cards, 'v1', 'p2')).toBe(0)
})
it('右栏收起后的确认路径经 openInbox 定位首张待处理卡,与计数同一谓词(含决议开放的工作卡)', () => {
const openInbox = vi.spyOn(store, 'openInbox').mockImplementation(() => {})
try {
const decisionOpen = { resultVersion: 1, readVersion: 1, decision: { mode: 'single' as const, state: 'open' as const } }
const cards = [
card({ id: 'dismissed', status: 'dismissed' }),
card({ id: 'elsewhere', conversationId: 'other' }),
card({ id: 'decision', status: 'active', work: decisionOpen }),
card({ id: 'confirm' }),
]
expect(pendingConfirmations(cards, 'v1', 'p1')).toBe(2)
openPendingConfirmation(cards, 'v1', 'p1')
expect(openInbox).toHaveBeenLastCalledWith('decision')
openPendingConfirmation(cards.filter(c => c.id !== 'decision'), 'v1', 'p1')
expect(openInbox).toHaveBeenLastCalledWith('confirm')
} finally {
openInbox.mockRestore()
}
})
it('依据条列出来源与 token 摘要,不含正文', () => {
const html = renderToStaticMarkup(<BasisStrip obs={{
items: ['项目知识', '对话历史'], estChars: 1800, truncations: ['历史已裁剪'],
sections: [{ name: '项目知识', estTokens: 1200 }, { name: '对话历史', estTokens: 600 }],
}} />)
expect(html).toContain('本次依据')
expect(html).toContain('项目知识 · 约 1200 token')
expect(html).toContain('共约 1800 token')
expect(html).toContain('有 1 处内容被裁剪')
expect(html).not.toContain('历史已裁剪')
})
it('无分节时退回条目名;无来源时不渲染', () => {
expect(renderToStaticMarkup(<BasisStrip obs={{ items: ['系统提示'], estChars: 10 }} />)).toContain('系统提示')
expect(renderToStaticMarkup(<BasisStrip obs={{ items: [], estChars: 0 }} />)).toBe('')
})
})
+453 -9
View File
@@ -1,8 +1,12 @@
// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。
// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import {
api,
type RdmImpactAnalysis, type RdmObjectRef, type RdmOperationDictionary, type RdmOperationPage, type RdmOperationQuery,
type RdmOperationRecord, type RdmOperationRef, type RdmOperationRefs, type RdmPendingRecheckEdge, type RdmTraceCoverage,
} from '../api'
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
import {
@@ -45,9 +49,15 @@ const TABS = [
{ id: 'materials', label: '资料与文档' },
{ id: 'trace', label: '关联追溯' },
{ id: 'timeline', label: '操作记录' },
{ id: 'operations', label: '审计记录' },
] as const
type TabId = (typeof TABS)[number]['id']
/** 「审计记录」页签及其覆盖面板、影响分析入口只在 W8b/W5 命令面就绪时暴露(store.rdmAuditOperationsAvailable)。 */
export function visibleAuditTabs(operationsAvailable: boolean) {
return TABS.filter(t => t.id !== 'operations' || operationsAvailable)
}
const OPERATION_FILTERS = [
['', '全部操作'],
['doc.instantiate', '创建文档实例'],
@@ -102,6 +112,8 @@ export function RdmAuditWorkspace() {
const project = caps?.projects.find(p => p.projectId === pid)
const exportOn = useStore(s => s.rdmExportAvailable)
const tabs = visibleAuditTabs(useStore(s => s.rdmAuditOperationsAvailable))
const activeTab: TabId = tabs.some(t => t.id === tab) ? tab : 'summary'
if (error) return <RdmErrorState raw={error} />
if (!caps || capsIdentity !== identity) return <div className="empty-note">加载中…</div>
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
@@ -118,17 +130,18 @@ export function RdmAuditWorkspace() {
{caps.crossProject && <Chip tone="info">跨项目</Chip>}
</div>
<div className="tabs rdm-audit-tabs" role="tablist">
{TABS.map(t => (
<button key={t.id} role="tab" aria-selected={tab === t.id}
className={`tab${tab === t.id ? ' active' : ''}`}
{tabs.map(t => (
<button key={t.id} role="tab" aria-selected={activeTab === t.id}
className={`tab${activeTab === t.id ? ' active' : ''}`}
onClick={() => setTab(t.id)}>{t.label}</button>
))}
</div>
<div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
{tab === 'materials' && <MaterialsTab key={pid} pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />}
{activeTab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
{activeTab === 'materials' && <MaterialsTab key={pid} pid={pid} />}
{activeTab === 'trace' && <TraceTab pid={pid} />}
{activeTab === 'timeline' && <TimelineTab pid={pid} />}
{activeTab === 'operations' && <OperationsTab key={pid} pid={pid} />}
</div>
</div>
)
@@ -582,6 +595,7 @@ function TreeList({ pid, kind, onOpenDoc }: { pid: string; kind: 'notes' | 'docs
)
}
// TODO(W10):知识多挂载位置暂不显示——rdm.inspect.tree/object DTO 尚无挂载位置字段,待 RDM 增补后在本行与对象详情跟进。
export function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore, onOpenDoc }: {
item: TreeItem
selectedId?: string
@@ -918,3 +932,433 @@ function CoverageList({ cov }: { cov: CoverageItem[] }) {
</div>
)
}
// ---- 审计记录(W10:rdm.audit.operations 正式审计列表 + W5 影响/覆盖/待复核) ----
// 只渲染服务端已裁剪的 DTO:「当时未记录」「当前」「受限」等文案由服务端给出,页面不补正文。
const fmtTime = (s?: string) => {
if (!s) return '—'
const d = new Date(s)
return Number.isNaN(d.getTime()) ? s : d.toLocaleString('zh-CN')
}
export interface OperationsFeedError { kind: 'cursor' | 'denied' | 'capability' | 'auth' | 'query'; message: string; raw: string }
export function classifyOperationsError(err: unknown): OperationsFeedError {
const raw = err instanceof Error ? err.message : String(err)
if (/游标已失效|游标不合法/.test(raw)) {
return { kind: 'cursor', message: '翻页位置已失效:项目、登录身份或授权、筛选条件或截止水位已变化,不能接着上一页继续读。请从第一页重新查询。', raw }
}
if (/权限拒绝/.test(raw)) {
return { kind: 'denied', message: '你没有该项目的审计授权,审计记录不可查看。如需查看,请联系项目创建者或域管理员授权。', raw }
}
const c = classifyRdmError(raw)
return { kind: c.kind, message: c.message, raw }
}
export interface OperationsFeedState {
phase: 'loading' | 'ready' | 'error'
items: RdmOperationRecord[]
nextCursor: string
hasMore: boolean
cutoffSeq: number
total: number
dictionary: RdmOperationDictionary | null
loadingMore: boolean
error: OperationsFeedError | null
}
const FEED_LOADING: OperationsFeedState = {
phase: 'loading', items: [], nextCursor: '', hasMore: false, cutoffSeq: 0, total: 0,
dictionary: null, loadingMore: false, error: null,
}
function appendUnique(prev: RdmOperationRecord[], next: RdmOperationRecord[]) {
const seen = new Set(prev.map(r => r.id))
return [...prev, ...next.filter(r => !seen.has(r.id))]
}
/** 审计列表的唯一异步状态所有者:每次请求取新 seq,非最新 seq 的响应(含失败)一律丢弃。
* 续页带回首页的 cutoffSeq,保证翻页期间新写入不让已翻过的页面漂移。 */
export function createOperationsFeed(fetchPage: (q: RdmOperationQuery) => Promise<RdmOperationPage>) {
let seq = 0
let state = FEED_LOADING
const listeners = new Set<() => void>()
const set = (next: OperationsFeedState) => {
state = next
for (const l of listeners) l()
}
const request = (
q: RdmOperationQuery,
onPage: (p: RdmOperationPage) => OperationsFeedState,
onError: (e: OperationsFeedError) => OperationsFeedState,
) => {
const mine = ++seq
fetchPage(q).then(
p => { if (mine === seq) set(onPage(p)) },
e => { if (mine === seq) set(onError(classifyOperationsError(e))) },
)
}
return {
getState: () => state,
subscribe: (l: () => void) => {
listeners.add(l)
return () => { listeners.delete(l) }
},
first: () => {
set(FEED_LOADING)
request({},
p => ({ ...FEED_LOADING, phase: 'ready', items: p.items, nextCursor: p.nextCursor || '', hasMore: p.hasMore, cutoffSeq: p.cutoffSeq, total: p.total, dictionary: p.dictionary }),
error => ({ ...FEED_LOADING, phase: 'error', error }))
},
more: () => {
if (state.phase !== 'ready' || state.loadingMore || !state.hasMore || !state.nextCursor) return
set({ ...state, loadingMore: true, error: null })
request({ cursor: state.nextCursor, cutoffSeq: state.cutoffSeq },
p => ({ ...state, loadingMore: false, items: appendUnique(state.items, p.items), nextCursor: p.nextCursor || '', hasMore: p.hasMore, total: p.total }),
error => ({ ...state, loadingMore: false, error }))
},
dispose: () => { seq++ },
}
}
const OUTCOME_TONE: Record<string, 'ok' | 'warn' | 'danger' | 'plain'> = {
succeeded: 'ok', rejected: 'danger', failed: 'danger', no_effect: 'plain', unknown_delay: 'warn',
}
/** 旧记录:服务端以 recorded=false 标出的缺项(显示「当时未记录」,不代表为空)。 */
export function isLegacyOperation(r: RdmOperationRecord): boolean {
return !r.actor.name.recorded || !r.object.recorded || !r.change.before.recorded || !r.change.after.recorded
|| !r.change.summary.recorded || !r.basis.reason.recorded
}
/** 业务中文摘要:当时名称为主;「当前」名称只作附注,不冒充历史。 */
export function describeOperation(r: RdmOperationRecord) {
const who = r.actor.name.display
const whoNow = r.actor.current && r.actor.current.value !== r.actor.name.value
? `${r.actor.current.marker}:${r.actor.current.value}` : ''
const object = r.object.display
const objectNow = r.object.current ? `${r.object.current.marker}:${r.object.current.value}` : ''
const when = fmtTime(r.initiatedAt)
return {
who, whoNow, object, objectNow, when,
action: r.operation.label,
outcome: r.outcome.label,
sentence: `${who} 于 ${when} 对「${object}」执行「${r.operation.label}」,结果:${r.outcome.label}`,
}
}
const refsText = (refs: RdmOperationRefs) =>
refs.recorded && refs.items.length > 0 ? refs.items.map(x => `${x.kind.label} ${x.id}`).join('、') : refs.display
function OperationsTab({ pid }: { pid: string }) {
const identity = useStore(() => store.getIdentityEpoch())
const [outcome, setOutcome] = useState('')
const [type, setType] = useState('')
const [dict, setDict] = useState<RdmOperationDictionary | null>(null)
const feed = useMemo(() => createOperationsFeed(q => api.rdmAuditOperations(pid, {
...q, limit: 30, outcomes: outcome ? [outcome] : undefined, types: type ? [type] : undefined,
})), [pid, outcome, type, identity])
useEffect(() => {
feed.first()
return feed.dispose
}, [feed])
const state = useSyncExternalStore(feed.subscribe, feed.getState, feed.getState)
useEffect(() => { if (state.dictionary) setDict(state.dictionary) }, [state.dictionary])
return (
<div className="rdm-audit-timeline">
<TraceCoveragePanel pid={pid} />
<SectionLabel>审计记录</SectionLabel>
<div className="rdm-audit-timeline-filters">
<select className="ui-input" aria-label="按结果筛选" value={outcome} onChange={e => setOutcome(e.target.value)}>
<option value="">全部结果</option>
{dict?.outcomes.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
</select>
<select className="ui-input" aria-label="按操作类型筛选" value={type} onChange={e => setType(e.target.value)}>
<option value="">全部类型</option>
{dict?.types.map(t => <option key={t.key} value={t.key}>{t.label}</option>)}
</select>
</div>
<OperationsListView pid={pid} state={state} onMore={feed.more} onRestart={feed.first} />
</div>
)
}
export function OperationsListView({ pid, state, onMore, onRestart }: {
pid: string
state: OperationsFeedState
onMore: () => void
onRestart: () => void
}) {
if (state.phase === 'loading') return <div className="empty-note">正在读取审计记录…</div>
if (state.phase === 'error' && state.error) return <OperationsErrorView error={state.error} onRestart={onRestart} />
return (
<div className="rdm-audit-ops">
{state.items.length === 0 && <EmptyState icon="clock">当前筛选下没有审计记录</EmptyState>}
{state.items.map(r => <OperationRow key={r.id} pid={pid} record={r} />)}
{state.items.length > 0 && (
<div className="rdm-audit-meta">已显示 {state.items.length} / 共 {state.total} 条 · 截至水位 {state.cutoffSeq}</div>
)}
{state.error && <OperationsErrorView error={state.error} onRestart={onRestart} />}
{state.hasMore && !state.error && (
<button type="button" className="btn v-ghost rdm-audit-more" disabled={state.loadingMore} onClick={onMore}>
{state.loadingMore ? '正在加载…' : '加载更多记录'}
</button>
)}
</div>
)
}
function OperationsErrorView({ error, onRestart }: { error: OperationsFeedError; onRestart: () => void }) {
return (
<div className="rdm-audit-err" role="alert">
<EmptyState icon={error.kind === 'denied' ? 'shield' : 'alert'}>{error.message}</EmptyState>
{(error.kind === 'cursor' || error.kind === 'query') && (
<button type="button" className="btn v-ghost" onClick={onRestart}>{error.kind === 'cursor' ? '从第一页重新查询' : '重试'}</button>
)}
<Collapsible id="rdm-audit-ops-err-receipt" trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
<code className="rdm-audit-err-raw">{error.raw}</code>
</Collapsible>
</div>
)
}
export function OperationRow({ pid, record, defaultOpen }: { pid: string; record: RdmOperationRecord; defaultOpen?: boolean }) {
const view = describeOperation(record)
const legacy = isLegacyOperation(record)
const restricted = record.change.disclosure === 'restricted'
const { actor, change, basis, impact, run, effectiveAt } = record
return (
<div className="rdm-audit-opgroup rdm-audit-op" data-operation-id={record.id}>
<Collapsible id={`rdm-audit-op-${pid}-${record.id}`} defaultOpen={defaultOpen} trigger={(
<span className="rdm-audit-opgroup-head">
<span className="rdm-audit-opgroup-actor">{view.who}</span>
{view.whoNow && <Chip tone="plain">{view.whoNow}</Chip>}
<span className="rdm-audit-opgroup-action">{view.action}</span>
<span className="rdm-audit-op-object">{view.object}</span>
{view.objectNow && <Chip tone="plain">{view.objectNow}</Chip>}
<span className="rdm-audit-opgroup-time">{view.when}</span>
<Chip tone={OUTCOME_TONE[record.outcome.key] ?? 'plain'}>{view.outcome}</Chip>
{legacy && <Chip tone="warn">旧记录</Chip>}
{restricted && <Chip tone="plain">正文受限</Chip>}
</span>
)}>
<div className="rdm-audit-event">
<div className="rdm-audit-event-summary rdm-audit-wrap">{view.sentence}</div>
{legacy && <div className="rdm-audit-truncated">这是旧记录:标为「当时未记录」的字段在操作发生时没有记录,不代表为空或为零。</div>}
{restricted && <div className="rdm-audit-truncated">正文受限:你当前没有该对象的正文权(跨项目、无权或已撤权),这里只显示元数据。</div>}
<SectionLabel>字段差异</SectionLabel>
<dl className="rdm-audit-event-fields">
<div><dt>修改前</dt><dd>{change.before.display}</dd></div>
<div><dt>修改后</dt><dd>{change.after.display}</dd></div>
<div><dt>差异摘要</dt><dd>{change.summary.display}</dd></div>
</dl>
<SectionLabel>来源与确认链</SectionLabel>
<dl className="rdm-audit-event-fields">
<div><dt>操作人(当时)</dt><dd>{actor.name.display}{actor.duty.recorded ? ` · ${actor.duty.display}` : ''}{view.whoNow ? `(${view.whoNow})` : ''}</dd></div>
<div><dt>依据原因</dt><dd>{basis.reason.display}</dd></div>
<div><dt>执行回执</dt><dd>{run.display}{run.runId ? ` · 运行 ${run.runId}` : ''}{run.stepId ? ` · 步骤 ${run.stepId}` : ''}{run.attempt ? ` · 第 ${run.attempt} 次尝试` : ''}</dd></div>
<div><dt>发起时间</dt><dd>{view.when}</dd></div>
<div><dt>生效时间</dt><dd>{effectiveAt.recorded && effectiveAt.value ? fmtTime(effectiveAt.value) : effectiveAt.display}</dd></div>
<div><dt>对象路径</dt><dd>{view.object}{view.objectNow ? `(${view.objectNow})` : ''}</dd></div>
</dl>
<EvidenceList pid={pid} refs={basis.evidence} />
<SectionLabel>影响(当时与当前)</SectionLabel>
<dl className="rdm-audit-event-fields">
<div><dt>直接影响(当时)</dt><dd>{refsText(impact.direct)}</dd></div>
<div><dt>间接影响(当时)</dt><dd>{refsText(impact.indirect)}</dd></div>
<div><dt>受控影响分析</dt><dd>{impact.analyses.display}</dd></div>
</dl>
{impact.analyses.items.map(a => <ImpactAnalysisBlock key={a.id} pid={pid} analysisId={a.id} />)}
</div>
</Collapsible>
</div>
)
}
function EvidenceList({ pid, refs }: { pid: string; refs: RdmOperationRefs }) {
return (
<div className="rdm-audit-detail-body">
<SectionLabel>依据证据</SectionLabel>
{refs.items.length === 0
? <div className="empty-note">{refs.display}</div>
: refs.items.map(r => <EvidenceLink key={`${r.kind.key}:${r.id}`} pid={pid} evidence={r} />)}
</div>
)
}
/** 证据跳转:展开时才读取对象详情;服务端按当前正文权判定,无权时只给出可读拒绝,不假设有正文。 */
function EvidenceLink({ pid, evidence }: { pid: string; evidence: RdmOperationRef }) {
const label = `${evidence.kind.label} ${evidence.id}`
return (
<Collapsible id={`rdm-audit-evidence:${pid}/${evidence.kind.key}/${evidence.id}`}
trigger={<span className="rdm-audit-err-toggle rdm-audit-wrap">{`查看证据:${label}`}</span>}>
<EvidenceDetail pid={pid} evidence={evidence} label={label} />
</Collapsible>
)
}
function EvidenceDetail({ pid, evidence, label }: { pid: string; evidence: RdmOperationRef; label: string }) {
const [detail, setDetail] = useState<ObjectDetail | null>(null)
const [err, setErr] = useState('')
const genRef = useRef(0)
useEffect(() => {
setDetail(null); setErr('')
return startLatestFetch(genRef,
() => api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: evidence.kind.key, id: evidence.id }),
setDetail, setErr)
}, [pid, evidence.kind.key, evidence.id])
if (err) {
return /权限拒绝|无权|受限/.test(err)
? <div className="rdm-audit-truncated">无权查看该证据(跨项目、无权或已撤权),仅保留元数据:{label}</div>
: <RdmErrorState raw={err} />
}
return !detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />
}
const refKey = (r: RdmObjectRef) => `${r.kind}:${r.id}`
const refLabel = (r: RdmObjectRef) => `${r.kind} ${r.id}${r.revision ? ` r${r.revision}` : ''}`
/** 当时快照与按当前受控图重算结果的差异(按对象 kind:id 比较)。 */
export function diffImpact(then: RdmImpactAnalysis, now: RdmImpactAnalysis) {
const thenKeys = new Set(then.involved.map(refKey))
const nowKeys = new Set(now.involved.map(refKey))
return {
added: now.involved.filter(r => !thenKeys.has(refKey(r))),
removed: then.involved.filter(r => !nowKeys.has(refKey(r))),
}
}
function ImpactSnapshotView({ title, a }: { title: string; a: RdmImpactAnalysis }) {
return (
<div className="rdm-audit-detail-body">
<SectionLabel>{title}</SectionLabel>
<div className="rdm-audit-wrap">起点 {refLabel(a.start)} · 分析 {a.id} · {fmtTime(a.at)}{a.actor ? ` · ${a.actor}` : ''}</div>
<div>直接影响 {a.direct.length} 项 · 共涉及 {a.involved.length} 项 · {a.layers.length} 层</div>
{a.direct.length > 0 && <div className="rdm-audit-wrap">直接:{a.direct.map(refLabel).join('、')}</div>}
{a.truncated && <div className="rdm-audit-truncated">部分覆盖:已达上限({a.maxNodes} 个对象 / {a.maxDepth} 层)截断,结果不完整</div>}
</div>
)
}
/** 当时与当前影响:展开时才读取不可变快照;重算须显式触发(生成新分析快照,不覆盖历史)。 */
function ImpactAnalysisBlock({ pid, analysisId }: { pid: string; analysisId: string }) {
return (
<div className="rdm-audit-detail-body">
<Collapsible id={`rdm-audit-impact:${pid}/${analysisId}`}
trigger={<span className="rdm-audit-err-toggle rdm-audit-wrap">{`查看影响分析 ${analysisId}`}</span>}>
<ImpactAnalysisDetail pid={pid} analysisId={analysisId} />
</Collapsible>
</div>
)
}
function ImpactAnalysisDetail({ pid, analysisId }: { pid: string; analysisId: string }) {
const [snapshot, setSnapshot] = useState<RdmImpactAnalysis | null>(null)
const [current, setCurrent] = useState<RdmImpactAnalysis | null>(null)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const snapRef = useRef(0)
const currentRef = useRef(0)
useEffect(() => {
setSnapshot(null); setErr('')
return startLatestFetch(snapRef, () => api.rdmImpactGet(pid, analysisId), setSnapshot, setErr)
}, [pid, analysisId])
useEffect(() => () => { currentRef.current++ }, [pid, analysisId])
const revision = Number(snapshot?.start.revision)
const recompute = () => {
if (!snapshot || !(revision > 0)) return
setBusy(true); setErr('')
startLatestFetch(currentRef,
() => api.rdmImpactRecompute(pid, { startKind: snapshot.start.kind, startId: snapshot.start.id, startRevision: revision, maxNodes: snapshot.maxNodes, maxDepth: snapshot.maxDepth }),
a => { setCurrent(a); setBusy(false) },
e => { setErr(e); setBusy(false) })
}
const diff = snapshot && current ? diffImpact(snapshot, current) : null
return (
<>
{err && <RdmErrorState raw={err} />}
{!snapshot && !err && <div className="empty-note">加载中…</div>}
{snapshot && <ImpactSnapshotView title="当时影响(不可变快照)" a={snapshot} />}
{snapshot && revision > 0 && (
<button type="button" className="btn v-ghost" disabled={busy} onClick={recompute}>
{busy ? '正在重算…' : '按当前受控图重算(生成新快照,不覆盖历史)'}
</button>
)}
{current && <ImpactSnapshotView title="当前影响" a={current} />}
{diff && (
<div className="rdm-audit-detail-body rdm-audit-wrap">
<div>当前新增:{diff.added.length ? diff.added.map(refLabel).join('、') : '无'}</div>
<div>当前不再涉及:{diff.removed.length ? diff.removed.map(refLabel).join('、') : '无'}</div>
</div>
)}
</>
)
}
interface TraceCoverageData { coverage: RdmTraceCoverage; pending: RdmPendingRecheckEdge[] }
const PENDING_STATE_LABELS: Record<string, string> = { 'pending-recheck': '待复核', stale: '已过期待复核' }
function TraceCoveragePanel({ pid }: { pid: string }) {
const [data, setData] = useState<TraceCoverageData | null>(null)
const [err, setErr] = useState('')
const genRef = useRef(0)
useEffect(() => {
setData(null); setErr('')
return startLatestFetch(genRef,
() => Promise.all([api.rdmTraceCoverage(pid), api.rdmTracePendingRecheck(pid)]).then(([coverage, pending]) => ({ coverage, pending })),
setData, setErr)
}, [pid])
if (err) return <div className="rdm-audit-truncated">受控覆盖统计暂不可用:{classifyOperationsError(err).message}</div>
if (!data) return <div className="empty-note">正在读取受控覆盖…</div>
return <TraceCoverageView pid={pid} data={data} />
}
export function TraceCoverageView({ pid, data }: { pid: string; data: TraceCoverageData }) {
const { coverage, pending } = data
const partial = coverage.gaps.length > 0 || coverage.candidate > 0 || pending.length > 0
const cards: [string, number][] = [
['正式覆盖', coverage.confirmed],
['候选(不计正式覆盖)', coverage.candidate],
['待复核', pending.length],
['缺项链', coverage.gaps.length],
]
return (
<div className="rdm-audit-coverage">
<SectionLabel>受控追溯覆盖</SectionLabel>
<div className="rdm-audit-cards">
{cards.map(([label, n]) => (
<div key={label} className="rdm-audit-card">
<div className="rdm-audit-card-num">{n}</div>
<div className="rdm-audit-card-label">{label}</div>
</div>
))}
</div>
{partial && <div className="rdm-audit-truncated">部分覆盖:存在候选、待复核或缺项链,正式覆盖只计已确认的受控边。</div>}
{coverage.gaps.length > 0 && (
<Collapsible id={`rdm-audit-gaps-${pid}`} trigger={<span className="rdm-audit-err-toggle">缺项链({coverage.gaps.length})</span>}>
{coverage.gaps.map((g, i) => (
<div key={i} className="rdm-audit-coverage-item rdm-audit-wrap">
<Chip tone="warn">规则 {g.rule}</Chip><span className="rdm-audit-wrap">{g.node} · {g.reason}</span>
</div>
))}
</Collapsible>
)}
{pending.length > 0 && (
<Collapsible id={`rdm-audit-pending-${pid}`} trigger={<span className="rdm-audit-err-toggle">待复核受控边({pending.length})</span>}>
{pending.map((e, i) => (
<div key={e.edgeId || i} className="rdm-audit-rel rdm-audit-op-pending">
<Chip tone="warn">{PENDING_STATE_LABELS[e.state] ?? humanizeState(e.state)}</Chip>
<span className="rdm-audit-rel-type">{e.kind}</span>
<span className="rdm-audit-wrap">{e.from}(r{e.fromOldRevision} → r{e.fromNewRevision || '?'})⇢ {e.to}(r{e.toOldRevision} → r{e.toNewRevision || '?'})</span>
</div>
))}
</Collapsible>
)}
</div>
)
}
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { CapabilityChangePreview, CapabilityGroupEditor, rolePermissionSaveError } from './RolePermissionsPanel'
import {
buildCapabilityGroups, capabilityGroupChanges, createRolePermissionDraft, linkedChainBreaks, setCapabilityGroup,
type RolePermissionValue,
} from '../rolePermissions'
const catalog = [
{ actionId: 'project.create', label: '创建项目', group: '项目入口' },
{ actionId: 'project.settings', label: '项目设置', group: '项目管理' },
{ actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' },
{ actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' },
{ actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' },
{ actionId: 'document.author', label: '编制文档', group: '文档动作' },
{ actionId: 'document.review', label: '审核文档', group: '文档动作' },
{ actionId: 'document.approve', label: '批准文档', group: '文档动作' },
]
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
const off: RolePermissionValue = { allowed: false, capAllowed: false }
const values = Object.fromEntries(catalog.map(item => [item.actionId, off]))
const groups = buildCapabilityGroups(catalog)
describe('CapabilityGroup role editor', () => {
it('replaces the per-capability checkbox grid with one default/cap pair per business group', () => {
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled={false} onToggle={vi.fn()} />)
expect(html.match(/type="checkbox"/g)).toHaveLength(groups.length * 2)
expect(html).toContain('创建与管理本人项目 默认授予')
expect(html).toContain('文档编制与审签 角色上限')
expect(html).not.toContain('编制文档 默认允许')
expect(html).not.toContain('管理项目成员 默认授予')
})
it('shows linked own-project management as controlled items with their association source', () => {
const create = groups.find(group => group.id === 'project-create')!
const linked = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true).values
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={linked} labels={labels} disabled onToggle={vi.fn()} />)
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
expect(html).toContain('分配文档三权:已联动')
const before = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled onToggle={vi.fn()} />)
expect(before).toContain('未联动(开启创建项目后自动补齐)')
})
it('marks a partially granted group instead of hiding the difference', () => {
const partial = { ...values, 'document.author': { allowed: true, capAllowed: true } }
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={partial} labels={labels} disabled={false} onToggle={vi.fn()} />)
expect(html).toContain('默认授予(部分)')
})
it('previews the grouped difference with the affected source and affected user count', () => {
const create = groups.find(group => group.id === 'project-create')!
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
labels={labels} roleName="项目经理" bindingCount={7} />)
expect(html).toContain('影响来源:职位角色「项目经理」 · 影响 7 名绑定用户')
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
expect(html).toContain('联动补齐 项目设置、管理项目成员、管理项目权限、分配文档三权(仅本人创建的项目)')
})
it('keeps the draft and names a linked conflict the server rejected as a whole', () => {
expect(rolePermissionSaveError(new Error('422: linked capability conflict project.members.manage')))
.toMatch(/关联能力冲突.*整笔拒绝.*本地草稿已保留/)
expect(rolePermissionSaveError(new Error('500: boom'))).toContain('本地草稿已保留')
})
it('keeps every action label visible inside the linked-source list once project creation opens', () => {
const create = groups.find(group => group.id === 'project-create')!
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={draft.values} labels={labels} disabled onToggle={vi.fn()} />)
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
expect(html).toContain('项目设置:已联动')
expect(html).toContain('管理项目权限:已联动')
expect(html).toContain('分配文档三权:已联动')
})
it('splits every preview row by business group when multiple groups change together', () => {
const create = groups.find(group => group.id === 'project-create')!
const duties = groups.find(group => group.id === 'document-duties')!
let draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
draft = setCapabilityGroup(draft, duties, 'allowed', true)
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
labels={labels} roleName="项目经理" bindingCount={7} />)
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
expect(html).toContain('文档编制与审签:默认 拒绝 → 允许')
})
it('treats a 403 the same as a 400 for linked-capability rejection phrasing', () => {
expect(rolePermissionSaveError(new Error('403: linked capability conflict'))).toMatch(/关联能力冲突/)
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).toContain('本地草稿已保留')
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).not.toMatch(/关联能力冲突/)
})
})
+101 -78
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
import {
createRolePermissionDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionDraft, type RolePermissionValue,
buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft, ensureLinkedCapabilities,
linkedChainBreaks, rebaseRoleDraft, setCapabilityGroup, validateRolePermissionDraft, visiblePatch,
type CapabilityGroup, type CapabilityGroupChange, type GroupLevel, type RolePermissionDraft, type RolePermissionValue,
} from '../rolePermissions'
import { Collapsible, Icon } from '@ui'
import { useStore } from '../store'
@@ -44,9 +45,65 @@ function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<s
}))
}
function roleValue(role: RoleRecord, actionId: string): RolePermissionValue {
const value = role.permissions.find(item => item.actionId === actionId)
return { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }
const LEVEL_TEXT: Record<GroupLevel, string> = { on: '允许', off: '拒绝', mixed: '部分' }
function linkedStatus(values: Record<string, RolePermissionValue>, actionId: string, createOn: boolean): string {
if (values[actionId]?.capAllowed) return '已联动(来源:创建项目,仅本人创建的项目)'
return createOn ? '待联动补齐(保存时随创建项目一并补齐)' : '未联动(开启创建项目后自动补齐)'
}
/** 业务能力组:组开关替代逐动作勾选;联动项为组内受控项,只展示来源。 */
export function CapabilityGroupEditor({ groups, values, labels, disabled, onToggle }: {
groups: CapabilityGroup[]
values: Record<string, RolePermissionValue>
labels: Record<string, string>
disabled: boolean
onToggle: (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => void
}) {
return <div className="rp-groups">{groups.map(group => {
const state = capabilityGroupState(values, group)
const createOn = state.allowed !== 'off' || state.capAllowed !== 'off'
return <section key={group.id} className="settings-group" aria-label={`能力组 ${group.label}`}>
<div className="ad-row">
<b title={`内部动作 ${group.actions.join('、')}`}>{group.label}</b>
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 默认授予`} checked={state.allowed === 'on'} disabled={disabled}
onChange={event => onToggle(group, 'allowed', event.target.checked)} />默认授予{state.allowed === 'mixed' ? '(部分)' : ''}</label>
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 角色上限`} checked={state.capAllowed === 'on'} disabled={disabled}
onChange={event => onToggle(group, 'capAllowed', event.target.checked)} />角色上限{state.capAllowed === 'mixed' ? '(部分)' : ''}</label>
</div>
{group.description && <p className="ad-hint">{group.description}</p>}
{group.linked.length > 0 && <ul className="rp-diff" aria-label={`${group.label} 联动项`}>{group.linked.map(id =>
<li key={id}>{labels[id] ?? id}:{linkedStatus(values, id, createOn)}</li>)}</ul>}
</section>
})}</div>
}
/** 变更预览:按业务组列出前后差异、联动补齐项、影响来源与受影响人数。 */
export function CapabilityChangePreview({ changes, breaks, labels, roleName, bindingCount }: {
changes: CapabilityGroupChange[]
breaks: string[]
labels: Record<string, string>
roleName: string
bindingCount: number
}) {
if (changes.length === 0 && breaks.length === 0) return <p className="ad-hint">没有权限变化</p>
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
return <div>
<p className="ad-hint" role="note">影响来源:职位角色「{roleName}」 · 影响 {bindingCount} 名绑定用户</p>
{breaks.length > 0 && <p className="ad-hint" role="note">关联待补齐:{names(breaks)}——保存时随「创建项目」一并补齐上限</p>}
<ul className="rp-diff">{changes.map(change => <li key={change.group.id}>
{change.group.label}:默认 {LEVEL_TEXT[change.before.allowed]} → {LEVEL_TEXT[change.after.allowed]},上限 {LEVEL_TEXT[change.before.capAllowed]} → {LEVEL_TEXT[change.after.capAllowed]}
{change.linkedAdded.length > 0 && <>;联动补齐 {names(change.linkedAdded)}(仅本人创建的项目)</>}
</li>)}</ul>
</div>
}
export function rolePermissionSaveError(error: unknown): string {
const raw = String(error)
if (/\b(400|403|422)\b/.test(raw) && /(link|conflict|关联|冲突|cap)/i.test(raw)) {
return `关联能力冲突,服务端已整笔拒绝,未保存任何修改;本地草稿已保留,请调整冲突项后重试(${raw.replace(/^Error:\s*/, '')})`
}
return `${raw.replace(/^Error:\s*/, '')};本地草稿已保留`
}
export function RolePermissionsPanel() {
@@ -65,12 +122,10 @@ export function RolePermissionsPanel() {
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [group, setGroup] = useState('')
const [actionFilter, setActionFilter] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [page, setPage] = useState(0)
const [view, setView] = useState<'single' | 'matrix'>('single')
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
@@ -87,11 +142,11 @@ export function RolePermissionsPanel() {
const draft = selected ? drafts[selected.roleId] ?? null : null
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
const groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog])
const filtered = roles ?? []
const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page])
const editablePageItems = pageItems.filter(item => !item.protected)
const changes = draft ? visiblePatch(draft, allEditableIds) : []
const capabilityGroups = useMemo(() => buildCapabilityGroups(catalog.filter(item => !item.protected)), [catalog])
const labels = useMemo(() => Object.fromEntries(catalog.map(item => [item.actionId, item.label])), [catalog])
const changes = draft ? capabilityGroupChanges(draft, capabilityGroups) : []
const breaks = draft ? linkedChainBreaks(draft.values) : []
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
filters = { query, mode, status, action: actionFilter }) => {
@@ -231,21 +286,25 @@ export function RolePermissionsPanel() {
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
const invalid = validateRolePermissionDraft(draft)
if (invalid) { setErr(`${invalid.actionId} 的默认权限不能超过角色上限`); return }
const full = Object.entries(draft.values).map(([actionId, value]) => ({ actionId, ...value }))
// 关联项不能以断链状态保存:提交前与服务端同规则补齐创建能力的联动上限。
const prepared = ensureLinkedCapabilities(draft)
const invalid = validateRolePermissionDraft(prepared)
if (invalid) { setErr(`${labels[invalid.actionId] ?? invalid.actionId} 的默认权限不能超过角色上限`); return }
const full = Object.entries(prepared.values).map(([actionId, value]) => ({ actionId, ...value }))
setBusy(true); setErr(''); setNotice('')
try {
await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
const receipt = await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
setDrafts(current => {
if (!(roleId in current)) return current
const next = { ...current }; delete next[roleId]; return next
})
await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存')
await load(roleCursor, true, selected.roleId)
const expanded = receipt?.linkedExpansion ?? []
setNotice(expanded.length ? `角色权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '角色权限已保存')
} catch (error) {
if (String(error).includes('409')) {
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
} else setErr(rolePermissionSaveError(error))
}
finally {
setBusy(false)
@@ -256,47 +315,30 @@ export function RolePermissionsPanel() {
}
}
const editValue = (actionId: string, field: 'allowed' | 'capAllowed', checked: boolean) => {
const current = drafts[selectedId]
if (!current) return
const value0 = current.values[actionId] ?? { allowed: false, capAllowed: false }
const value = field === 'allowed'
? { allowed: checked, capAllowed: checked ? true : value0.capAllowed }
: { allowed: checked ? value0.allowed : false, capAllowed: checked }
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: updateRolePermission(prev[selectedId], actionId, value) } : prev)
}
const editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => {
const start = drafts[selectedId]
if (!start) return
let next = start
for (const item of editablePageItems) {
const current = next.values[item.actionId] ?? { allowed: false, capAllowed: false }
const value = field === 'allowed'
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
: { allowed: checked ? current.allowed : false, capAllowed: checked }
next = updateRolePermission(next, item.actionId, value)
}
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: next } : prev)
const editGroup = (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => {
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: setCapabilityGroup(prev[selectedId], group, field, checked) } : prev)
}
// 矩阵按业务组汇总;只有选中角色可编辑组开关,其余角色只读摘要。
const matrix = () => <div className="rp-matrix-wrap">
<table className="ad-table rp-matrix">
<thead><tr><th>角色</th>{pageItems.map(item => <th key={item.actionId}>{item.label}</th>)}</tr></thead>
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
{pageItems.map(item => {
const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId)
return <td key={item.actionId}>
{item.protected ? <span className="ad-dim">系统授权</span> : <>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 默认允许`} checked={value?.allowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} />默认</label>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 角色上限`} checked={value?.capAllowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} />上限</label>
</>}
</td>
})}
</tr>)}</tbody>
<thead><tr><th>角色</th>{capabilityGroups.map(group => <th key={group.id} title={group.description}>{group.label}</th>)}</tr></thead>
<tbody>{filtered.map(role => {
const values = role.roleId === selectedId && draft ? draft.values : roleValues(role, catalog)
const editable = role.roleId === selectedId && !!draft && canManagePermissions
return <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
{capabilityGroups.map(group => {
const state = capabilityGroupState(values, group)
return <td key={group.id}>{editable ? <>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 默认授予`} checked={state.allowed === 'on'}
disabled={busy || roleLoading} onChange={event => editGroup(group, 'allowed', event.target.checked)} />默认</label>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 角色上限`} checked={state.capAllowed === 'on'}
disabled={busy || roleLoading} onChange={event => editGroup(group, 'capAllowed', event.target.checked)} />上限</label>
</> : <span className="ad-dim">默认{LEVEL_TEXT[state.allowed]} · 上限{LEVEL_TEXT[state.capAllowed]}</span>}</td>
})}
</tr>
})}</tbody>
</table>
</div>
@@ -317,9 +359,6 @@ export function RolePermissionsPanel() {
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
</select>
<select className="lg-input" aria-label="权限分组" value={group} onChange={event => { setGroup(event.target.value); setPage(0) }}>
<option value="">全部权限</option>{groups.map(value => <option key={value}>{value}</option>)}
</select>
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
@@ -329,16 +368,8 @@ export function RolePermissionsPanel() {
<div className="ad-row rp-view-toggle">
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
<button className="ad-mini" disabled={page === 0} onClick={() => setPage(value => value - 1)}>上一组</button>
<button className="ad-mini" disabled={(page + 1) * 6 >= catalog.filter(item => !group || item.group === group).length} onClick={() => setPage(value => value + 1)}>下一组</button>
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
{view === 'matrix' && canManagePermissions && <>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', true)}>当前页默认全选</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', false)}>当前页默认清除</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', true)}>当前页上限全开</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', false)}>当前页上限关闭</button>
</>}
</div>
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
@@ -363,21 +394,13 @@ export function RolePermissionsPanel() {
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
</div>}
{canViewPermissions ? <><table className="ad-table"><thead><tr><th>权限</th><th>默认允许</th><th>角色上限</th><th>说明</th></tr></thead>
<tbody>{pageItems.map(item => {
const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false }
return <tr key={item.actionId}><td>{item.label}<div className="ad-dim ad-mono">{item.actionId}</div></td>
<td><input type="checkbox" aria-label={`${item.label} 默认允许`} checked={value.allowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} /></td>
<td><input type="checkbox" aria-label={`${item.label} 角色上限`} checked={value.capAllowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} /></td>
<td className="ad-dim">{item.protected ? '受保护系统授权,不能由普通角色授予' : item.description}</td></tr>
})}</tbody></table>
{canViewPermissions ? <>
<CapabilityGroupEditor groups={capabilityGroups} values={draft.values} labels={labels}
disabled={busy || roleLoading || !canManagePermissions} onToggle={editGroup} />
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
{changes.length === 0 ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
<li key={change.actionId}><span className="ad-mono">{change.actionId}</span>:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}</li>)}</ul>}
<CapabilityChangePreview changes={changes} breaks={breaks} labels={labels} roleName={selected.name} bindingCount={selected.bindingCount} />
</Collapsible>
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || (changes.length === 0 && breaks.length === 0) || allEditableIds.length === 0} onClick={savePermissions}>保存权限</button>}
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
</> : <p className="ad-hint">请选择角色</p>}
</section>
+71
View File
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { TaskSidebar } from './TaskSidebar'
const mocked = vi.hoisted(() => ({ state: {} as Record<string, unknown> }))
vi.mock('../store', () => ({
useStore: (select: (value: Record<string, unknown>) => unknown) => select(mocked.state),
store: { openInbox: vi.fn(), openFiles: vi.fn(), focusPending: vi.fn(), setUI: vi.fn(), toggleLeft: vi.fn() },
}))
const member = { owner: 'bob', name: 'Bob', auth: true, systemActions: [],
linkedCapabilities: { projectCreate: false, linkedActions: [], authorityEpoch: 1 } }
const creator = { ...member, linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } }
function render(patch: Record<string, unknown>) {
mocked.state = {
projects: [], projectsLoaded: true, projectSync: { status: 'ok', failures: 0 }, tasks: [], me: member,
adminSections: [], artifacts: [], cards: [], projectCards: [], serverWorkspaces: [],
rdmAuditAvailable: false, rdmCockpitAvailable: false, currentConvId: null, currentProjectId: null,
ui: { theme: 'light', showArchived: false },
...patch,
}
return renderToStaticMarkup(<TaskSidebar />)
}
beforeEach(() => { mocked.state = {} })
describe('EmptyListNoCreate sidebar', () => {
it('shows a waiting-for-invite state without inviting a same-name project for a member without creation', () => {
const html = render({})
expect(html).toContain('尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。')
expect(html).not.toContain('新建项目')
expect(html).toMatch(/<button class="tsb-newtask-main" disabled=""/)
})
it('shows a loading state instead of an empty state before the list arrives', () => {
const html = render({ projectsLoaded: false })
expect(html).toContain('正在加载项目列表…')
expect(html).not.toContain('等待项目负责人邀请')
})
it('points a creator to explicit project creation', () => {
expect(render({ me: creator })).toContain('还没有项目:从「新建项目」开始。')
})
})
describe('AutoDiscover sidebar status', () => {
it('surfaces a degraded or offline project sync instead of silently showing stale projects', () => {
expect(render({ projectSync: { status: 'degraded', failures: 2 } })).toContain('项目列表同步异常,正在自动重试')
expect(render({ projectSync: { status: 'offline', failures: 0 } })).toContain('网络已断开,恢复后自动同步项目列表')
expect(render({})).not.toContain('同步异常')
})
it('keeps conversation creation available once a discovered project is current', () => {
const html = render({ projects: [{ id: 'host-b', name: '项目P', owner: 'carol' }], currentProjectId: 'host-b' })
expect(html).toMatch(/<button class="tsb-newtask-main" title="在当前项目新建对话/)
expect(html).toContain('从「新建对话」开始')
})
it('falls back to a project-less sidebar when the current discovery has no project', () => {
const html = render({ projects: [], currentProjectId: null })
expect(html).toContain('尚无项目')
expect(html).not.toContain('同步异常')
})
it('does not render a sync notice when the listing succeeded', () => {
expect(render({ projectSync: { status: 'ok', failures: 0 } })).not.toContain('同步异常')
expect(render({ projectSync: { status: 'idle', failures: 0 } })).not.toContain('同步异常')
})
})
+55 -25
View File
@@ -3,11 +3,12 @@
import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store'
import { auth } from '../auth'
import { api } from '../api'
import { api, type ConversationView } from '../api'
import { Icon } from '@ui'
import type { Conversation } from '../contracts'
import { relativeTime, filterTasks } from '../tasklist'
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
import {
canCreateProject, emptyProjectsNotice, projectCapabilitySummary, projectStage, projectStateLabel, projectWorkError,
} from '../projectmeta'
import { buildWorkspaceEntries } from '../workspaces'
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
@@ -53,12 +54,15 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const artifacts = useStore(s => s.artifacts)
const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
const scratchMode = useStore(s => !!s.scratchMode)
const showArchived = useStore(s => s.ui.showArchived)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
const theme = useStore(s => s.ui.theme)
const projectsLoaded = useStore(s => !!s.projectsLoaded)
const syncStatus = useStore(s => s.projectSync?.status ?? 'idle')
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
s.projectCards.filter(c => c.status === 'action_required').length)
@@ -70,13 +74,15 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const createConversation = async (projectId?: string) => {
try {
if (projectId && scratchMode) await store.openProject(projectId)
await store.createTask(projectId)
onConversationOpen?.()
} catch { /* store owns the actionable failure toast */ }
}
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable, rdmCockpit: rdmCockpitAvailable })
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
const scratchTasks = tasks.filter(t => t.scratch)
const visible = filterTasks(scratchMode ? scratchTasks : tasks.filter(t => t.projectId === currentProjectId && !t.scratch), q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const currentProject = projects.find(p => p.id === currentProjectId)
@@ -84,17 +90,21 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const workError = currentProject ? projectWorkError(currentProject) : null
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
const canCreate = canCreateProject(me)
const noProjects = projects.length === 0
const syncNotice = syncStatus === 'degraded' ? '项目列表同步异常,正在自动重试'
: syncStatus === 'offline' ? '网络已断开,恢复后自动同步项目列表' : null
const rowActions = (t: Conversation) => (
const rowActions = (t: ConversationView) => (
<span className="tsb-row-acts">
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
</button>
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
{!t.scratch && <button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
<Icon name="shield" size={13} />
</button>
</button>}
</span>
)
@@ -107,13 +117,13 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
onConversationOpen?.()
}
const taskRow = (t: Conversation) => (
const taskRow = (t: ConversationView) => (
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
<Icon name="message" size={14} />
<span className="tsb-task-t" title={t.title}>{t.title || '未命名对话'}</span>
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
{t.crossPrivate && !t.scratch && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
</button>
{rowActions(t)}
@@ -128,9 +138,9 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<div className="tsb-brand">
<button className="tsb-projrow" title="切换项目"
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
<Icon name="folder" size={15} />
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
{stage && <span className="tsb-projrow-stage">{stage}</span>}
<Icon name={scratchMode ? 'message' : 'folder'} size={15} />
<span className="tsb-projrow-name">{scratchMode ? '无项目对话' : currentProject?.name ?? '选择项目'}</span>
{stage && !scratchMode && <span className="tsb-projrow-stage">{stage}</span>}
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button>
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={onCollapse}><Icon name="panel-left" /></button>
@@ -139,7 +149,9 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
<div className="tsb-actions">
<div className="tsb-newtask">
<button className="tsb-newtask-main" title="在当前项目新建对话(⌘N)" onClick={() => createConversation()}>
<button className="tsb-newtask-main" disabled={noProjects && !scratchMode}
title={scratchMode ? '新建无项目对话(⌘N)' : noProjects ? emptyProjectsNotice(projectsLoaded, canCreate) : '在当前项目新建对话(⌘N)'}
onClick={() => createConversation()}>
<Icon name="plus" size={15} />新建对话
</button>
<button className="tsb-newtask-caret" title="更多创建选项"
@@ -147,12 +159,12 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
</div>
<div className="tsb-search">
<Icon name="search" size={14} />
<input aria-label="搜索本项目会话" placeholder="搜索对话…" value={q} onChange={e => setQ(e.target.value)} />
<input aria-label={scratchMode ? '搜索无项目对话' : '搜索本项目会话'} placeholder="搜索对话…" value={q} onChange={e => setQ(e.target.value)} />
</div>
</div>
<div className="tsb-orgrow">
<span className="tsb-seclabel" style={{ padding: 0 }}>本项目会话</span>
<span className="tsb-seclabel" style={{ padding: 0 }}>{scratchMode ? '无项目对话' : '本项目会话'}</span>
<span style={{ flex: 1 }} />
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
@@ -160,7 +172,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
<div className="tsb-scroll">
{creatingProject && (
{syncNotice && <div className="tsb-tool-note" role="status">{syncNotice}</div>}
{canCreate && creatingProject && (
<div className="tsb-newproject">
<input className="tsb-search" autoFocus value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
@@ -185,7 +198,9 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
)}
{rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
<div className="tsb-empty">{scratchMode ? '开始一个通用对话;无项目对话彼此独立,不会使用项目文档与知识。'
: noProjects ? emptyProjectsNotice(projectsLoaded, canCreate)
: workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
)}
{rest.map(t => taskRow(t))}
</div>
@@ -204,7 +219,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<kbd>{entry.shortcut}</kbd>
</button>
))}
{currentProject && (
{currentProject && !scratchMode && (
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
<span className="tsb-tool-name">项目设置</span>
@@ -228,25 +243,37 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{/* 弹出菜单们 */}
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem icon="message" onClick={() => { void store.enterScratchMode(); onConversationOpen?.(); setMenu(null) }}>
无项目对话
<kbd>{scratchMode ? '当前 · ' : ''}{scratchTasks.filter(t => !t.archived).length} 会话</kbd>
</MenuItem>
{projects.map(p => {
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
const mine = !!p.owner && p.owner === me?.owner
return (
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
{p.name}
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
<kbd>{p.id === currentProjectId && !scratchMode ? '当前 · ' : pstage ? `${pstage} · ` : ''}{mine ? '我创建 · ' : ''}{count} 会话</kbd>
</MenuItem>
)
})}
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
<div className="tsb-menu-div" />
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
{noProjects && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
{canCreate && <>
<div className="tsb-menu-div" />
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
</>}
</Menu>
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>
<div className="tsb-menu-div" />
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
{scratchMode
? <MenuItem icon="message" onClick={() => { createConversation(); setMenu(null) }}>新建无项目对话<kbd>⌘N</kbd></MenuItem>
: !noProjects && <MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>}
{canCreate && <>
<div className="tsb-menu-div" />
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
</>}
{noProjects && !canCreate && !scratchMode && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
))}
@@ -256,6 +283,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{rowTask && <>
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
</>}
{rowTask && !rowTask.scratch && <>
<div className="tsb-menu-div" />
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
@@ -270,6 +299,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<span>
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
{me && <div className="tsb-acc-mail">{projectCapabilitySummary(me)}</div>}
</span>
</div>
<div className="tsb-menu-div" />
+32 -9
View File
@@ -22,7 +22,8 @@ export const docBridge: DocBridge = {
},
}
const inboxCallbacks = {
// 无项目对话(scratch)没有可留档的项目:只用不含 onArchive 的回调。
const scratchInboxCallbacks = {
onAction: (c: ActivityCard, id: string, payload?: WorkCardAction) => store.cardAction(c, id, payload),
onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c),
onReference: (c: ActivityCard) => store.referenceWork(c),
@@ -30,7 +31,24 @@ const inboxCallbacks = {
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
onArchive: (c: ActivityCard) => store.cardToProject(c),
}
const inboxCallbacks = { ...scratchInboxCallbacks, onArchive: (c: ActivityCard) => store.cardToProject(c) }
type Scope = string | null | undefined
/** 本会话仍待用户处理的卡片:计数与定位共用这一谓词。 */
const isPendingConfirmation = (card: ActivityCard, conversationId: Scope, projectId: Scope) =>
card.status !== 'dismissed' && card.conversationId === conversationId && card.projectId === projectId
&& (card.status === 'action_required' || card.work?.decision?.state === 'open')
/** 本会话仍待用户处理的卡片数:右栏收起后由对话输入区提示承接确认路径。 */
export function pendingConfirmations(cards: ActivityCard[], conversationId: Scope, projectId: Scope): number {
return cards.filter(card => isPendingConfirmation(card, conversationId, projectId)).length
}
/** 右栏收起或不在「工作」页签时的确认路径:经 openInbox 展开右栏、切到工作页签并定位首张待处理卡。 */
export function openPendingConfirmation(cards: ActivityCard[], conversationId: Scope, projectId: Scope) {
store.openInbox(cards.find(card => isPendingConfirmation(card, conversationId, projectId))?.id)
}
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
@@ -63,12 +81,16 @@ export function Workbench() {
const cards = useStore(s => s.cards)
const plan = useStore(s => s.plan)
const todos = useStore(s => s.todos)
const inboxScope = useStore(s => s.inboxScope)
const scratch = useStore(s => !!s.tasks.find(t => t.id === s.currentConvId)?.scratch)
const storedScope = useStore(s => s.inboxScope)
const inboxScope = scratch ? 'conv' : storedScope
const projectCards = useStore(s => s.projectCards)
const filter = useStore(s => s.ui.inboxFilter)
const visible = inboxScope === 'project' ? projectCards : cards
const pending = pendingCount(visible)
const unread = visible.filter(card => card.work && card.work.resultVersion > card.work.readVersion).length
const pendingHere = useStore(s => pendingConfirmations(s.cards, s.currentConvId, s.currentProjectId))
const collapseHint = pendingHere > 0 ? `收起右栏(${pendingHere} 项待确认可从对话输入区的提示回到工作栏处理)` : '收起右栏'
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
const switchTab = (tab: 'work' | 'files') =>
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
@@ -83,11 +105,11 @@ export function Workbench() {
{rightTab === 'work' && (
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
)}
<button className="tab" title="收起右栏" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
<button className="tab" title={collapseHint} aria-label={collapseHint} onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
</div>
{rightTab === 'files'
? <FilesTab />
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} />}
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} scratch={scratch} />}
</>
)
}
@@ -105,13 +127,14 @@ export function hasActiveProgressCard(cards: ActivityCard[]): boolean {
return cards.some(c => c.kind === 'progress' && (c.status === 'active' || c.status === 'action_required'))
}
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards, scratch }: {
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
filter: InboxFilter
inboxScope: 'conv' | 'project'
projectCards: ActivityCard[]
cards: ActivityCard[]
scratch: boolean
}) {
const record = useStore(s => s.workRecord)
const showCurrentTask = !hasActiveProgressCard(cards)
@@ -122,16 +145,16 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
{!scratch && <button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
</button>
</button>}
<span style={{ flex: 1 }} />
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
</div>
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
: <InboxList cards={cards} docBridge={docBridge} callbacks={scratch ? scratchInboxCallbacks : inboxCallbacks} filter={filter} />}
<WorkspaceSurfaces />
</>}
</div>
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import type { Project } from '../contracts'
import { MemberDutyCell, RdmMembersSection, memberManagementView } from './adminRdmSections'
const project: Project = {
id: 'host-1', owner: 'alice', name: '项目P', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' },
}
describe('MemberManagementEntryHidden', () => {
it('stays read-only until the self check confirms member management', () => {
expect(memberManagementView(null, null)).toEqual({ canManage: false, notice: '正在核对成员管理权限…' })
expect(memberManagementView([], null).canManage).toBe(false)
expect(memberManagementView([{ action: 'member.manage', allowed: false, }], null).notice).toContain('成员增删由项目创建者负责')
expect(memberManagementView([{ action: 'member.manage', allowed: true }], null)).toEqual({ canManage: true, notice: null })
})
it('does not offer member management on a project that cannot accept work', () => {
expect(memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档').canManage).toBe(false)
})
it('renders no add, role change or removal entry before authorization is known', () => {
const html = renderToStaticMarkup(<RdmMembersSection project={project} />)
expect(html).toContain('正在核对成员管理权限…')
expect(html).not.toContain('添加成员')
expect(html).not.toContain('选择人员')
expect(html).toContain('实际职责')
})
})
describe('CreatorOwnerMemberDuties member table', () => {
it('separates the immutable creator from the owner label and plain members', () => {
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
expect(creator).toContain('创建者')
expect(creator).toContain('成员增删')
expect(creator).toContain('随「创建项目」联动')
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator="alice" />)
expect(owner).toContain('项目负责人')
expect(owner).toContain('不含成员管理')
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
expect(member).toContain('基础协作')
expect(member).not.toContain('成员增删')
})
it('falls back to plain member duty when the project has no creator recorded', () => {
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator={undefined} />)
expect(owner).toContain('项目负责人')
expect(owner).not.toContain('<span class="ad-chip ad-chip-admin">创建者')
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator={undefined} />)
expect(member).toContain('基础协作')
})
it('marks the creator cell as an admin chip and the plain member as a neutral chip', () => {
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
expect(creator).toContain('ad-chip-admin')
expect(member).toMatch(/ad-chip(?!-admin)/)
})
})
describe('MemberManagementEntryHidden notice clarity', () => {
it('names the project-wide stage error as the reason to gate management entries', () => {
const result = memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档,请联系项目负责人恢复后再新建对话')
expect(result).toEqual({ canManage: false, notice: null })
})
it('distinguishes pending self-check from a confirmed no-permission state', () => {
const pending = memberManagementView(null, null)
const denied = memberManagementView([{ action: 'project.members.manage', allowed: false }], null)
expect(pending.notice).toMatch(/正在核对/)
expect(denied.notice).toMatch(/成员增删由项目创建者负责/)
})
})
+91 -87
View File
@@ -5,7 +5,7 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { canManageProjectMembers, memberDuty, projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { Btn, Collapsible, Icon, Picker } from '@ui'
import {
@@ -24,20 +24,32 @@ interface MemberRow {
profRole?: { roleId: string; name: string } | null
}
export function RdmMembersSection({
project,
initialCanManage,
initialRows,
}: {
project: Project
initialCanManage?: boolean
initialRows?: MemberRow[]
}) {
/** 成员管理入口门控:本人自省未到达或无成员管理资格时只读(fail-closed)。 */
export function memberManagementView(actions: { action: string; allowed: boolean }[] | null, writeError: string | null) {
if (actions === null) return { canManage: false, notice: '正在核对成员管理权限…' }
if (!canManageProjectMembers(actions)) {
return { canManage: false, notice: '你可以查看成员与实际职责;成员增删由项目创建者负责(需具备项目成员管理资格)。' }
}
return { canManage: !writeError, notice: null }
}
/** 实际职责:创建者(不可变)与界面身份「负责人」分开显示,并标明能力关联来源。 */
export function MemberDutyCell({ member, creator }: { member: { owner: string; role: string }; creator: string | undefined }) {
const duty = memberDuty(member, creator)
return <>
<span className={duty.kind === 'member' ? 'ad-chip' : 'ad-chip ad-chip-admin'}>{duty.label}</span>
<div className="ad-dim" title={`来源:${duty.source}`}>{duty.duties}</div>
</>
}
export function RdmMembersSection({ project }: { project: Project }) {
const projectId = project.id
const writeError = projectWorkError(project)
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
const [canManage, setCanManage] = useState(initialCanManage ?? false)
const [rows, setRows] = useState<MemberRow[] | null>(initialRows ?? null)
const me = useStore(s => s.me)
const [manageActions, setManageActions] = useState<{ action: string; allowed: boolean }[] | null>(null)
const manage = memberManagementView(manageActions, writeError)
const [rows, setRows] = useState<MemberRow[] | null>(null)
const [memberNext, setMemberNext] = useState('')
const [memberTotal, setMemberTotal] = useState<number | null>(null)
const [directoryOk, setDirectoryOk] = useState(true)
@@ -63,23 +75,31 @@ export function RdmMembersSection({
const memberGen = useRef(0)
useEffect(() => {
setCanManage(false)
setPicked([]); setPickedItems([]); knownPicks.current.clear()
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
setRoleOptions(null)
}, [project])
// rdm.role.options 受 member.manage 授权;未确认前先不发请求,避免普通成员被 403。
// 跟着 [project, manage.canManage] 重订:项目切换或权限放开后重发,迟到响应丢弃。
useEffect(() => {
if (!canManage) {
setRoleOptions(null)
return
}
if (!manage.canManage) return
let live = true
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
.catch(() => { if (live) setRoleOptions(null) })
return () => { live = false }
}, [project, canManage])
}, [project, manage.canManage])
useEffect(() => {
setManageActions(null)
if (!me?.owner) return
let live = true
cmd<{ actions?: { action: string; allowed: boolean }[] }>('rdm.perm.check', permCheckPayload(projectId, me.owner))
.then(out => { if (live) setManageActions(out.actions ?? []) })
.catch(() => { if (live) setManageActions([]) })
return () => { live = false }
}, [project, me?.owner])
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
[candMatch, candFields, candRoleId])
@@ -107,9 +127,6 @@ export function RdmMembersSection({
const out = await cmd<RdmMemberPage>('rdm.project.member.list',
memberListParams(projectId, { query: memberQuery, roleId: memberRoleId, cursor }))
if (gen !== memberGen.current) return
if (!cursor) {
setCanManage(Boolean(out.canManage))
}
setRows(prev => appendMemberPage(cursor ? prev : null, out.members))
setMemberNext(out.next || '')
setMemberTotal(typeof out.total === 'number' ? out.total : null)
@@ -159,48 +176,45 @@ export function RdmMembersSection({
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
{canManage ? (
<div className="ad-create">
<div className="ad-row">
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
<select className="lg-input" aria-label="匹配方式" value={candMatch}
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select>
<select className="lg-input" aria-label="检索字段" value={candFields}
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
</div>
<div className="ad-row">
<Picker
aria-label="选择人员"
selected={picked}
onSelect={onPick}
selectedItems={pickedItems}
fetchPage={fetchPage}
reloadKey={accountSearchKey(projectId, candOptions)}
disabled={pickerDisabled}
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示匹配账号"
/>
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option>
<option value="owner">项目负责人</option>
</select>
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
setPicked([]); setPickedItems([])
})}>添加成员</button>
</div>
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
{manage.notice && <p className="ad-hint" role="note">{manage.notice}</p>}
{manage.canManage && <div className="ad-create">
<div className="ad-row">
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
<select className="lg-input" aria-label="匹配方式" value={candMatch}
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select>
<select className="lg-input" aria-label="检索字段" value={candFields}
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
</div>
) : (
<p className="ad-hint">仅项目创建者可管理成员;你可以查看成员列表。</p>
)}
<div className="ad-row">
<Picker
aria-label="选择人员"
selected={picked}
onSelect={onPick}
selectedItems={pickedItems}
fetchPage={fetchPage}
reloadKey={accountSearchKey(projectId, candOptions)}
disabled={pickerDisabled}
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示匹配账号"
/>
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option>
<option value="owner">项目负责人(界面身份,不含成员管理)</option>
</select>
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
setPicked([]); setPickedItems([])
})}>添加成员</button>
</div>
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。被加入的成员无需重新登录,项目会自动出现在其项目列表。</p>
</div>}
<div className="ad-row">
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
onChange={e => setMemberQuery(e.target.value)} />
@@ -209,41 +223,31 @@ export function RdmMembersSection({
</div>
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
<table className="ad-table">
<thead>
<tr>
<th>成员</th>
<th>专业角色</th>
<th>项目身份</th>
<th>添加人</th>
<th>添加时间</th>
{canManage && <th></th>}
</tr>
</thead>
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>实际职责</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
<tbody>
{(rows ?? []).map(m => (
<tr key={m.owner}>
<td className="ad-mono">{personLabel(m)}</td>
<td className="ad-dim">{m.profRole?.name || '—'}</td>
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
<td><MemberDutyCell member={m} creator={project.owner} /></td>
<td className="ad-dim">{m.addedBy || '—'}</td>
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
{canManage && (
<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>
)}
<td>{manage.canManage && <>
<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={canManage ? 6 : 5} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
{rows === null && <tr><td colSpan={canManage ? 6 : 5} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
{rows !== null && rows.length === 0 && <tr><td colSpan={7} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
{rows === null && <tr><td colSpan={7} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
</tbody>
</table>
{memberNext && rows !== null && (
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import {
canCreateProject, canManageProjectMembers, emptyProjectsNotice, memberDuty, noProjectError, projectCapabilitySummary,
} from './projectmeta'
const linked = (projectCreate: boolean) => ({ projectCreate, source: 'role', linkedActions: [], authorityEpoch: 3 })
describe('EmptyListNoCreate', () => {
it('asks a member without creation capability to wait for an invite instead of creating a project', () => {
const notice = emptyProjectsNotice(true, false)
expect(notice).toContain('等待项目负责人邀请')
expect(notice).not.toContain('新建')
expect(noProjectError(true, false)).not.toContain('新建')
})
it('does not call a list that has not arrived empty', () => {
expect(emptyProjectsNotice(false, true)).toBe('正在加载项目列表…')
expect(noProjectError(false, false)).toContain('尚未加载')
})
it('points a creator to explicit project creation', () => {
expect(emptyProjectsNotice(true, true)).toContain('新建项目')
})
})
describe('DefaultProjectDisabled creation entry', () => {
it('follows the server-computed creation link first', () => {
expect(canCreateProject({ auth: true, isAdmin: true, systemActions: ['project.create'], linkedCapabilities: linked(false) })).toBe(false)
expect(canCreateProject({ auth: true, systemActions: [], linkedCapabilities: linked(true) })).toBe(true)
})
it('falls back to explicit system actions and fails closed without an identity', () => {
expect(canCreateProject({ auth: true, systemActions: [] })).toBe(false)
expect(canCreateProject({ auth: true, systemActions: ['project.create'] })).toBe(true)
expect(canCreateProject(null)).toBe(false)
expect(canCreateProject({ auth: false })).toBe(true)
})
it('summarizes the creation source and the linked own-project management', () => {
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(true) })).toContain('来源:职位角色')
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: { ...linked(true), conflicts: ['project.members.manage'] } }))
.toContain('1 项关联被角色上限阻断')
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(false) })).toContain('被邀请加入后项目自动出现')
})
})
describe('CreatorOwnerMemberDuties', () => {
it('gives member management only to the immutable creator, not to an owner label', () => {
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice')).toMatchObject({ kind: 'creator', label: '创建者' })
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').duties).toContain('成员增删')
const owner = memberDuty({ owner: 'bob', role: 'owner' }, 'alice')
expect(owner).toMatchObject({ kind: 'owner', label: '项目负责人' })
expect(owner.duties).toContain('不含成员管理')
const member = memberDuty({ owner: 'carol', role: 'member' }, 'alice')
expect(member).toMatchObject({ kind: 'member', source: '成员关系' })
expect(member.duties).toContain('本人对话')
})
})
describe('MemberManagementEntryHidden capability', () => {
it('requires an allowed member management action from the self check', () => {
expect(canManageProjectMembers(null)).toBe(false)
expect(canManageProjectMembers([{ action: 'member.manage', allowed: false }])).toBe(false)
expect(canManageProjectMembers([{ action: 'doc.read', allowed: true }])).toBe(false)
expect(canManageProjectMembers([{ action: 'member.manage', allowed: true }])).toBe(true)
expect(canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])).toBe(true)
})
it('treats the two old and new capability ids as equivalent for backward compatibility', () => {
const onlyLegacy = canManageProjectMembers([{ action: 'member.manage', allowed: true }])
const onlyModern = canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])
const both = canManageProjectMembers([
{ action: 'member.manage', allowed: true },
{ action: 'project.members.manage', allowed: true },
])
expect(onlyLegacy).toBe(onlyModern)
expect(onlyModern).toBe(true)
expect(both).toBe(true)
})
})
describe('CreatorOwnerMemberDuties member identity', () => {
it('falls back to plain member when the creator record is missing', () => {
const member = memberDuty({ owner: 'carol', role: 'member' }, undefined)
expect(member).toMatchObject({ kind: 'member', label: '项目成员' })
expect(member.duties).toContain('基础协作')
})
it('names the source of each member kind distinctly', () => {
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').source).toMatch(/创建项目/)
expect(memberDuty({ owner: 'bob', role: 'owner' }, 'alice').source).toMatch(/成员关系/)
expect(memberDuty({ owner: 'carol', role: 'member' }, 'alice').source).toBe('成员关系')
})
})
describe('DefaultProjectDisabled empty state messaging', () => {
it('keeps both helpers pointing the member to wait for an invite', () => {
expect(emptyProjectsNotice(true, false)).toContain('等待项目负责人邀请')
expect(noProjectError(true, false)).toContain('等待项目负责人邀请')
})
it('keeps the loading notice distinct from a real empty list', () => {
expect(emptyProjectsNotice(false, false)).toMatch(/加载/)
expect(noProjectError(false, false)).toMatch(/加载/)
expect(emptyProjectsNotice(false, false)).not.toBe(noProjectError(false, false))
})
})
+69
View File
@@ -1,3 +1,5 @@
import type { LinkedCapabilities } from './api'
/** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */
export function projectStage(settings?: Record<string, unknown> | null): string | null {
const stage = settings?.stage
@@ -41,3 +43,70 @@ export function projectActionError(error: unknown): string {
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
return raw.replace(/^Error:\s*/, '')
}
interface ProjectActor {
auth?: boolean; isAdmin?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities
}
/** 只决定是否展示创建入口;服务端仍按当前身份最终判定。 */
export function canCreateProject(me: ProjectActor | null | undefined): boolean {
if (!me) return false
if (me.auth === false) return true
if (me.linkedCapabilities) return me.linkedCapabilities.projectCreate
// Backends without capability exposure keep the server as the only gate.
if (me.systemActions === undefined) return true
return !!me.isAdmin || me.systemActions.includes('project.create')
}
const CAPABILITY_SOURCE_LABELS: Record<string, string> = {
role: '职位角色', 'system-grant': '明确系统授权', 'bootstrap-admin': '初始管理员',
}
function capabilitySourceLabel(source: string | undefined): string {
return (source && CAPABILITY_SOURCE_LABELS[source]) || source || '未知来源'
}
/** 账户区一句话:本人是否能创建项目、来源、以及联动形成的本人项目管理资格。 */
export function projectCapabilitySummary(me: ProjectActor | null | undefined): string {
const linked = me?.linkedCapabilities
if (!linked) return canCreateProject(me) ? '可创建项目' : '未授予创建项目 · 被邀请加入后项目自动出现'
if (!linked.projectCreate) return '未授予创建项目 · 被邀请加入后项目自动出现'
const conflicts = linked.conflicts?.length ? ` · ${linked.conflicts.length} 项关联被角色上限阻断` : ''
return `可创建项目(来源:${capabilitySourceLabel(linked.source)})· 本人创建的项目自动具备设置、成员、项目权限与文档职责管理${conflicts}`
}
/** 空项目列表:未加载不当作空;无创建能力时只提示等待邀请,不诱导新建同名项目。 */
export function emptyProjectsNotice(loaded: boolean, canCreate: boolean): string {
if (!loaded) return '正在加载项目列表…'
if (canCreate) return '还没有项目:从「新建项目」开始。'
return '尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。'
}
/** 新建对话时没有可用项目:不再自动创建「默认项目」。 */
export function noProjectError(loaded: boolean, canCreate: boolean): string {
if (!loaded) return '项目列表尚未加载完成,请稍候再试'
if (canCreate) return '还没有项目,请先新建项目'
return '尚无可用项目,请等待项目负责人邀请你加入'
}
interface MemberDuty { kind: 'creator' | 'owner' | 'member'; label: string; duties: string; source: string }
/** 项目内实际职责:成员管理只随不可变创建者;负责人只是界面身份。 */
export function memberDuty(member: { owner: string; role: string }, creator: string | undefined): MemberDuty {
if (creator && member.owner === creator) {
return { kind: 'creator', label: '创建者', duties: '项目设置、成员增删、项目内权限、文档职责分配 + 基础协作',
source: '随「创建项目」联动(仅本人创建的项目)' }
}
if (member.role === 'owner') {
return { kind: 'owner', label: '项目负责人', duties: '基础协作:项目阅读与本人对话;不含成员管理',
source: '成员关系(界面身份不授予创建者管理权)' }
}
return { kind: 'member', label: '项目成员', duties: '基础协作:查看与切换项目、两树一网阅读、本人对话', source: '成员关系' }
}
const MEMBER_MANAGE_ACTIONS = new Set(['member.manage', 'project.members.manage'])
/** 本人自省结果里成员管理是否生效;未知(null)按无权处理。 */
export function canManageProjectMembers(actions: { action: string; allowed: boolean }[] | null | undefined): boolean {
return !!actions?.some(a => MEMBER_MANAGE_ACTIONS.has(a.action) && a.allowed)
}
-1
View File
@@ -212,7 +212,6 @@ export interface RdmMemberRow {
}
export interface RdmMemberPage {
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
canManage?: boolean
}
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */
export interface PersonPickItem {
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
PROJECT_CREATE_LINKED_ACTIONS, buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft,
ensureLinkedCapabilities, linkedChainBreaks, projectCreateSources, rebaseGrantDraft, rebaseRoleDraft, setCapabilityGroup,
type RolePermissionValue,
} from './rolePermissions'
const catalog = [
{ actionId: 'project.create', label: '创建项目', group: '项目入口' },
{ actionId: 'project.settings', label: '项目设置', group: '项目管理' },
{ actionId: 'project.archive', label: '归档项目', group: '项目管理' },
{ actionId: 'project.stage.advance', label: '推进项目阶段', group: '项目管理' },
{ actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' },
{ actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' },
{ actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' },
{ actionId: 'document.read', label: '读取文档', group: '文档动作' },
{ actionId: 'document.author', label: '编制文档', group: '文档动作' },
{ actionId: 'document.review', label: '审核文档', group: '文档动作' },
{ actionId: 'future.action', label: '未来动作', group: '扩展' },
]
const off: RolePermissionValue = { allowed: false, capAllowed: false }
const values = (overrides: Record<string, RolePermissionValue> = {}) =>
({ ...Object.fromEntries(catalog.map(item => [item.actionId, off])), ...overrides })
describe('CapabilityGroup model', () => {
const groups = buildCapabilityGroups(catalog)
const create = groups.find(group => group.id === 'project-create')!
it('groups actions by business capability and keeps unknown actions visible', () => {
expect(groups.map(group => group.id)).toEqual(['project-create', 'project-governance', 'document-duties', 'document-read', 'catalog:扩展'])
expect(create.actions).toEqual(['project.create'])
expect(create.linked).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
const all = groups.flatMap(group => [...group.actions, ...group.linked]).sort()
expect(all).toEqual(catalog.map(item => item.actionId).sort())
})
it('enabling project creation links own-project management caps without granting them by default', () => {
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
expect(draft.values['project.create']).toEqual({ allowed: true, capAllowed: true })
for (const id of PROJECT_CREATE_LINKED_ACTIONS) expect(draft.values[id]).toEqual({ allowed: false, capAllowed: true })
expect(capabilityGroupState(draft.values, create)).toEqual({ allowed: 'on', capAllowed: 'on', linkedReady: true })
})
it('turning project creation off keeps already linked management caps', () => {
const enabled = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
const disabled = setCapabilityGroup(createRolePermissionDraft(enabled.values), create, 'capAllowed', false)
expect(disabled.values['project.create']).toEqual(off)
for (const id of PROJECT_CREATE_LINKED_ACTIONS) expect(disabled.values[id].capAllowed).toBe(true)
})
it('reports mixed group state and the grouped preview with linked additions', () => {
const duties = groups.find(group => group.id === 'document-duties')!
const draft = setCapabilityGroup(createRolePermissionDraft(values({ 'document.author': { allowed: true, capAllowed: true } })), create, 'capAllowed', true)
expect(capabilityGroupState(draft.values, duties).allowed).toBe('mixed')
const changes = capabilityGroupChanges(draft, groups)
expect(changes.map(change => change.group.id)).toEqual(['project-create'])
expect(changes[0].before.capAllowed).toBe('off')
expect(changes[0].after.capAllowed).toBe('on')
expect(changes[0].linkedAdded).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
})
it('detects and repairs a broken link from a server baseline before saving', () => {
const broken = createRolePermissionDraft(values({ 'project.create': { allowed: true, capAllowed: true } }))
expect(linkedChainBreaks(broken.values)).toEqual([...PROJECT_CREATE_LINKED_ACTIONS])
expect(linkedChainBreaks(ensureLinkedCapabilities(broken).values)).toEqual([])
})
it('preserves the grouped draft when an optimistic conflict is replayed on newer server values', () => {
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
const server = values({ 'document.review': { allowed: true, capAllowed: true } })
const rebased = rebaseRoleDraft(draft, server)
expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true })
expect(rebased.values['project.members.manage'].capAllowed).toBe(true)
expect(rebased.values['document.review']).toEqual({ allowed: true, capAllowed: true })
})
it('replays explicit grant edits on the latest grants after a version conflict', () => {
expect(rebaseGrantDraft(['system.users.view'], ['project.create'], ['system.users.view', 'system.roles.view']))
.toEqual(['project.create', 'system.roles.view'])
})
it('names every project creation source', () => {
const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }
expect(projectCreateSources(role, ['project.create'])).toEqual(['职位角色「项目经理」', '明确系统授权'])
expect(projectCreateSources(null, [])).toEqual([])
})
it('reports each business-group toggle position alongside the linked chain readiness', () => {
const governance = groups.find(group => group.id === 'project-governance')!
const duties = groups.find(group => group.id === 'document-duties')!
expect(capabilityGroupState(values(), governance)).toEqual({ allowed: 'off', capAllowed: 'off', linkedReady: true })
expect(capabilityGroupState(values({ 'document.author': { allowed: true, capAllowed: true },
'document.review': { allowed: false, capAllowed: true } }), duties)).toEqual({
allowed: 'mixed', capAllowed: 'on', linkedReady: true,
})
})
it('keeps the linked additions accurate when an unrelated group also changes', () => {
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'capAllowed', true)
draft.values['project.archive'] = { allowed: true, capAllowed: true }
const governanceId = 'project-governance'
const changes = capabilityGroupChanges(draft, groups)
const governance = changes.find(change => change.group.id === governanceId)
expect(governance).toBeDefined()
expect(governance!.linkedAdded).toEqual([])
})
it('keeps draft baseline identity during non-conflict rebases', () => {
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
const same = rebaseRoleDraft(draft, draft.values)
expect(same.values).toEqual(draft.values)
expect(same.baseline).toEqual(draft.values)
expect(same.generation).toBeGreaterThan(draft.generation)
})
})
+155
View File
@@ -61,6 +61,161 @@ export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: strin
return changes
}
// ---- 业务能力组(KTD1):普通界面按业务组配置,低层动作留给服务端鉴权与审计 ----
export const PROJECT_CREATE_ACTION = 'project.create'
/** 与 Backend catalog.ProjectCreateLinkedActions 同序:创建者在本人项目里的管理资格。 */
export const PROJECT_CREATE_LINKED_ACTIONS = [
'project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign',
] as const
interface CapabilityCatalogItem { actionId: string; label: string; group: string; protected?: boolean }
export interface CapabilityGroup {
id: string
label: string
description: string
/** 组开关直接控制的动作。 */
actions: string[]
/** 组内受控项:随主能力联动,不单独勾选。 */
linked: string[]
}
const BUSINESS_GROUPS: (Omit<CapabilityGroup, 'actions' | 'linked'> & { actions: readonly string[]; linked?: readonly string[] })[] = [
{ id: 'project-create', label: '创建与管理本人项目',
description: '创建新项目;本人创建的项目随之具备设置、成员增删、项目内权限与文档职责分配资格,不涉及他人项目',
actions: [PROJECT_CREATE_ACTION], linked: PROJECT_CREATE_LINKED_ACTIONS },
{ id: 'project-governance', label: '项目归档与阶段', description: '归档/恢复与推进本人创建项目的阶段',
actions: ['project.archive', 'project.stage.advance'] },
{ id: 'document-duties', label: '文档编制与审签', description: '履行已分配的文档编制、审核、批准与发布职责',
actions: ['document.author', 'document.review', 'document.approve', 'document.publish'] },
{ id: 'document-read', label: '文档阅读', description: '读取获准的项目文档;项目成员的基础协作另由成员关系提供',
actions: ['document.read'] },
{ id: 'project-content', label: '追溯与知识', description: '编辑追溯网、发布项目知识与读取业务审计',
actions: ['trace.edit', 'knowledge.publish', 'project.audit.read'] },
]
/** 目录里未归入业务组的动作按目录分组兜底,保证新增动作不会从界面消失。 */
export function buildCapabilityGroups(catalog: CapabilityCatalogItem[]): CapabilityGroup[] {
const known = new Set(catalog.map(item => item.actionId))
const claimed = new Set<string>()
const groups: CapabilityGroup[] = []
for (const group of BUSINESS_GROUPS) {
const actions = group.actions.filter(id => known.has(id))
if (actions.length === 0) continue
const linked = (group.linked ?? []).filter(id => known.has(id))
for (const id of [...actions, ...linked]) claimed.add(id)
groups.push({ id: group.id, label: group.label, description: group.description, actions, linked })
}
const rest = new Map<string, CapabilityGroup>()
for (const item of catalog) {
if (claimed.has(item.actionId)) continue
const group = rest.get(item.group) ?? { id: `catalog:${item.group}`, label: item.group, description: '', actions: [], linked: [] }
group.actions.push(item.actionId)
rest.set(item.group, group)
}
return [...groups, ...rest.values()]
}
export type GroupLevel = 'on' | 'off' | 'mixed'
function level(flags: boolean[]): GroupLevel {
if (flags.length > 0 && flags.every(Boolean)) return 'on'
return flags.some(Boolean) ? 'mixed' : 'off'
}
const OFF: RolePermissionValue = { allowed: false, capAllowed: false }
export function capabilityGroupState(values: Record<string, RolePermissionValue>, group: CapabilityGroup) {
const items = group.actions.map(id => values[id] ?? OFF)
return {
allowed: level(items.map(item => item.allowed)),
capAllowed: level(items.map(item => item.capAllowed)),
linkedReady: group.linked.every(id => (values[id] ?? OFF).capAllowed),
}
}
/** 创建能力开启(默认或上限)即补齐联动项上限;只开上限,不把管理动作默认授予所有成员项目。 */
export function ensureLinkedCapabilities(draft: RolePermissionDraft): RolePermissionDraft {
const create = draft.values[PROJECT_CREATE_ACTION]
if (!create?.allowed && !create?.capAllowed) return draft
let next = draft
for (const id of PROJECT_CREATE_LINKED_ACTIONS) {
const current = next.values[id]
if (!current || current.capAllowed) continue
next = updateRolePermission(next, id, { ...current, capAllowed: true })
}
return next
}
/** 组开关:沿用「默认不超过上限」耦合;关闭创建不反向删除已有联动资格。 */
export function setCapabilityGroup(
draft: RolePermissionDraft, group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean,
): RolePermissionDraft {
let next = draft
for (const id of group.actions) {
const current = next.values[id] ?? OFF
const value = field === 'allowed'
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
: { allowed: checked ? current.allowed : false, capAllowed: checked }
next = updateRolePermission(next, id, value)
}
return ensureLinkedCapabilities(next)
}
/** 创建能力已开但联动项上限仍关:断链,需在保存时补齐或由服务端整笔拒绝。 */
export function linkedChainBreaks(values: Record<string, RolePermissionValue>): string[] {
const create = values[PROJECT_CREATE_ACTION]
if (!create?.allowed && !create?.capAllowed) return []
return PROJECT_CREATE_LINKED_ACTIONS.filter(id => id in values && !values[id].capAllowed)
}
export interface CapabilityGroupChange {
group: CapabilityGroup
before: ReturnType<typeof capabilityGroupState>
after: ReturnType<typeof capabilityGroupState>
/** 本次联动补齐上限的受控项。 */
linkedAdded: string[]
}
export function capabilityGroupChanges(draft: RolePermissionDraft, groups: CapabilityGroup[]): CapabilityGroupChange[] {
const changed = (id: string) => {
const before = draft.baseline[id] ?? OFF
const after = draft.values[id] ?? OFF
return before.allowed !== after.allowed || before.capAllowed !== after.capAllowed
}
return groups.flatMap(group => {
if (![...group.actions, ...group.linked].some(changed)) return []
return [{
group,
before: capabilityGroupState(draft.baseline, group),
after: capabilityGroupState(draft.values, group),
linkedAdded: group.linked.filter(id => !(draft.baseline[id] ?? OFF).capAllowed && (draft.values[id] ?? OFF).capAllowed),
}]
})
}
/** 系统授权草稿冲突重放:只把本地相对旧基线的增删应用到最新服务端授权上。 */
export function rebaseGrantDraft(baseline: string[], local: string[], server: string[]): string[] {
const before = new Set(baseline)
const now = new Set(local)
const next = new Set(server)
for (const id of now) if (!before.has(id)) next.add(id)
for (const id of before) if (!now.has(id)) next.delete(id)
return [...next].sort()
}
/** 某账号 project.create 的实际来源(角色默认与明确系统授权可并存)。
* getUserRole 的角色摘要不带 permissions,缺失按「无角色默认权限」处理。 */
export function projectCreateSources(
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined, grants: string[],
): string[] {
const sources: string[] = []
if (role?.permissions?.some(item => item.actionId === PROJECT_CREATE_ACTION && item.allowed)) sources.push(`职位角色「${role.name}」`)
if (grants.includes(PROJECT_CREATE_ACTION)) sources.push('明确系统授权')
return sources
}
export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null {
for (const actionId of Object.keys(draft.values).sort()) {
const value = draft.values[actionId]
+407
View File
@@ -0,0 +1,407 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { auth } from './auth'
import {
PROJECT_SYNC_INTERVAL_MS, PROJECT_SYNC_RENDER_BUDGET_MS, PROJECT_SYNC_REQUEST_BUDGET_MS,
createProjectAutoDiscover, projectSyncDelay, store, type ProjectSyncEnv, type ProjectSyncStatus,
} from './store'
import type { ConversationView } from './api'
import type { Project } from './contracts'
const project = (id: string, owner = 'alice'): Project => ({ id, owner, name: id, createdAt: '', updatedAt: '' })
const task = (id: string, projectId: string): ConversationView => ({ id, projectId, title: id, createdAt: '', updatedAt: '' })
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((ok, fail) => { resolve = ok; reject = fail })
return { promise, resolve, reject }
}
/** 可控的页面环境:可见性、联网与 wake/sleep 事件由测试驱动。 */
function fakeEnv() {
const env = {
visible: true,
online: true,
handlers: null as null | { wake: () => void; sleep: (status: 'paused' | 'offline') => void },
}
const port: ProjectSyncEnv = {
visible: () => env.visible,
online: () => env.online,
subscribe: handlers => { env.handlers = handlers; return () => { env.handlers = null } },
}
return {
port,
hide() { env.visible = false; env.handlers?.sleep('paused') },
show() { env.visible = true; env.handlers?.wake() },
offline() { env.online = false; env.handlers?.sleep('offline') },
online() { env.online = true; env.handlers?.wake() },
focus() { env.handlers?.wake() },
}
}
let projects: Project[]
let tasks: ConversationView[]
let requests: { path: string; init?: RequestInit }[]
let route: (path: string, init?: RequestInit) => Promise<Response>
beforeEach(async () => {
projects = [project('host-a')]
tasks = [task('conv-a', 'host-a')]
requests = []
const saved = new Map<string, string>()
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
setItem: (key: string, value: string) => { saved.set(key, value) },
removeItem: (key: string) => { saved.delete(key) } })
route = async (path, init) => {
if (path === '/api/projects' && !init?.method) return response(projects)
if (path === '/api/conversations' && !init?.method) return response(tasks.filter(t => projects.some(p => p.id === t.projectId)))
if (path === '/api/conversations/conv-a') return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 })
if (path === '/api/me') return response({ owner: 'bob', name: 'Bob', auth: true, systemActions: [] })
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([])
if (path.endsWith('/cards')) return response({ cards: [] })
return response({})
}
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
requests.push({ path, init })
return route(path, init)
})
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
auth.beginSession()
await store.refreshMe()
await store.refreshProjects()
await store.openTask('conv-a')
requests = []
})
afterEach(() => {
store.stopProjectSync()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
async function advanceUntil(check: () => boolean, limitMs: number, stepMs = 250): Promise<number> {
let elapsed = 0
while (!check() && elapsed < limitMs) {
await vi.advanceTimersByTimeAsync(stepMs)
elapsed += stepMs
}
return elapsed
}
describe('AutoDiscover project list', () => {
it('keeps the visible-page budget inside 5 seconds', () => {
expect(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS + PROJECT_SYNC_RENDER_BUDGET_MS).toBeLessThanOrEqual(5000)
expect(projectSyncDelay(0)).toBe(PROJECT_SYNC_INTERVAL_MS)
})
it('surfaces a project the member was added to within 5 seconds without switching the current work', async () => {
vi.useFakeTimers()
const env = fakeEnv()
store.startProjectSync(env.port)
projects = [project('host-a'), project('host-b', 'carol')]
const elapsed = await advanceUntil(() => store.getState().projects.some(p => p.id === 'host-b'), 5000)
expect(store.getState().projects.map(p => p.id)).toEqual(['host-a', 'host-b'])
expect(elapsed).toBeLessThanOrEqual(5000)
expect(store.getState()).toMatchObject({ currentProjectId: 'host-a', currentConvId: 'conv-a', projectsLoaded: true })
expect(requests.map(r => r.path)).not.toContain('/api/conversations/conv-a')
expect(store.getState().projectSync.status).toBe('ok')
})
it('clears a removed current project within 5 seconds and keeps the remaining projects', async () => {
projects = [project('host-a'), project('host-b', 'carol')]
await store.refreshProjects()
vi.useFakeTimers()
store.startProjectSync(fakeEnv().port)
projects = [project('host-b', 'carol')]
const elapsed = await advanceUntil(() => store.getState().currentProjectId !== 'host-a', 5000)
expect(elapsed).toBeLessThanOrEqual(5000)
expect(store.getState()).toMatchObject({ currentProjectId: 'host-b', currentConvId: null })
expect(store.getState().projects.map(p => p.id)).toEqual(['host-b'])
expect(store.getState().tasks.map(t => t.id)).toEqual([])
})
it('does not re-render or re-open anything when the list is unchanged', async () => {
vi.useFakeTimers()
store.startProjectSync(fakeEnv().port)
const before = store.getState()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 3)
expect(requests.filter(r => r.path === '/api/projects')).toHaveLength(3)
expect(store.getState().projects).toBe(before.projects)
expect(requests.map(r => r.path)).not.toContain('/api/conversations')
})
})
describe('RefreshSingleFlight', () => {
it('never overlaps requests and folds wake-ups during a request into one immediate follow-up', async () => {
vi.useFakeTimers()
const env = fakeEnv()
let concurrent = 0
let peak = 0
const calls: ReturnType<typeof deferred<void>>[] = []
const fetchOnce = vi.fn(() => {
concurrent++; peak = Math.max(peak, concurrent)
const call = deferred<void>()
calls.push(call)
return call.promise.finally(() => { concurrent-- })
})
const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {})
sync.start()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
expect(fetchOnce).toHaveBeenCalledTimes(1)
env.focus(); env.online(); env.show()
await vi.advanceTimersByTimeAsync(500)
expect(fetchOnce).toHaveBeenCalledTimes(1)
calls[0].resolve()
await vi.advanceTimersByTimeAsync(0)
expect(fetchOnce).toHaveBeenCalledTimes(2)
calls[1].resolve()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS - 1)
expect(fetchOnce).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(fetchOnce).toHaveBeenCalledTimes(3)
expect(peak).toBe(1)
sync.stop()
})
it('schedules the next round only after the current one finishes', async () => {
vi.useFakeTimers()
const slow = deferred<void>()
const fetchOnce = vi.fn().mockReturnValueOnce(slow.promise).mockResolvedValue(undefined)
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, () => {})
sync.start()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + 1500)
expect(fetchOnce).toHaveBeenCalledTimes(1)
slow.resolve()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
expect(fetchOnce).toHaveBeenCalledTimes(2)
sync.stop()
})
})
describe('RefreshBackoff', () => {
it('reports a sync failure, backs off exponentially and returns to the 2-second rhythm on success', async () => {
vi.useFakeTimers()
const statuses: [ProjectSyncStatus, number][] = []
let fail = true
const fetchOnce = vi.fn(async () => { if (fail) throw new Error('503: unavailable') })
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, (status, failures) => statuses.push([status, failures]))
sync.start()
await vi.advanceTimersByTimeAsync(2000)
expect(statuses.at(-1)).toEqual(['degraded', 1])
await vi.advanceTimersByTimeAsync(3999)
expect(fetchOnce).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(fetchOnce).toHaveBeenCalledTimes(2)
expect(statuses.at(-1)).toEqual(['degraded', 2])
fail = false
await vi.advanceTimersByTimeAsync(8000)
expect(fetchOnce).toHaveBeenCalledTimes(3)
expect(statuses.at(-1)).toEqual(['ok', 0])
await vi.advanceTimersByTimeAsync(2000)
expect(fetchOnce).toHaveBeenCalledTimes(4)
expect(projectSyncDelay(10)).toBe(30000)
sync.stop()
})
it('aborts a request that exceeds its 2-second budget and counts it as a failure', async () => {
vi.useFakeTimers()
let signal: AbortSignal | undefined
const fetchOnce = vi.fn((s: AbortSignal) => { signal = s; return new Promise<void>(() => {}) })
const statuses: ProjectSyncStatus[] = []
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, status => statuses.push(status))
sync.start()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS)
expect(signal?.aborted).toBe(true)
expect(statuses.at(-1)).toBe('degraded')
sync.stop()
})
it('shows the store sync state as degraded while the list endpoint fails', async () => {
vi.useFakeTimers()
const previous = route
route = (path, init) => path === '/api/projects' ? Promise.resolve(response({ error: 'down' }, 503)) : previous(path, init)
store.startProjectSync(fakeEnv().port)
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
expect(store.getState().projectSync).toEqual({ status: 'degraded', failures: 1 })
expect(store.getState().projects.map(p => p.id)).toEqual(['host-a'])
})
})
describe('RefreshHiddenPause', () => {
it('pauses on a hidden page and resumes immediately when it becomes visible again', async () => {
vi.useFakeTimers()
const env = fakeEnv()
const statuses: ProjectSyncStatus[] = []
const fetchOnce = vi.fn(async () => {})
const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status))
sync.start()
env.hide()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 10)
expect(fetchOnce).not.toHaveBeenCalled()
expect(statuses.at(-1)).toBe('paused')
env.show()
await vi.advanceTimersByTimeAsync(0)
expect(fetchOnce).toHaveBeenCalledTimes(1)
expect(statuses.at(-1)).toBe('ok')
sync.stop()
})
it('does not start when the page is already hidden', async () => {
vi.useFakeTimers()
const env = fakeEnv()
env.hide()
const fetchOnce = vi.fn(async () => {})
const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {})
sync.start()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5)
expect(fetchOnce).not.toHaveBeenCalled()
sync.stop()
})
})
describe('RefreshReconnectCompensate', () => {
it('stops while offline and re-checks immediately when the network returns', async () => {
vi.useFakeTimers()
const env = fakeEnv()
const statuses: ProjectSyncStatus[] = []
const fetchOnce = vi.fn(async () => {})
const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status))
sync.start()
env.offline()
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5)
expect(fetchOnce).not.toHaveBeenCalled()
expect(statuses.at(-1)).toBe('offline')
env.online()
await vi.advanceTimersByTimeAsync(0)
expect(fetchOnce).toHaveBeenCalledTimes(1)
sync.stop()
})
it('re-checks immediately on focus instead of waiting for the next interval', async () => {
vi.useFakeTimers()
const env = fakeEnv()
store.startProjectSync(env.port)
projects = [project('host-a'), project('host-b', 'carol')]
env.focus()
await vi.advanceTimersByTimeAsync(0)
expect(store.getState().projects.map(p => p.id)).toContain('host-b')
})
it('drops a late list from the previous identity after a reconnect under another account', async () => {
vi.useFakeTimers()
const env = fakeEnv()
const late = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects' ? late.promise : previous(path, init)
store.startProjectSync(env.port)
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
auth.beginSession()
route = previous
projects = [project('host-c', 'dave')]
await store.refreshProjects()
late.resolve(response([project('host-a'), project('alice-secret')]))
await vi.advanceTimersByTimeAsync(0)
expect(store.getState().projects.map(p => p.id)).toEqual(['host-c'])
})
})
function routeScratchSession() {
const previous = route
route = (path, init) => {
if (path === '/api/scratch-sessions' && init?.method === 'POST') {
return Promise.resolve(response({ project: project('scratch-1'), conversation: { ...task('scratch-conv', 'scratch-1'), scratch: true } }))
}
if (path === '/api/conversations/scratch-conv') return Promise.resolve(response({ conversation: { ...task('scratch-conv', 'scratch-1'), scratch: true }, messages: [], runs: [], activeHitl: [], lastSeq: 0 }))
return previous(path, init)
}
}
describe('DefaultProjectDisabled', () => {
it('does not create a default project when the member has no projects yet', async () => {
projects = []
await store.refreshProjects()
routeScratchSession()
requests = []
await store.createTask()
const posts = requests.filter(r => r.init?.method === 'POST').map(r => r.path)
expect(posts).toEqual(['/api/scratch-sessions'])
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-conv', currentProjectId: 'scratch-1' })
})
it('does not treat a list that has not arrived as empty', async () => {
auth.beginSession()
const pending = deferred<Response>()
route = path => path === '/api/projects' ? pending.promise : Promise.resolve(response({}))
void store.refreshProjects()
await expect(store.createTask()).rejects.toThrow(/尚未加载/)
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
pending.resolve(response([]))
})
it('points a creator to explicit project creation instead of creating one silently', async () => {
const previous = route
route = (path, init) => path === '/api/me'
? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true, systemActions: ['project.create'],
linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } }))
: previous(path, init)
await store.refreshMe()
projects = []
await store.refreshProjects()
routeScratchSession()
requests = []
await store.createTask()
expect(requests.filter(r => r.path === '/api/projects' && r.init?.method === 'POST')).toEqual([])
expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/scratch-sessions'])
expect(store.getState().projects).toEqual([])
})
it('surfaces the stage-specific project error before any POST is sent', async () => {
projects = [{ ...project('host-archived'), settings: { 'rdm.state': 'archived' } } as Project]
await store.refreshProjects()
requests = []
await expect(store.createTask()).rejects.toThrow(/请先新建项目|归档/)
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
})
})
describe('AutoDiscover sync ownership on lifecycle events', () => {
it('surfaces sync start and stop without leaking listeners across re-starts', () => {
const env = fakeEnv()
const release: Array<() => void> = []
const port: ProjectSyncEnv = { ...env.port, subscribe: handlers => {
const releaseFn = () => {}
release.push(releaseFn)
env.port.subscribe(handlers)
return () => { releaseFn() }
} }
store.startProjectSync(port)
store.stopProjectSync()
store.startProjectSync(port)
expect(release.length).toBe(2)
store.stopProjectSync()
})
it('keeps the store sync state idle after a clean stop and ready to re-start', () => {
store.startProjectSync(fakeEnv().port)
store.stopProjectSync()
expect(store.getState().projectSync.status).toBe('idle')
expect(store.getState().projectSync.failures).toBe(0)
})
it('drops the late response when an identity reset interleaves a sync', async () => {
vi.useFakeTimers()
const env = fakeEnv()
const pending = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects' ? pending.promise : previous(path, init)
store.startProjectSync(env.port)
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
store.resetIdentity()
pending.resolve(response([project('host-late')]))
await vi.advanceTimersByTimeAsync(0)
expect(store.getState().projects.find(p => p.id === 'host-late')).toBeUndefined()
store.stopProjectSync()
})
})
+294 -48
View File
@@ -1,9 +1,9 @@
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { type AdminZoneSection, api, connectEvents } from './api'
import { type AdminZoneSection, type ConversationView, type LinkedCapabilities, api, connectEvents } from './api'
import { auth } from './auth'
import { projectActionError, projectWorkError } from './projectmeta'
import { noProjectError, projectActionError, projectWorkError } from './projectmeta'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox'
@@ -11,7 +11,7 @@ import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces'
import { cockpitReady, rdmAuditOperationsReady, rdmAuditReady, rdmExportReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement'
import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation'
@@ -26,7 +26,6 @@ import type {
TaskSourceRef,
AgentProfile,
Artifact,
Conversation,
HITLCard,
Message,
PlanSnapshot,
@@ -85,19 +84,38 @@ export interface UIState {
taskOrg: 'project' | 'timeline'
showArchived: boolean
lastProjectId: string | null
/** 用户上次停留在无项目对话视图;刷新后回到同一视图。 */
lastScratch: boolean
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
adminZoneOpen: boolean
changePwOpen: boolean
projectSettingsId: string | null
}
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] }
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }
/** 能否新建真实项目(无项目对话不需要此能力);服务端仍按当前身份最终判定。 */
export function canCreateProject(me: Pick<Me, 'auth' | 'systemActions'> | null | undefined): boolean {
return !!me && (!me.auth || !!me.systemActions?.includes('project.create'))
}
/** KTD9 项目自动发现:可见页每 2 秒串行重查;单次请求预算 2 秒、渲染预留 1 秒,正常链路 5 秒内可见。 */
export const PROJECT_SYNC_INTERVAL_MS = 2000
export const PROJECT_SYNC_REQUEST_BUDGET_MS = 2000
export const PROJECT_SYNC_RENDER_BUDGET_MS = 1000
const PROJECT_SYNC_MAX_BACKOFF_MS = 30000
export type ProjectSyncStatus = 'idle' | 'ok' | 'degraded' | 'paused' | 'offline'
interface AppState {
contextObs: Record<string, ContextObs>
projects: Project[]
/** 本身份的项目列表是否已到达过;未到达不等于空列表。 */
projectsLoaded: boolean
projectSync: { status: ProjectSyncStatus; failures: number }
currentProjectId: string | null
tasks: Conversation[]
/** 无项目对话视图:currentProjectId 此时是无项目对话的内部项目,不在 projects 里。 */
scratchMode: boolean
tasks: ConversationView[]
me: Me | null
currentConvId: string | null
messages: Message[]
@@ -129,6 +147,8 @@ interface AppState {
rdmCockpitAvailable: boolean
/** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */
rdmExportAvailable: boolean
/** 「审计记录」页签(W10):五条 W8b/W5 命令都启用才为 true。不参与七命令就绪门。 */
rdmAuditOperationsAvailable: boolean
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
rdmRolesAvailable: boolean
rdmMembersAvailable: boolean
@@ -171,6 +191,7 @@ filesView: 'list', workspaceSurfacePin: null,
taskOrg: 'project',
showArchived: false,
lastProjectId: null,
lastScratch: false,
adminZoneOpen: false,
changePwOpen: false,
projectSettingsId: null,
@@ -199,13 +220,15 @@ const surfaceActionsInflight: Record<string, boolean> = {}
let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
projects: [], projectsLoaded: false, projectSync: { status: 'idle', failures: 0 },
currentProjectId: null, scratchMode: false, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
rdmAuditAvailable: false,
rdmCockpitAvailable: false,
rdmExportAvailable: false,
rdmAuditOperationsAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
rdmRender: NO_RENDER_CAPABILITIES,
adminSections: [],
@@ -322,6 +345,44 @@ const actionInflight = new Set<string>()
const actionFeedbackOwners: Record<string, symbol> = {}
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
/** 离开当前项目:关闭项目/会话流并清空项目内投影(不触碰服务端历史)。 */
function resetProjectScope() {
projectSse?.close(); projectSse = null
sse?.close(); sse = null
projectSeq = 0
projectCardsRequest++
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
artifacts: [], outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false, filesView: 'list' })
}
/** remember=false 用于没有真实项目时的自动进入:不改写用户上次选择的视图。 */
function setScratchMode(on: boolean, remember = true) {
if (state.scratchMode !== on) set({ scratchMode: on })
if (remember && state.ui.lastScratch !== on) setUI({ lastScratch: on })
}
/** 绑定会话所属项目(真实项目或无项目对话的内部项目),供上传与工件归属。 */
async function bindProject(projectId: string, remember: boolean) {
if (state.currentProjectId !== projectId) resetProjectScope()
set({ currentProjectId: projectId })
if (state.inboxScope === 'project') {
store.loadProjectCards()
store.connectProject()
}
if (remember) setUI({ lastProjectId: projectId })
await store.refreshArtifacts(projectId)
}
function latestScratchTask(): ConversationView | undefined {
return state.tasks.filter(t => t.scratch && !t.archived)
.reduce<ConversationView | undefined>((latest, t) => !latest || t.updatedAt > latest.updatedAt ? t : latest, undefined)
}
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
const version = ++detailVersion
observations[id] = { ...observations[id], version, seq,
@@ -438,9 +499,144 @@ export function useStore<T>(selector: (s: AppState) => T): T {
return useSyncExternalStore(
(cb) => { listeners.add(cb); return () => listeners.delete(cb) },
() => selector(state),
() => selector(state),
)
}
export interface ProjectSyncEnv {
visible: () => boolean
online: () => boolean
/** wake:重新聚焦/页面恢复可见/网络恢复;sleep:页面隐藏或断网。 */
subscribe: (handlers: { wake: () => void; sleep: (status: 'paused' | 'offline') => void }) => () => void
}
export function projectSyncDelay(failures: number): number {
return failures <= 0 ? PROJECT_SYNC_INTERVAL_MS : Math.min(PROJECT_SYNC_MAX_BACKOFF_MS, PROJECT_SYNC_INTERVAL_MS * 2 ** failures)
}
/**
* 单一所有者的串行重查:同一时刻至多一个请求;进行中被唤醒只记一次补偿,完成后立即再查。
* 下一轮在本轮结束后才排期(不重叠);失败/超时按指数退避,成功或唤醒补偿后恢复 2 秒节奏。
*/
export function createProjectAutoDiscover(
fetchOnce: (signal: AbortSignal) => Promise<void>,
env: ProjectSyncEnv,
report: (status: ProjectSyncStatus, failures: number) => void,
) {
let active = false
let inflight = false
let pending = false
let failures = 0
let timer: ReturnType<typeof setTimeout> | null = null
let controller: AbortController | null = null
let unsubscribe: (() => void) | null = null
const clear = () => { if (timer !== null) { clearTimeout(timer); timer = null } }
const blocked = (): 'paused' | 'offline' | null => !env.online() ? 'offline' : !env.visible() ? 'paused' : null
const schedule = () => {
clear()
const reason = blocked()
if (reason) { report(reason, failures); return }
timer = setTimeout(() => { timer = null; void run() }, projectSyncDelay(failures))
}
const run = async (): Promise<void> => {
if (!active) return
const reason = blocked()
if (reason) { clear(); report(reason, failures); return }
if (inflight) { pending = true; return }
clear()
inflight = true
const own = new AbortController()
controller = own
const budget = setTimeout(() => own.abort(), PROJECT_SYNC_REQUEST_BUDGET_MS)
const aborted = new Promise<never>((_, reject) =>
own.signal.addEventListener('abort', () => reject(new Error('project sync timeout')), { once: true }))
try {
await Promise.race([fetchOnce(own.signal), aborted])
if (active && controller === own) { failures = 0; report('ok', 0) }
} catch {
if (active && controller === own) { failures++; report('degraded', failures) }
} finally {
clearTimeout(budget)
inflight = false
if (controller === own) controller = null
}
if (!active) return
if (pending) { pending = false; return run() }
schedule()
}
return {
start() {
if (active) return
active = true
unsubscribe = env.subscribe({
wake: () => { void run() },
sleep: status => { clear(); report(status, failures) },
})
schedule()
},
stop() {
active = false; pending = false; failures = 0
clear()
controller?.abort(); controller = null
unsubscribe?.(); unsubscribe = null
report('idle', 0)
},
trigger: () => run(),
}
}
function browserProjectSyncEnv(): ProjectSyncEnv {
return {
visible: () => typeof document === 'undefined' || document.visibilityState !== 'hidden',
online: () => typeof navigator === 'undefined' || navigator.onLine !== false,
subscribe: ({ wake, sleep }) => {
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}
const onVisibility = () => { if (document.visibilityState === 'hidden') sleep('paused'); else wake() }
const onOffline = () => sleep('offline')
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('focus', wake)
window.addEventListener('online', wake)
window.addEventListener('offline', onOffline)
return () => {
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('focus', wake)
window.removeEventListener('online', wake)
window.removeEventListener('offline', onOffline)
}
},
}
}
let projectSync: ReturnType<typeof createProjectAutoDiscover> | null = null
const projectIdsKey = (projects: Project[]) => projects.map(p => p.id).sort().join('\n')
/** 列表合并只更新投影:不重复 openProject、不切对话、不重建流;仅当前项目失效时清除其状态。 */
async function mergeProjectList(projects: Project[]) {
if (state.projectsLoaded && JSON.stringify(state.projects) === JSON.stringify(projects)) return
const membershipChanged = !state.projectsLoaded || projectIdsKey(state.projects) !== projectIdsKey(projects)
set({ projects, projectsLoaded: true, error: null })
const current = state.currentProjectId
// 无项目对话视图绑定的是不在列表里的内部项目,列表变化不切换视图。
if (state.scratchMode) { /* keep the scratch view */ }
else if (current && !projects.some(p => p.id === current)) {
const fallback = projects[0]
if (fallback) await store.openProject(fallback.id)
else {
resetProjectScope()
set({ currentProjectId: null })
setUI({ lastProjectId: null })
setScratchMode(true, false)
}
store.toast('你已不在原项目中,已清除该项目的入口')
} else if (!current) {
const next = projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
if (next) await store.openProject(next.id)
else setScratchMode(true, false)
}
if (membershipChanged) await store.refreshTasks()
}
export const store = {
getState: () => state,
getIdentityEpoch: () => identityEpoch,
@@ -460,16 +656,17 @@ export const store = {
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
convRestoreDone = true
persistConvId(null)
set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
set({ projects: [], projectsLoaded: false, currentProjectId: null, scratchMode: false, tasks: [], me: null, currentConvId: null,
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmAuditOperationsAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES,
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
setUI({ lastProjectId: null, lastScratch: false, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
settingsOpen: false, changePwOpen: false, trackerRunId: null, workspaceSurfacePin: null,
previewExpanded: false, filesView: 'list', showArchived: false })
},
@@ -477,16 +674,45 @@ export const store = {
async refreshProjects() {
const epoch = identityEpoch
const request = ++projectsRequest
let scratch = false
try {
const projects = await api.listProjects()
if (epoch !== identityEpoch || request !== projectsRequest) return
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
set({ projects, currentProjectId: current?.id ?? null, error: null })
if (current) await store.openProject(current.id)
// 没有真实项目(或上次停留在无项目对话)时进入无项目对话视图,而不是停在空项目上。
scratch = projects.length === 0 || state.ui.lastScratch
if (scratch) set({ projects, projectsLoaded: true, error: null })
else {
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
set({ projects, projectsLoaded: true, currentProjectId: current?.id ?? null, error: null })
if (current) await store.openProject(current.id)
}
} catch (e) {
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
}
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
if (scratch && epoch === identityEpoch && request === projectsRequest) await store.enterScratchMode(state.ui.lastScratch)
},
/** 自动发现的一次重查:与 refreshProjects 共用请求代次,迟到/旧身份响应丢弃;失败抛出供退避。 */
async syncProjects(signal?: AbortSignal) {
const epoch = identityEpoch
const request = ++projectsRequest
const projects = await api.listProjects(signal)
if (epoch !== identityEpoch || request !== projectsRequest) return
await mergeProjectList(projects)
},
startProjectSync(env: ProjectSyncEnv = browserProjectSyncEnv()) {
projectSync?.stop()
projectSync = createProjectAutoDiscover(signal => store.syncProjects(signal), env, (status, failures) => {
if (state.projectSync.status !== status || state.projectSync.failures !== failures) set({ projectSync: { status, failures } })
})
projectSync.start()
},
stopProjectSync() {
projectSync?.stop()
projectSync = null
},
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
@@ -495,14 +721,16 @@ export const store = {
const request = ++tasksRequest
try {
const tasks = await api.listTasks(includeArchived)
if (epoch !== identityEpoch || request !== tasksRequest) return
if (epoch !== identityEpoch || request !== tasksRequest || !Array.isArray(tasks)) return
set({ tasks })
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
if (!convRestoreDone) {
convRestoreDone = true
const saved = savedConvId()
if (saved && !state.currentConvId && tasks.some(t => t.id === saved && !t.archived)) {
store.openConversation(saved)
const restored = saved ? tasks.find(t => t.id === saved && !t.archived) : undefined
if (restored && !state.currentConvId) {
if (restored.scratch) await store.openTask(restored.id)
else store.openConversation(restored.id)
}
}
} catch { /* 列表竞态,静默 */ }
@@ -540,40 +768,44 @@ export const store = {
}
},
/** 打开真实项目:同时离开无项目对话视图。 */
async openProject(projectId: string) {
if (state.currentProjectId !== projectId) {
projectSse?.close(); projectSse = null
sse?.close(); sse = null
projectSeq = 0
projectCardsRequest++
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
artifacts: [], outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false, filesView: 'list' })
}
set({ currentProjectId: projectId })
if (store.getState().inboxScope === 'project') {
store.loadProjectCards()
store.connectProject()
}
store.setUI({ lastProjectId: projectId })
await store.refreshArtifacts(projectId)
setScratchMode(false)
await bindProject(projectId, true)
},
/** 进入无项目对话视图:当前会话不是无项目对话时清空会话视图,并打开最近一条未归档的无项目对话。 */
async enterScratchMode(remember = true) {
const epoch = identityEpoch
const current = state.tasks.find(t => t.id === state.currentConvId)
setScratchMode(true, remember)
if (current?.scratch) return
if (state.currentProjectId || state.currentConvId) resetProjectScope()
set({ currentProjectId: null })
const latest = latestScratchTask()
if (latest && epoch === identityEpoch) await store.openTask(latest.id)
},
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
* 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */
* 无项目对话视图、或项目列表已到达且没有真实项目时,新建无项目对话;从不代建真实项目。 */
async createTask(projectId?: string) {
const epoch = identityEpoch
const changed = () => { if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') }
try {
if (state.scratchMode || (!projectId && !state.currentProjectId && state.projectsLoaded && state.projects.length === 0)) {
const { conversation } = await api.createScratchSession()
changed()
setScratchMode(true)
await store.refreshTasks()
changed()
if (!state.tasks.some(t => t.id === conversation.id)) set({ tasks: [{ ...conversation, scratch: true }, ...state.tasks] })
await store.openTask(conversation.id)
changed()
return
}
let pid = projectId ?? state.currentProjectId
if (!pid && state.projects.length > 0) pid = state.projects[0].id
if (!pid) {
const p = await store.createProject('默认项目')
pid = p.id
}
if (!pid) throw new Error(noProjectError(state.projectsLoaded, canCreateProject(state.me)))
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
const error = projectWorkError(state.projects.find(p => p.id === pid))
if (error) throw new Error(error)
@@ -597,12 +829,15 @@ export const store = {
if (title) store.toast('已在当前项目创建任务')
},
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确);视图随任务进入或离开无项目对话。 */
async openTask(convId: string) {
const epoch = identityEpoch
const t = state.tasks.find(x => x.id === convId)
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
if (t?.scratch) {
if (t.projectId !== state.currentProjectId) await bindProject(t.projectId, false)
} else if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
if (epoch !== identityEpoch) return
if (t) setScratchMode(!!t.scratch)
await store.openConversation(convId)
},
@@ -653,7 +888,7 @@ export const store = {
}
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
// surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
if (state.currentProjectId) api.projectSurfaces(state.currentProjectId).then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
api.surfaceList(convId).then(async list => {
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
const metas = list ?? []
@@ -981,10 +1216,12 @@ export const store = {
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [], ...(taskRef ? { taskRef } : {}) }) })
return 'busy'
}
store.toast(`发送失败:${e}`)
const removed = isNotProjectMember(e)
store.toast(removed ? '无法继续发送:你已不在该项目中或项目已关闭;历史对话仍可查看' : `发送失败:${e}`)
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds,
attachmentNames: attachmentIds.map(attachmentId => state.artifacts.find(artifact => artifact.id === attachmentId)?.name ?? '已添加材料'),
...(taskRef ? { taskRef } : {}), deliveryError: '发送未成功,内容已保留;可修改后重试或移除' }) })
...(taskRef ? { taskRef } : {}),
deliveryError: removed ? '无法发送:你已不在该项目中或项目已关闭,内容已保留' : '发送未成功,内容已保留;可修改后重试或移除' }) })
await store.reconcile()
return 'rejected'
} finally {
@@ -1232,6 +1469,7 @@ export const store = {
rdmAuditAvailable: rdmAuditReady(cmds),
rdmCockpitAvailable: cockpitReady(cmds),
rdmExportAvailable: rdmExportReady(cmds),
rdmAuditOperationsAvailable: rdmAuditOperationsReady(cmds),
rdmRolesAvailable: rdmRolesReady(cmds),
rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDutyReady(cmds),
@@ -1240,7 +1478,8 @@ export const store = {
})
} catch {
if (epoch !== identityEpoch) return
set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmAuditOperationsAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES })
}
try {
@@ -1556,6 +1795,11 @@ function isConversationBusy(err: unknown): boolean {
return String(err).includes('409:')
}
/** 已被移出项目或项目已关闭:后端以 403 `conversation: not-project-member` 拒绝继续发送。 */
function isNotProjectMember(err: unknown): boolean {
return String(err).includes('not-project-member')
}
function shellPhase() {
const liveRunId = state.live?.runId ?? null
return deriveShellPhase({
@@ -1580,5 +1824,7 @@ export function mergeStartedRun(runs: Run[], run: Run): Run[] {
export function eventTime(e: { ts?: string }, now = Date.now()): number {
if (!e.ts) return now
const t = Date.parse(e.ts)
return Number.isNaN(t) || t <= 0 ? now : t
// 后端 0 值占位(0001-01-01T00:00:00Z 等)会让状态栏读到巨大耗时,回落 now 让「已工作」保持真实时钟。
if (Number.isNaN(t) || t <= 0) return now
return t
}
+3
View File
@@ -669,6 +669,9 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.rdm-audit-export-result { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-export-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-wrap { overflow-wrap: anywhere; min-width: 0; }
.rdm-audit-op .rdm-audit-opgroup-head, .rdm-audit-op-pending { flex-wrap: wrap; }
.rdm-audit-op-object { color: var(--text); overflow-wrap: anywhere; min-width: 0; }
.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; }
+15
View File
@@ -54,6 +54,21 @@ export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
/** 「审计记录」页签(W10)消费的 W8b/W5 命令:操作记录列表、受控覆盖面板、影响分析快照与重算。不并入七命令就绪门。 */
export const RDM_AUDIT_OPERATIONS_COMMANDS = [
'rdm.audit.operations',
'rdm.trace.coverage',
'rdm.trace.pending-recheck',
'rdm.compliance.impact.get',
'rdm.compliance.impact.recompute',
] as const
/** 五命令齐备且 enabled 才展示「审计记录」页签(能力缺失即不暴露,避免进入即 400)。 */
export function rdmAuditOperationsReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return RDM_AUDIT_OPERATIONS_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
/** 文档驾驶舱消费的全部 rdm.cockpit.* 命令(I1 只读面;rollout I2)。 */
export const COCKPIT_COMMANDS = [
'rdm.cockpit.overview',
+61
View File
@@ -0,0 +1,61 @@
// 管理端用户面板两处缺陷的回归锁定:
// (1) getUserRole 的 role 摘要不带 permissions(线上实测),projectCreateSources 不得因此抛异常;
// 展示来源时用已加载角色列表里同一 roleId 的条目补齐权限,不新增请求。
// (2) 一次性登录密码按服务端严格解码要求发送 JSON 对象体。
import { afterEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { api, type RoleRecord } from '../src/api'
import { auth } from '../src/auth'
import { ProjectCreateSourceNote, roleWithPermissions } from '../src/components/AccountUsersPanel'
import { projectCreateSources } from '../src/rolePermissions'
const listed: RoleRecord = {
roleId: 'role-pm', name: '项目经理', description: '研发', enabled: true, roleVersion: 3, bindingCount: 1,
permissions: [
{ actionId: 'project.create', allowed: true, capAllowed: true, permissionVersion: 2 },
{ actionId: 'document.read', allowed: true, capAllowed: true, permissionVersion: 1 },
],
}
/** getUserRole 的真实响应形状:有角色字段、没有 permissions。 */
const summary = { roleId: 'role-pm', name: '项目经理', description: '研发', enabled: true, roleVersion: 3, bindingCount: 1 }
afterEach(() => vi.restoreAllMocks())
describe('角色摘要缺少 permissions 时的创建能力来源', () => {
it('把缺失的 permissions 当作空集,不抛异常也不虚构角色来源', () => {
expect(projectCreateSources({ name: '项目经理' }, [])).toEqual([])
expect(projectCreateSources({ name: '项目经理' }, ['project.create'])).toEqual(['明确系统授权'])
expect(projectCreateSources({ ...summary, permissions: undefined }, [])).toEqual([])
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={{ name: '项目经理' }} grants={[]} />))
.toContain('被加入项目后由成员关系提供基础协作')
})
it('用角色列表里同一 roleId 的权限补齐来源', () => {
const resolved = roleWithPermissions(summary, [listed])
expect(resolved?.permissions?.some(item => item.actionId === 'project.create')).toBe(true)
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={resolved} grants={[]} />))
.toContain('创建项目来源:职位角色「项目经理」')
})
it('列表里没有对应条目时按无角色默认权限处理,角色自带权限时不被列表覆盖', () => {
expect(projectCreateSources(roleWithPermissions(summary, []), [])).toEqual([])
const denied = { ...listed, roleId: 'role-other', permissions: [{ actionId: 'project.create', allowed: false, capAllowed: false, permissionVersion: 1 }] }
expect(projectCreateSources(roleWithPermissions({ ...summary, permissions: denied.permissions }, [denied]), [])).toEqual([])
})
})
describe('管理员一次性登录密码请求体', () => {
it('发送 POST + JSON 对象体,避免服务端 400 EOF', async () => {
const issued = { owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true }
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify(issued)))
const out = await api.adminIssueOneTimePassword('alice')
const [url, options] = request.mock.calls[0]
expect(url).toBe('/api/admin/users/alice/one-time-password')
expect(options?.method).toBe('POST')
expect(options?.headers).toEqual({ 'Content-Type': 'application/json' })
expect(JSON.parse(String(options?.body))).toEqual({})
expect(out).toEqual(issued)
})
})
+53
View File
@@ -329,3 +329,56 @@ describe('conversation work reading', () => {
expect(render()).toContain('会覆盖旧草稿,原件保留')
})
})
describe('ProjectLifecycleConversation work record', () => {
it('opens a project lifecycle conversation with a single focused work card and a closed history', () => {
snapshot.state!.cards = [card({ title: '整理材料', status: 'active' }), card({
id: 'history_1', status: 'done', tone: 'ok', title: '旧一轮记录',
body: [{ type: 'markdown', markdown: '项目准备阶段的总结报告' }],
updatedAt: '2026-10-12T08:00:00Z',
})]
const html = renderWork()
expect(html).toContain('整理材料')
expect(html).not.toContain('项目准备阶段的总结报告')
expect(html).toContain('更多工作与成果 · 1 项')
})
it('names a project lifecycle conversation by its own work record when the right work pane is closed', () => {
snapshot.state!.workRecord = { cardId: 'card_1', runId: 'run_1', conversationId: 'cnv_1', title: '材料整理' }
snapshot.state!.cards = [card()]
const html = render()
expect(html).toContain('data-conversation-work')
expect(html).not.toContain('核对会议结论')
})
it('falls back to a project lifecycle conversation level when the auxiliary work pane already owns the detail', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.ui.rightTab = 'work'
snapshot.state!.cards = [card()]
const html = render()
expect(html).not.toContain('data-conversation-work')
expect(html).not.toContain('核对会议结论')
})
it('keeps the project lifecycle conversation summary headline visible regardless of the right pane state', () => {
snapshot.state!.cards = [card({ status: 'done', work: { result: 'project-lifecycle-summary-result', resultVersion: 1, readVersion: 0 } })]
snapshot.state!.ui.rightCollapsed = false
const html = render()
expect(html).toContain('1 项工作有新结果')
expect(html).not.toContain('project-lifecycle-summary-result')
})
it('lists only the project lifecycle conversation\'s active work alongside its completed history', () => {
snapshot.state!.cards = [
card({ status: 'active' }),
card({ id: 'other_project', projectId: 'prj_2', conversationId: 'cnv_2', title: '其它项目的工作',
body: [{ type: 'kv', kv: [['正在做', '其它项目的工作细节']] }] }),
card({ id: 'other_conv', projectId: 'prj_1', conversationId: 'cnv_9', title: '其它会话的工作',
body: [{ type: 'kv', kv: [['正在做', '其它会话的工作细节']] }] }),
]
const html = renderWork()
expect(html).toContain('核对会议结论')
expect(html).not.toContain('其它项目的工作细节')
expect(html).not.toContain('其它会话的工作细节')
})
})
+33
View File
@@ -0,0 +1,33 @@
// eventTime 取事件上的服务端时间;后端 0 值占位(0001-01-01T00:00:00Z)
// 不能让状态栏把「已工作」读成几百年的耗时——非正 epoch 一律回落到墙上时钟。
import { describe, expect, it } from 'vitest'
import { eventTime } from '../src/store'
describe('eventTime', () => {
it('returns now when the timestamp is missing', () => {
const now = 1_700_000_000_000
expect(eventTime({}, now)).toBe(now)
expect(eventTime({ ts: '' }, now)).toBe(now)
expect(eventTime({ ts: undefined }, now)).toBe(now)
})
it('returns now for unparseable timestamps', () => {
const now = 1_700_000_000_000
expect(eventTime({ ts: 'not-a-date' }, now)).toBe(now)
expect(eventTime({ ts: '2026-13-40T99:99:99Z' }, now)).toBe(now)
})
it('returns now when the parsed time is not a positive epoch (zero-value placeholder)', () => {
// 0001-01-01T00:00:00Z is the Go/Unix zero time;过去不能解释为有效事件时间。
const now = 1_700_000_000_000
expect(eventTime({ ts: '0001-01-01T00:00:00Z' }, now)).toBe(now)
// Date.parse('1970-01-01T00:00:00Z') === 0,应被一并回落,否则状态栏会显示从 epoch 至今。
expect(eventTime({ ts: '1970-01-01T00:00:00Z' }, now)).toBe(now)
})
it('returns the parsed epoch for a real, parseable timestamp', () => {
const now = 1_700_000_000_000
expect(eventTime({ ts: '2024-01-02T03:04:05Z' }, now)).toBe(Date.parse('2024-01-02T03:04:05Z'))
expect(eventTime({ ts: '2026-10-12T10:00:00Z' }, now)).toBe(Date.parse('2026-10-12T10:00:00Z'))
})
})
+4 -1
View File
@@ -21,7 +21,10 @@ describe('管理员一次性密码发放', () => {
const out = await api.adminIssueOneTimePassword('alice')
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith('/api/admin/users/alice/one-time-password', { method: 'POST' })
// 服务端严格解码 JSON 对象体:无体无 Content-Type 会被拒为 400 EOF。
expect(fetchSpy).toHaveBeenCalledWith('/api/admin/users/alice/one-time-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
})
expect(out).toEqual(response)
})
+64
View File
@@ -0,0 +1,64 @@
// ps-frontend-no-project-surfaces-r2:openConversation 在 state.currentProjectId 为空时
// 不再触发 /api/projects/<id>/surfaces 请求(避免被移除项目成员打开历史会话时的 404 噪音)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { store } from '../src/store'
import type { Conversation, Message, SurfaceMeta } from '../src/contracts'
function detail(id: string) {
return {
conversation: { id, projectId: '', title: id, createdAt: '', updatedAt: '' } as Conversation,
messages: [] as Message[], runs: [], activeHitl: [], lastSeq: 0, resumeSeq: 0,
}
}
class FakeEventSource {
onopen = null
onerror = null
onmessage = null
addEventListener() {}
close() {}
}
let projectSurfacesSpy: ReturnType<typeof vi.spyOn>
beforeEach(async () => {
vi.stubGlobal('EventSource', FakeEventSource)
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id))
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
vi.spyOn(api, 'listCards').mockResolvedValue([])
vi.spyOn(api, 'listTasks').mockResolvedValue([])
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
projectSurfacesSpy = vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
vi.spyOn(api, 'listProjectCards').mockResolvedValue([])
// 还原至 currentProjectId = null 的干净投影,避免别的套件残留影响本次断言。
store.resetIdentity()
})
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('openConversation projectSurfaces gating', () => {
it('skips the /api/projects/.../surfaces request and keeps projectSurfaces empty when no project is current', async () => {
expect(store.getState().currentProjectId).toBeNull()
await store.openConversation('cnv_no_project')
// 让微任务(包含 surfaceList/Stream 等链)落定,但不改变当前结论。
await new Promise(r => setTimeout(r, 0))
expect(projectSurfacesSpy).not.toHaveBeenCalled()
expect(store.getState().projectSurfaces).toEqual([])
expect(store.getState().currentProjectId).toBeNull()
})
it('still requests /api/projects/<id>/surfaces when a project is current', async () => {
const metas: SurfaceMeta[] = [{ surfaceId: 'surf_1', title: '页面一', pluginId: 'plg_1', revision: 1, updatedAt: '2026-10-12T00:00:00Z' }]
projectSurfacesSpy.mockResolvedValueOnce(metas)
await store.openProject('prj_with_surfaces')
expect(store.getState().currentProjectId).toBe('prj_with_surfaces')
projectSurfacesSpy.mockClear()
await store.openConversation('cnv_with_project')
expect(projectSurfacesSpy).toHaveBeenCalledTimes(1)
expect(projectSurfacesSpy.mock.calls[0][0]).toBe('prj_with_surfaces')
// 微任务处理完 .then 后,projectSurfaces 投影被填进服务端的元数据列表。
await new Promise(r => setTimeout(r, 0))
expect(store.getState().projectSurfaces).toEqual(metas)
})
})
-243
View File
@@ -1,243 +0,0 @@
// RDM 项目设置:成员页门控测试
// 当 canManage=false 时:添加成员入口与行操作按钮(移除/升降级)不出现,不调用 rdm.role.options;
// 当 canManage=true 时:添加成员与行操作按钮出现,请求 rdm.role.options。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, act } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { api } from '../src/api'
import { RdmMembersSection } from '../src/components/adminRdmSections'
import type { Project } from '../src/contracts'
import type { RdmMemberRow } from '../src/rdmManagement'
const project: Project = {
id: 'prj_test',
name: '测试项目',
settings: {
'rdm.projectId': 'rdm_prj_test',
'rdm.state': 'ready',
},
createdAt: '2026-10-01T00:00:00Z',
updatedAt: '2026-10-01T00:00:00Z',
}
const mockRows: RdmMemberRow[] = [
{ owner: 'alice', name: 'Alice', role: 'owner', addedBy: 'admin', addedAt: '2026-10-01T10:00:00Z' },
{ owner: 'bob', name: 'Bob', role: 'member', addedBy: 'alice', addedAt: '2026-10-02T10:00:00Z' },
]
describe('RdmMembersSection 静态组件门控呈现', () => {
it('canManage=false 时:添加成员与移除按钮不呈现,显示只读提示,动作列头不渲染', () => {
const html = renderToStaticMarkup(createElement(RdmMembersSection, {
project,
initialCanManage: false,
initialRows: mockRows,
}))
expect(html).toContain('仅项目创建者可管理成员;你可以查看成员列表。')
expect(html).not.toContain('添加成员')
expect(html).not.toContain('移除')
expect(html).not.toContain('降为成员')
expect(html).not.toContain('升为负责人')
// 列表基础内容、搜索框、成员信息仍可见
expect(html).toContain('alice')
expect(html).toContain('bob')
expect(html).toContain('搜索成员')
// 表头只有 5 列(无末尾操作列头 <th></th>)
const thMatches = html.match(/<th\b/g) || []
expect(thMatches).toHaveLength(5)
})
it('canManage=true 时:添加成员与移除按钮呈现,不显示只读提示,动作列头渲染', () => {
const html = renderToStaticMarkup(createElement(RdmMembersSection, {
project,
initialCanManage: true,
initialRows: mockRows,
}))
expect(html).not.toContain('仅项目创建者可管理成员')
expect(html).toContain('添加成员')
expect(html).toContain('移除')
expect(html).toContain('降为成员')
expect(html).toContain('升为负责人')
// 表头共 6 列(包含末尾操作列头 <th></th>)
const thMatches = html.match(/<th\b/g) || []
expect(thMatches).toHaveLength(6)
})
})
describe('RdmMembersSection 动态加载与命令门控交互', () => {
let commandsCalled: { id: string; params?: Record<string, unknown> }[] = []
let mockEnv: { container: any; cleanup: () => void }
function setupMockDom() {
const origDoc = globalThis.document
const origWindow = globalThis.window
const origAct = (globalThis as any).IS_REACT_ACT_ENVIRONMENT
;(globalThis as any).HTMLIFrameElement = class {}
;(globalThis as any).Element = class {}
;(globalThis as any).HTMLElement = class extends (globalThis as any).Element {}
function createMockElement(tag: string): any {
const el: any = {
nodeType: 1,
tagName: tag.toUpperCase(),
children: [],
style: {},
ownerDocument: doc,
get options() {
return el.children.filter((c: any) => c.tagName === 'OPTION')
},
get length() {
return el.children.filter((c: any) => c.tagName === 'OPTION').length
},
setAttribute(k: string, v: string) { el[k] = v },
removeAttribute(k: string) { delete el[k] },
getAttribute(k: string) { return el[k] },
appendChild(c: any) {
el.children.push(c)
c.parentNode = el
if (c.tagName === 'OPTION') {
const idx = el.children.filter((x: any) => x.tagName === 'OPTION').indexOf(c)
el[idx] = c
}
return c
},
removeChild(c: any) {
const i = el.children.indexOf(c)
if (i >= 0) el.children.splice(i, 1)
delete c.parentNode
},
insertBefore(newNode: any, refNode: any) {
const i = el.children.indexOf(refNode)
if (i >= 0) el.children.splice(i, 0, newNode)
else el.children.push(newNode)
newNode.parentNode = el
return newNode
},
addEventListener() {},
removeEventListener() {},
}
return el
}
const doc: any = {
nodeType: 9,
createElement(tag: string) { return createMockElement(tag) },
createElementNS(_ns: string, tag: string) { return createMockElement(tag) },
createTextNode(text: string) {
return { nodeType: 3, nodeValue: text, ownerDocument: doc }
},
createComment(text: string) {
return { nodeType: 8, nodeValue: text, ownerDocument: doc }
},
addEventListener() {},
removeEventListener() {},
defaultView: globalThis,
}
doc.ownerDocument = doc
globalThis.document = doc
globalThis.window = globalThis as any
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
return {
container: createMockElement('div'),
cleanup() {
globalThis.document = origDoc
globalThis.window = origWindow
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = origAct
},
}
}
function serializeDom(el: any): string {
if (!el) return ''
if (el.nodeType === 3) return el.nodeValue || ''
if (el.nodeType === 8) return ''
let out = `<${el.tagName.toLowerCase()}`
if (el.className) out += ` class="${el.className}"`
out += '>'
if (el.textContent && el.children.length === 0) {
out += el.textContent
} else {
for (const c of el.children) out += serializeDom(c)
}
out += `</${el.tagName.toLowerCase()}>`
return out
}
beforeEach(() => {
commandsCalled = []
mockEnv = setupMockDom()
})
afterEach(() => {
mockEnv.cleanup()
vi.restoreAllMocks()
})
it('当 rdm.project.member.list 返回 canManage=false 时:不请求 rdm.role.options,添加成员与移除按钮缺席', async () => {
vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_p, id, params) => {
commandsCalled.push({ id, params })
if (id === 'rdm.project.member.list') {
return { members: mockRows, canManage: false, total: 2 } as any
}
if (id === 'rdm.role.options') {
return { roles: [{ roleId: 'r1', name: '架构师' }] } as any
}
return {} as any
})
const root = createRoot(mockEnv.container)
await act(async () => {
root.render(createElement(RdmMembersSection, { project }))
})
// 等待 useEffect 及微任务完成
await act(async () => {
await new Promise(r => setTimeout(r, 20))
})
const html = serializeDom(mockEnv.container)
// 证明请求了成员列表,但未请求 rdm.role.options
expect(commandsCalled.some(c => c.id === 'rdm.project.member.list')).toBe(true)
expect(commandsCalled.some(c => c.id === 'rdm.role.options')).toBe(false)
// 证明添加成员和移除按钮缺席,显示只读提示
expect(html).not.toContain('添加成员')
expect(html).not.toContain('移除')
expect(html).toContain('仅项目创建者可管理成员;你可以查看成员列表。')
})
it('当 rdm.project.member.list 返回 canManage=true 时:触发 rdm.role.options 请求,呈现添加成员与移除按钮', async () => {
vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_p, id, params) => {
commandsCalled.push({ id, params })
if (id === 'rdm.project.member.list') {
return { members: mockRows, canManage: true, total: 2 } as any
}
if (id === 'rdm.role.options') {
return { roles: [{ roleId: 'r1', name: '架构师' }] } as any
}
return {} as any
})
const root = createRoot(mockEnv.container)
await act(async () => {
root.render(createElement(RdmMembersSection, { project }))
})
// 等待 useEffect 及微任务完成
await act(async () => {
await new Promise(r => setTimeout(r, 20))
})
const html = serializeDom(mockEnv.container)
// 证明请求了成员列表,且在 canManage=true 后请求了 rdm.role.options
expect(commandsCalled.some(c => c.id === 'rdm.project.member.list')).toBe(true)
expect(commandsCalled.some(c => c.id === 'rdm.role.options')).toBe(true)
// 证明添加成员和移除按钮存在
expect(html).toContain('添加成员')
expect(html).toContain('移除')
expect(html).not.toContain('仅项目创建者可管理成员')
})
})
+264
View File
@@ -0,0 +1,264 @@
// RdmMembersSection rdm.role.options 授权门控:rdm.perm.check 给出 member.manage
// 未允许时,不得提前发 rdm.role.options(普通成员被 403);允许后必须发。
//
// 测试在 node 环境下用最小化的 DOM mock 驱动 React 19 createRoot,让 useEffect 正常落槽;
// 不去依赖 happy-dom/jsdom(受网络/包锁约束,allowed_paths 也限制仅 src + test)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { RdmMembersSection } from '../src/components/adminRdmSections'
import { api } from '../src/api'
import type { Project } from '../src/contracts'
// 用 vi.mock 隔离 useStore:组件只用到 select(state).me,其它 store 字段触发不到,
// 不会影响此处关注点(避免 host Store 启动监听器、送 SSE 等副作用污染测试)。
vi.mock('../src/store', () => {
const state = {
me: { owner: 'bob', name: 'bob', auth: true },
}
return {
useStore: (select: (value: typeof state) => unknown) => select(state),
eventTime: (_e: { ts?: string }, now: number) => now,
}
})
// ---- 自定义 React DOM 桩:让 useEffect 在 node 环境落到 commit 阶段 ----
// 服务端 React 不运行 useEffect;createRoot 需要一个 DOM-shaped container。
// 这里只覆盖 react-dom-client 用到的几个关键入口(addEventListener、tagName、namespaceURI、
// ownerDocument.defaultView.HTMLIFrameElement、activeElement、documentElement)。任何
// 新字段缺失都会在第一处 React 调用时显式爆出,便于修复而不是静默吞下。
class HTMLIFrameElement {}
class MockDocument {
nodeType = 9
defaultView: any
HTMLIFrameElement = HTMLIFrameElement
activeElement: any = null
documentElement: any
constructor() {
this.defaultView = makeWindow()
this.defaultView.document = this
this.activeElement = null
this.documentElement = this
}
addEventListener() {}
removeEventListener() {}
dispatchEvent() {}
contains() { return false }
createElement(tag: string) {
const el = new MockElement()
el.tagName = tag
el.nodeName = tag
el.ownerDocument = this
if (renderObserver) renderObserver.append(tag)
return el
}
createElementNS(_ns: string, tag: string) { return this.createElement(tag) }
createTextNode() { return new MockElement() }
}
/** 创建 DOM 节点时把它们的 tag 累积下来,替代 innerHTML 读取(Mock 上 innerHTML 为空)。 */
let renderObserver: { tags: string[]; reset(): void; append(t: string): void } | null = null
class MockElement {
nodeType = 1
style: Record<string, string> = {}
attributes: Record<string, string> = {}
tagName = 'DIV'
nodeName = 'DIV'
namespaceURI = 'http://www.w3.org/1999/xhtml'
ownerDocument: any
childNodes: any[] = []
parentNode: any = null
appendChild(c: any) { this.childNodes.push(c); c.parentNode = this; return c }
removeChild(c: any) { const i = this.childNodes.indexOf(c); if (i >= 0) this.childNodes.splice(i, 1); c.parentNode = null; return c }
insertBefore(c: any, ref: any) {
const i = ref ? this.childNodes.indexOf(ref) : this.childNodes.length
this.childNodes.splice(i < 0 ? this.childNodes.length : i, 0, c)
c.parentNode = this
return c
}
contains(c: any) {
for (let n: any = c; n; n = n.parentNode) if (n === this) return true
return false
}
addEventListener() {}
removeEventListener() {}
dispatchEvent() {}
focus() {}
setAttribute(k: string, v: string) { this.attributes[k] = v }
removeAttribute(k: string) { delete this.attributes[k] }
cloneNode() { return new MockElement() }
/** React 写 `<select>` 时会读 `select.options`,给一个可索引的 length 让 updateOptions 不崩。 */
get options() {
return this.childNodes
}
/** React 写入 innerHTML;这里读为空即可(断言用别的字段)。 */
get innerHTML() { return '' }
get outerHTML() { return '' }
get textContent() { return '' }
set textContent(_v: string) { /* 允许 commit 阶段写入 */ }
get value() { return this.attributes.value ?? '' }
set value(v: string) { this.attributes.value = v }
get firstChild() { return this.childNodes[0] }
get lastChild() { return this.childNodes[this.childNodes.length - 1] }
get nextSibling() {
if (!this.parentNode) return null
const i = this.parentNode.childNodes.indexOf(this)
return this.parentNode.childNodes[i + 1] ?? null
}
get previousSibling() {
if (!this.parentNode) return null
const i = this.parentNode.childNodes.indexOf(this)
return this.parentNode.childNodes[i - 1] ?? null
}
}
function makeWindow() {
const w: any = {
event: undefined,
HTMLIFrameElement,
document: undefined as any,
navigator: { userAgent: 'node' },
}
return w
}
function makeContainer(): MockElement {
const doc = new MockDocument()
const el = doc.createElement('div') as MockElement
return el
}
function installDomGlobals() {
const win = makeWindow()
const doc = new MockDocument()
doc.defaultView = win
win.document = doc
;(globalThis as any).window = win
;(globalThis as any).document = doc
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
;(globalThis as any).MessageChannel = class { port1 = { postMessage() {} }; port2 = { onmessage: null as any, postMessage() {} } }
;(globalThis as any).setImmediate = (cb: (...args: any[]) => void) => Promise.resolve().then(() => cb())
}
let _installed = false
function ensureDomGlobals() {
if (_installed) return
installDomGlobals()
_installed = true
}
// ---- 用例辅助 ----
const project: Project = {
id: 'host-1', owner: 'alice', name: '项目P', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' },
}
type CmdHandler = (id: string, params: Record<string, unknown>) => Promise<unknown> | unknown
/** 直接劫持 api.rdmProjectCommand,用 handlers[id] 分别登记每个命令的响应。 */
function spyCommandBus(handlers: Record<string, CmdHandler>) {
const calls: Array<[string, Record<string, unknown>]> = []
const original = api.rdmProjectCommand.bind(api)
const spy = vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_proj: Project, id: string, params: Record<string, unknown> = {}) => {
const args: [string, Record<string, unknown>] = [id, params]
calls.push(args)
if (!handlers[id]) throw new Error(`unexpected command: ${id} (no handler registered)`)
return handlers[id](id, params)
})
return {
calls,
roleOptionsCalls: () => calls.filter(([id]) => id === 'rdm.role.options'),
permCheckCalls: () => calls.filter(([id]) => id === 'rdm.perm.check'),
memberListCalls: () => calls.filter(([id]) => id === 'rdm.project.member.list'),
restore: () => {
spy.mockRestore()
;(api as any).rdmProjectCommand = original
},
}
}
let root: Root | null = null
let container: MockElement | null = null
beforeEach(() => {
ensureDomGlobals()
renderObserver = {
tags: [],
reset() { this.tags = [] },
append(t: string) { this.tags.push(t) },
}
})
afterEach(async () => {
if (root) {
await act(async () => root!.unmount())
root = null
}
container = null
renderObserver = null
vi.restoreAllMocks()
})
async function renderAndFlush(element: ReactNode, flushes: number = 3) {
container = makeContainer()
await act(async () => {
root = createRoot(container as any)
root!.render(element)
})
// 反复冲一轮 microtask flush,让 perm.check 的 .then 落进 commit。
for (let i = 0; i < flushes; i++) {
await act(async () => { await Promise.resolve() })
}
}
/** 助记:累计的 render 标签里「候选专业角色筛选」下拉 = 看到标 'select' + 紧邻的 option 角色字符串。 */
function sawCandidateRoleFilter(): boolean {
if (!renderObserver) return false
// React 渲染时按 tag 序列创建 select/option;候选筛选断言只需确保 select 节点创建了。
return renderObserver.tags.includes('select')
}
describe('RdmMembersSection rdm.role.options 授权门控', () => {
it('member.manage 未被允许时不得请求 rdm.role.options(普通成员避免 403)', async () => {
const bus = spyCommandBus({
'rdm.perm.check': () => ({ actions: [{ action: 'member.manage', allowed: false }] }),
'rdm.project.member.list': () => ({ members: [], accountDirectoryAvailable: true }),
})
await renderAndFlush(createElement(RdmMembersSection, { project }))
expect(bus.permCheckCalls().length).toBe(1)
expect(bus.roleOptionsCalls().length).toBe(0)
})
it('member.manage 被允许后再请求 rdm.role.options,并把结果写回角色筛选', async () => {
const bus = spyCommandBus({
'rdm.perm.check': () => ({ actions: [{ action: 'member.manage', allowed: true }] }),
'rdm.role.options': () => ({ roles: [
{ roleId: 'r-eng', name: '工程师' },
{ roleId: 'r-qa', name: '测试' },
] }),
'rdm.project.member.list': () => ({ members: [], accountDirectoryAvailable: true }),
})
await renderAndFlush(createElement(RdmMembersSection, { project }))
expect(bus.permCheckCalls().length).toBe(1)
expect(bus.roleOptionsCalls().length).toBe(1)
})
it('门控生效前的初始挂载阶段不出现专业角色筛选下拉(不提供误导选项)', async () => {
// 用一个永挂起的 perm.check 模拟「自省中」——此时页面上确实只见核对提示,
// 候选区不出现;这是顺带的渲染回归保单,防止门控未来误把 options 提前注入。
// memberManagementView 在 actions===null 时返回 canManage:false,rdm.role.options 因此不发请求;
// 同时 candRoleId 筛选(占位 select)也不应渲染——manage.canManage=false 时整个 ad-create 块被跳掉。
spyCommandBus({
'rdm.perm.check': () => new Promise<{ actions: { action: string; allowed: boolean }[] }>(() => {}),
'rdm.project.member.list': () => new Promise<any>(() => {}),
})
await renderAndFlush(createElement(RdmMembersSection, { project }))
expect(sawCandidateRoleFilter()).toBe(false)
})
})
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { auth } from '../src/auth'
import { canCreateProject, store } from '../src/store'
import type { ConversationView } from '../src/api'
import type { Project } from '../src/contracts'
const project = (id: string): Project => ({ id, name: id, createdAt: '', updatedAt: '' })
const task = (id: string, projectId: string, scratch = false, updatedAt = ''): ConversationView =>
({ id, projectId, title: id, createdAt: '', updatedAt, ...(scratch ? { scratch: true } : {}) })
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
const detail = (conversation: ConversationView) => response({ conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 })
let projects: Project[]
let tasks: ConversationView[]
let me: Record<string, unknown>
let requests: { path: string; init?: RequestInit }[]
let scratchCount: number
const posts = () => requests.filter(r => r.init?.method === 'POST').map(r => r.path)
beforeEach(async () => {
projects = []; tasks = []; requests = []; scratchCount = 0
me = { owner: 'bob', name: 'Bob', auth: true, systemActions: [] }
const saved = new Map<string, string>()
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
setItem: (key: string, value: string) => { saved.set(key, value) },
removeItem: (key: string) => { saved.delete(key) } })
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
requests.push({ path, init })
if (path === '/api/projects' && !init?.method) return response(projects)
if (path === '/api/conversations' && !init?.method) return response(tasks)
if (path === '/api/me') return response(me)
if (path === '/api/scratch-sessions' && init?.method === 'POST') {
const id = `scratch-${++scratchCount}`
const conversation = task(`${id}-conv`, id, true)
tasks = [conversation, ...tasks]
return response({ project: project(id), conversation })
}
const created = path.match(/^\/api\/projects\/([^/]+)\/conversations$/)
if (created && init?.method === 'POST') {
const conversation = task(`${created[1]}-new`, created[1])
tasks = [conversation, ...tasks]
return response(conversation)
}
const conv = path.match(/^\/api\/conversations\/([^/]+)$/)
if (conv && !init?.method) {
const found = tasks.find(t => t.id === conv[1])
return found ? detail(found) : response({ error: 'not found' }, 404)
}
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([])
if (path.endsWith('/cards')) return response({ cards: [] })
return response({})
})
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
auth.beginSession()
await store.refreshMe()
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('scratch session entry', () => {
it('enters scratch mode for an account without project creation and no projects, and never creates a project', async () => {
await store.refreshProjects()
expect(store.getState()).toMatchObject({ scratchMode: true, projectsLoaded: true, currentProjectId: null })
requests = []
await store.createTask()
expect(posts()).toEqual(['/api/scratch-sessions'])
expect(requests.some(r => r.path === '/api/projects' && r.init?.method === 'POST')).toBe(false)
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-1-conv', currentProjectId: 'scratch-1', projects: [] })
})
it('always creates a scratch session in scratch mode even when real projects exist', async () => {
projects = [project('host-a')]
await store.refreshProjects()
expect(store.getState()).toMatchObject({ scratchMode: false, currentProjectId: 'host-a' })
await store.enterScratchMode()
expect(store.getState()).toMatchObject({ scratchMode: true, currentProjectId: null, currentConvId: null })
requests = []
await store.createTask()
await store.createTask()
expect(posts()).toEqual(['/api/scratch-sessions', '/api/scratch-sessions'])
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-2-conv', currentProjectId: 'scratch-2' })
})
it('keeps creating project conversations in project mode', async () => {
projects = [project('host-a')]
await store.refreshProjects()
requests = []
await store.createTask()
expect(posts()).toEqual(['/api/projects/host-a/conversations'])
expect(store.getState()).toMatchObject({ scratchMode: false, currentConvId: 'host-a-new', currentProjectId: 'host-a' })
})
it('returns to the remembered scratch view on reload and opens the latest scratch task', async () => {
projects = [project('host-a')]
tasks = [task('old', 'scratch-o', true, '2026-01-01'), task('new', 'scratch-n', true, '2026-02-01'), task('work', 'host-a')]
await store.refreshProjects()
await store.enterScratchMode()
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'new', currentProjectId: 'scratch-n' })
expect(store.getState().ui.lastScratch).toBe(true)
await store.refreshProjects()
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'new', currentProjectId: 'scratch-n' })
await store.openProject('host-a')
expect(store.getState()).toMatchObject({ scratchMode: false, currentProjectId: 'host-a' })
expect(store.getState().ui.lastScratch).toBe(false)
})
})
describe('scratch mode follows the opened task', () => {
it('sets scratch mode for a scratch task and clears it for a normal task', async () => {
projects = [project('host-a')]
tasks = [task('scratch-conv', 'scratch-1', true), task('work', 'host-a')]
await store.refreshProjects()
await store.openTask('scratch-conv')
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-conv', currentProjectId: 'scratch-1' })
expect(store.getState().ui.lastProjectId).toBe('host-a')
await store.openTask('work')
expect(store.getState()).toMatchObject({ scratchMode: false, currentConvId: 'work', currentProjectId: 'host-a' })
})
})
describe('canCreateProject', () => {
it('allows project creation without auth or with project.create only', () => {
expect(canCreateProject({ auth: false })).toBe(true)
expect(canCreateProject({ auth: true, systemActions: ['project.create'] })).toBe(true)
expect(canCreateProject({ auth: true, systemActions: [] })).toBe(false)
expect(canCreateProject({ auth: true })).toBe(false)
expect(canCreateProject(null)).toBe(false)
})
})
+92
View File
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { setPersistedOpen } from '@ui'
import type { ActivityCard } from '../src/contracts'
import { Conversation } from '../src/components/Conversation'
import { ConversationWork } from '../src/components/ConversationWork'
import { Workbench } from '../src/components/Workbench'
import { store } from '../src/store'
// SSR has no browser subscription snapshot: substitute only the hook boundary.
const snapshot = vi.hoisted(() => ({ state: undefined as ReturnType<typeof store.getState> | undefined }))
vi.mock('../src/store', async importOriginal => {
const actual = await importOriginal<typeof import('../src/store')>()
return { ...actual, useStore: (select: (s: ReturnType<typeof store.getState>) => unknown) => select(snapshot.state!) }
})
// Record the callbacks each card surface receives; the rendered archive button belongs to the UI repo.
const passed = vi.hoisted(() => ({ inbox: [] as Record<string, unknown>[], card: [] as Record<string, unknown>[] }))
vi.mock('@ui', async importOriginal => {
const actual = await importOriginal<typeof import('@ui')>()
return {
...actual,
InboxList: (props: { callbacks: Record<string, unknown> }) => { passed.inbox.push(props.callbacks); return null },
ActivityCardView: (props: { callbacks: Record<string, unknown> }) => { passed.card.push(props.callbacks); return null },
}
})
const timestamp = '2026-10-12T10:00:00Z'
const card: ActivityCard = {
id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1', kind: 'progress', title: '整理材料',
status: 'active', tone: 'info', body: [], createdAt: timestamp, updatedAt: timestamp,
}
function setup(scratch: boolean) {
const base = store.getState()
snapshot.state = {
...base, currentProjectId: 'prj_1', currentConvId: 'cnv_1', connected: true, inboxScope: 'project',
tasks: [{ id: 'cnv_1', projectId: 'prj_1', title: '讨论', createdAt: timestamp, updatedAt: timestamp, ...(scratch ? { scratch: true } : {}) }],
messages: [], cards: [card], projectCards: [], runs: [], activeHitl: [], live: null, artifacts: [], outbox: [], posting: false,
ui: { ...base.ui, rightCollapsed: true, rightTab: 'work', inboxFilter: 'all' },
cardActionStates: {}, workRecord: null, surfaces: [],
}
}
beforeEach(() => {
passed.inbox = []; passed.card = []
setPersistedOpen('conversation-settings:prj_1:cnv_1', true)
})
describe.each([
{ name: 'scratch conversation', scratch: true },
{ name: 'project conversation', scratch: false },
])('project-only surfaces in a $name', ({ scratch }) => {
beforeEach(() => setup(scratch))
it(scratch ? 'hides the cross-conversation read setting' : 'keeps the cross-conversation read setting', () => {
const html = renderToStaticMarkup(createElement(Conversation))
expect(html).toContain('会话设置')
expect(html).toContain('这次工作的要求')
expect(html.includes('读取同项目的其他对话')).toBe(!scratch)
expect(html.includes('实际读取范围还需项目设置允许')).toBe(!scratch)
})
it(scratch ? 'keeps the conversation inbox without the project scope' : 'keeps the project scope and inbox', () => {
const html = renderToStaticMarkup(createElement(Workbench))
expect(html).toContain('本会话')
expect(html.includes('>项目')).toBe(!scratch)
expect(html.includes('项目收件箱为空')).toBe(!scratch)
if (scratch) {
expect(passed.inbox).toHaveLength(1)
expect(passed.inbox[0]).not.toHaveProperty('onArchive')
expect(passed.inbox[0]).toHaveProperty('onDismiss')
}
})
it(scratch ? 'omits archive-to-project from conversation inbox cards' : 'offers archive-to-project on conversation inbox cards', () => {
snapshot.state!.inboxScope = 'conv'
renderToStaticMarkup(createElement(Workbench))
expect(passed.inbox).toHaveLength(1)
expect(typeof passed.inbox[0].onArchive).toBe(scratch ? 'undefined' : 'function')
})
it(scratch ? 'omits archive-to-project from conversation work cards' : 'offers archive-to-project on conversation work cards', () => {
renderToStaticMarkup(createElement(ConversationWork))
expect(passed.card.length).toBeGreaterThan(0)
for (const callbacks of passed.card) {
expect(typeof callbacks.onArchive).toBe(scratch ? 'undefined' : 'function')
expect(callbacks).toHaveProperty('onDismiss')
}
})
})
+57
View File
@@ -0,0 +1,57 @@
// 被移出项目/项目关闭后的发送拒绝:403 `conversation: not-project-member` 走专用文案,
// 其他失败保持通用文案(见 src/store.ts sendMessage 的 catch 分支)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { store } from '../src/store'
import type { Conversation, Message } from '../src/contracts'
const REMOVED_TOAST = '无法继续发送:你已不在该项目中或项目已关闭;历史对话仍可查看'
const REMOVED_DELIVERY_ERROR = '无法发送:你已不在该项目中或项目已关闭,内容已保留'
const GENERIC_DELIVERY_ERROR = '发送未成功,内容已保留;可修改后重试或移除'
function detail(id: string) {
return {
conversation: { id, projectId: 'p', title: id, createdAt: '', updatedAt: '' } as Conversation,
messages: [] as Message[], runs: [], activeHitl: [], lastSeq: 0, resumeSeq: 0,
}
}
class FakeEventSource {
onopen = null
onerror = null
onmessage = null
addEventListener() {}
close() {}
}
beforeEach(async () => {
vi.stubGlobal('EventSource', FakeEventSource)
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id))
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
vi.spyOn(api, 'listCards').mockResolvedValue([])
vi.spyOn(api, 'listTasks').mockResolvedValue([])
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
vi.spyOn(api, 'listProjectCards').mockResolvedValue([])
await store.openProject('p')
await store.openConversation('a')
})
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('sendMessage rejection copy', () => {
it('keeps the message in the outbox with the removed-member copy on 403 not-project-member', async () => {
vi.spyOn(api, 'sendMessage').mockRejectedValue(new Error('403: conversation: not-project-member'))
expect(await store.sendMessage('这条还要继续吗', [], 'kept-id')).toBe('rejected')
expect(store.getState().outbox[0]).toMatchObject({ id: 'kept-id', text: '这条还要继续吗', attachmentIds: [] })
expect(store.getState().outbox[0].deliveryError).toBe(REMOVED_DELIVERY_ERROR)
expect(store.getState().toast).toBe(REMOVED_TOAST)
})
it('keeps the generic copy for any other send failure', async () => {
vi.spyOn(api, 'sendMessage').mockRejectedValue(new Error('500: upstream down'))
expect(await store.sendMessage('这条还要继续吗', [], 'kept-id')).toBe('rejected')
expect(store.getState().outbox[0].deliveryError).toBe(GENERIC_DELIVERY_ERROR)
expect(store.getState().toast).toContain('发送失败:')
})
})
+3 -9
View File
@@ -26,18 +26,12 @@ describe('mergeStartedRun', () => {
})
describe('eventTime', () => {
it('parses normal ISO server timestamps', () => {
const iso = '2026-10-03T14:50:25.073Z'
expect(eventTime({ ts: iso }, 0)).toBe(Date.parse(iso))
expect(eventTime({ ts: iso }, 42)).toBe(Date.parse(iso))
it('parses the server timestamp', () => {
expect(eventTime({ ts: '2026-10-03T14:50:25.073Z' }, 0)).toBe(Date.parse('2026-10-03T14:50:25.073Z'))
})
it('falls back to now when timestamp is missing, invalid or zero-time 0001-01-01T00:00:00Z', () => {
it('falls back when the timestamp is missing or unparseable', () => {
expect(eventTime({}, 42)).toBe(42)
expect(eventTime({ ts: undefined }, 42)).toBe(42)
expect(eventTime({ ts: '' }, 42)).toBe(42)
expect(eventTime({ ts: 'not-a-time' }, 42)).toBe(42)
expect(eventTime({ ts: '0001-01-01T00:00:00Z' }, 42)).toBe(42)
expect(eventTime({ ts: '1970-01-01T00:00:00Z' }, 42)).toBe(42)
})
})