Compare commits

..
Author SHA1 Message Date
weichen 92e05392a7 Verify saved role permissions and settled project-switch responses
Why: Checkbox assertions did not prove the save payload, and the project
switch test had no expanded descendants or settled late-response assertion.

Evidence: The strengthened seven browser checks pass as SELF in the Dev
feature/admin-notes-repair task card. Production source is unchanged.

Impact: Test-only coverage adds exact role/action/default/cap save assertions,
refresh checks, expanded descendants, and awaited late-response settlement.

Refs: Independent reviewer repair_candidate_review; Dev R4/R3
2026-10-13 03:12:06 +00:00
weichen 9ea8249be8 Restore management controls and complete note-tree browsing
Why: EasyList hid ad-prefixed admin controls, note spines could not expand,
and late details and stale permission checkboxes misrepresented selections.

Evidence: Dev task feature/admin-notes-repair records the red/green browser
checks, 215 Frontend tests, and build results as SELF.

Impact: Rename management selectors, use recursive Collapsible tree levels
with local retry and request guards, and load role checkbox values correctly.
No permission or storage contracts change.

Refs: Dev feature/admin-notes-repair; RDM feature/inspect-notes-repair
2026-10-13 02:45:55 +00:00
124 changed files with 1210 additions and 16488 deletions
-68
View File
@@ -1,68 +0,0 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Check out fixed Frontend and UI commits
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }}
UI_SHA: fadabceadeec210d52cda7e9cec71e988d2e4482
run: |
set -euo pipefail
printf 'context server=%s repository=%s sha=%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}"
[[ "${GITHUB_SERVER_URL}" =~ ^https?://[^/]+/?$ ]]
test "${GITHUB_REPOSITORY}" = "weichen/Agent-System-Frontend"
[[ "${GITHUB_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${UI_SHA}" =~ ^[0-9a-f]{40}$ ]]
test -n "${TOKEN}"
if [ -z "${UI_DEPLOY_KEY}" ]; then
echo "UI_DEPLOY_KEY is required for the private UI dependency" >&2
exit 1
fi
export GIT_TERMINAL_PROMPT=0
mkdir -p "${RUNNER_TEMP}/iam-frontend-ci/frontend" "${RUNNER_TEMP}/iam-frontend-ci/ui"
cd "${RUNNER_TEMP}/iam-frontend-ci/frontend"
git init -q
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "${GITHUB_SHA}"
umask 077
trap 'rm -f "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"' EXIT
printf '%s' "${UI_DEPLOY_KEY}" > "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key"
printf '%s\n' '[git-ssh.u1680832.nyat.app]:31411 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK55QSxDqMMZ1HIWHSPkQ7fyJkDKbdg98TeIv6SrR3qx' > "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"
export GIT_SSH_COMMAND="ssh -F /dev/null -i ${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts -p 31411"
cd "${RUNNER_TEMP}/iam-frontend-ci/ui"
git init -q
git remote add origin "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git"
git fetch --depth=1 origin "${UI_SHA}"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "${UI_SHA}"
git -C "${RUNNER_TEMP}/iam-frontend-ci/frontend" status --short
git -C "${RUNNER_TEMP}/iam-frontend-ci/ui" status --short
- name: Install Node 22
env:
NODE_ARCHIVE_SHA256: df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz
printf '%s %s\n' "${NODE_ARCHIVE_SHA256}" "$RUNNER_TEMP/node.tar.xz" | sha256sum -c -
tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz"
- name: Install dependencies
run: |
set -euo pipefail
npm ci --prefix "${RUNNER_TEMP}/iam-frontend-ci/ui"
npm ci --prefix "${RUNNER_TEMP}/iam-frontend-ci/frontend"
- name: Test and build
run: |
set -euo pipefail
cd "${RUNNER_TEMP}/iam-frontend-ci/frontend"
npm test
npm run build
-150
View File
@@ -1,150 +0,0 @@
name: governance
on:
pull_request:
push:
branches: [main]
jobs:
governance:
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
# 只物化两个治理 gitlink(Guidelines 与 deps/development-manager),非递归;
# 其它 gitlink 不物化、不取回。凭据只以 URL 限定的 extraheader 存在于本进程树内。
python3 - <<'PY_CHECKOUT'
import os
from pathlib import Path
import re
import subprocess
import sys
from urllib.parse import urlsplit
# 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines",
"deps/development-manager": "Development-Manager",
}
TARGET = "weichen/Agent-System-Frontend"
root_token = os.environ.pop("TOKEN", "")
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
if not root_token or not component_token:
sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN")
if os.environ.get("GITHUB_REPOSITORY") != TARGET:
sys.exit("Checkout repository is outside the reviewed allowlist")
sha = os.environ.get("GITHUB_SHA", "")
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
sys.exit("Checkout requires a fixed commit SHA")
# 丢弃继承的 Git 追踪/配置/SSH 钩子;凭据只存在于单个 Git 进程树的精确 URL 头中。
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null",
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0")
config = {
"core.hooksPath": "/dev/null", "core.fsmonitor": "false",
"init.templateDir": "", "credential.helper": "",
"fetch.recurseSubmodules": "false", "submodule.recurse": "false",
"http.followRedirects": "false", "protocol.allow": "never",
"protocol." + urlsplit(SERVER).scheme + ".allow": "always",
}
def git(repo, *args, extra=None):
values = config | (extra or {})
env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))}
for index, (key, value) in enumerate(values.items()):
env[f"GIT_CONFIG_KEY_{index}"] = key
env[f"GIT_CONFIG_VALUE_{index}"] = value
return subprocess.run(["git", "-C", str(repo), *args], env=env,
check=True, stdout=subprocess.PIPE).stdout
def materialize_governance(root):
# -r 列出任意深度的 gitlink,但不进入 submodule 内部;其它 gitlink 只记录、从不取回。
links = {}
for entry in git(root, "ls-tree", "-rz", "HEAD").split(b"\0"):
if not entry:
continue
metadata, path = entry.split(b"\t", 1)
mode, kind, commit = metadata.decode().split()
if mode == "160000":
links[path.decode()] = commit
for name in REPOSITORIES:
if name not in links:
raise ValueError("Governance gitlink is missing from the fixed commit")
raw = git(root, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
entries = {}
for item in raw.split(b"\0"):
if not item:
continue
key, value = item.decode().split("\n", 1)
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
if not match:
continue
name, field = match.groups()
if name not in REPOSITORIES:
continue
if field in entries.setdefault(name, {}):
raise ValueError("Duplicate candidate .gitmodules setting")
entries[name][field] = value
# 先整体校验两个治理条目,再进行任何带凭据的取回。
for name, repository in REPOSITORIES.items():
fields = entries.get(name, {})
if fields.get("path") != name:
raise ValueError("Governance submodule path is outside the reviewed allowlist")
trusted_url = f"{SERVER}/weichen/{repository}.git"
if fields.get("url") not in {SSH + repository + ".git", trusted_url}:
raise ValueError("Governance submodule URL is outside the reviewed allowlist")
for name, repository in REPOSITORIES.items():
url = f"{SERVER}/weichen/{repository}.git"
# 仅持久化审查过的公开 URL,使 Git 在进程级配置消失后仍识别该 submodule。
git(root, "config", f"submodule.{name}.url", url)
git(root, "submodule", "update", "--init", "--checkout", "--", name,
extra={f"submodule.{name}.url": url,
f"submodule.{name}.update": "checkout",
f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token})
child = root / name
if git(child, "rev-parse", "HEAD").decode().strip() != links[name]:
raise ValueError("Materialized governance gitlink does not match the fixed SHA")
try:
root = Path.cwd()
# 必须是干净的临时工作区;不复用既有 Git 状态。
if (root / ".git").exists():
raise ValueError("Checkout requires a fresh workspace")
git(root, "init", "-q")
url = f"{SERVER}/{TARGET}.git"
git(root, "remote", "add", "origin", url)
git(root, "fetch", "--depth=1", "origin", sha,
extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token})
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
materialize_governance(root)
except (ValueError, OSError, subprocess.CalledProcessError) as error:
sys.exit(f"Checkout refused: {error}")
print("Checkout complete; credentials were not persisted in Git configuration")
PY_CHECKOUT
unset TOKEN SUBMODULE_TOKEN
- name: Install Go 1.27.1
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
- name: Validate agent execution configuration
run: scripts/agent-exec validate --repo .
- name: Governance check
run: scripts/dm governance check --repo .
-6
View File
@@ -1,6 +0,0 @@
[submodule "deps/development-guidelines"]
path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
[submodule "deps/development-manager"]
path = deps/development-manager
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
@@ -1,47 +0,0 @@
# Agent-System-Frontend 采纳 development-governance-v2 治理套件 任务卡
任务主记录:本文件随 PR 描述维护(PR 链接见下方"结果与验证")。
状态:进行中;owner:weichen。
## 基线与授权
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
- branch_kind:initiative;initiative_id:governance-adoption-Agent-System-Frontend
- R0(initial_main_sha):8b59998d3c5b0545f3bb0b3e670b22aa7fe2179b;工作区:/home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption(worktree);分支:feature/governance-adoption
- 父任务信息(仅子任务填写):无
- accepting_requirements:false(单仓采纳 initiative,范围固定为下列十步,不接收新需求)
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=development-governance-v2 Phase 6 每个正式仓库独立 initiative;triage 收据=无(本仓采纳前尚无 triage 入口)
- 授权与交付终点:owner 已授权 development-governance-v2 全部实施(Agent-System-Dev 主记录 docs/superpowers/plans/2026-10-16-development-governance-v2-master.md);终点为本 PR 合入 main 且采纳状态 active。分支保护与 tag 由用户执行。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标(Phase 6 十步):固定 Guidelines SHA(deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb);增加 AGENT-EXECUTION.md;增加共享执行器薄入口(deps/development-manager @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c + scripts/dm、scripts/agent-exec、scripts/task);四条路由(implementation/repair/test/review);任务卡模板与分支元数据;CI 中启用 schema 校验(.gitea/workflows/governance.yml:agent-exec validate + governance check);一次正例运行;负例(缺配置、坏收据、越界路径、未知副作用);独立 Review + CI;采纳状态 active。
非目标:不改业务代码与既有测试;不改分支保护(由用户执行);不发布 tag。
引导说明:AGENT-EXECUTION.md 由 host 以固定版本治理套件(dm-server governance init @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)生成并作为引导配置提交(执行器运行前必须存在有效配置;沿用 DM 采纳先例);其余文件由实施单元生成。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| governance-adoption-Agent-System-Frontend-r1 | 13 个允许路径(治理文件 + 两个 gitlink) | 5670c68e | implementation | cursor→antigravity→pi | run e3fd063d(dm-server pinned,attempts=1) | cursor / not-observable | success | d64dbc3a15d2(apply 快进) | 否 | /tmp/gov-receipt-Agent-System-Frontend-r1.json |
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | 8b59998d3c5b0545f3bb0b3e670b22aa7fe2179b | 8b59998d3c5b0545f3bb0b3e670b22aa7fe2179b | 2026-10-17T06:53:57Z | 一致 |
## 结果与验证(完成后填写)
- 结果:NOT_RUN
- 验证:见下表(命令输出由 /tmp/gov-neg/ev.sh 写入 .task/evidence/governance-adoption/,SELF 除非另注)。
- 影响与剩余项:NOT_RUN
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| 20261017T071544Z | `scripts/dm governance check --repo .` | d64dbc3a15d2 | 0 | SELF | ok fixtures;[输出](evidence/governance-adoption/00-20261017T071544Z.log) sha256:b4f09e161961151b |
| 20261017T071544Z | `scripts/agent-exec validate --repo .` | d64dbc3a15d2 | 0 | SELF | validate: ok;[输出](evidence/governance-adoption/01-20261017T071544Z.log) sha256:06f07c50e89b4bbf |
| 20261017T071544Z | `python3 /tmp/gov-neg/adopt_cases.py .` | d64dbc3a15d2 | 0 | SELF | [verdict] ALL PASS cases=5;[输出](evidence/governance-adoption/02-20261017T071544Z.log) sha256:f74ae904b59d73e4 |
@@ -1,45 +0,0 @@
# IAM U3 账号管理与本人恢复前端 任务卡
任务主记录:本文件随 PR 描述维护。
状态:已完成(合入前闭合);owner:weichen。PR:[Frontend PR #86](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/86)
## 基线与授权
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
- branch_kind:standalone;initiative_id:无(跨仓任务 Agent-System-Dev `feature/iam-u3-account-directory-recovery` 的前端子交付)
- R0:710ef836(分支创建时的 origin/main;分支早于本仓治理接入,原无任务卡,本卡在收口时补建);工作区:/home/weichen/projects/Agent-System-IAM-U3-Frontend-Combo/frontend(worktree);分支:feature/iam-u3-account-ui
- 父任务信息(仅子任务填写):无
- accepting_requirements:false
- 需求分流:由 Dev U3 主任务承接
- 授权与交付终点:用户已批准 U3 阶段 4–6 与 Frontend 组件阶段 7(HEAD `93ee723`);U4 合入后用户选择 merge_then_accept:同步 main、重跑证据、独立 Review、当前 HEAD CI 通过后合入,用户在部署上验收,问题另立修复。终点为本 PR 合入 main,再由 RDM/Dev 组合固定。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标:把 U3 账号界面移植到含 U4 职位权限 UI、账号链路修复(#94/#95)、密码登录与管理员临时重置(#107)、RDM 成员检索(#108)的 main 上,每项能力只保留一套实现(以 main 为基础补入 U3 缺少的行为),并按 U3 RDM 目录消费的命令合同补回成员/职责候选选择器。
非目标:不恢复 U3 登录 Key 标签页(main 已改为纯密码登录);不恢复 main 已删除的 rdm.duty.get 编辑器;不改 Backend/RDM/UI。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| iam-u3-frontend-onto-main-r2 | .gitea/workflows/ci.yml、src/、test/ | implementation | cursor→antigravity→pi | 1(e80375762b4e) | cursor / not-observable(新进程,fresh_session=true) | success | d47c493(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-onto-main-run2.json |
| iam-u3-frontend-rdm-selectors-r1 | src/、test/ | implementation | cursor→antigravity→pi | 1(16e6bae28677) | cursor / not-observable(新进程,fresh_session=true) | success | 72c941c(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-rdm-selectors-run.json |
| iam-u3-frontend-ci-ui-sha-r1 | .gitea/workflows/ci.yml | repair | cursor→antigravity→pi | 1(3f39344cbd2e) | cursor / not-observable(新进程,fresh_session=true) | success | afd9e75(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-ci-ui-sha-run.json |
首个单元 r1 因 U3 分支缺 AGENT-EXECUTION.md 返回 invalid-config(未启动 worker);改为在 main 上建同步分支移植。`e4041b3` 以 `-s ours` 记录原分支 `bc9b17a` 历史,树等于 `afd9e75`。
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|---|---|---|---|
| U4 后再同步 | 1650b4fff0163e48a752ecbd86a3c4b18771e287 | e4041b36b97c1d3ac335584644d906b0e8adf55c | 2026-10-18 |
| 合入前 pr-check | 1650b4fff0163e48a752ecbd86a3c4b18771e287 | 080c63081642e5670c8c7ff2646905f5b398c807 | 2026-10-19(PR_CHECK_OK,落后 main 0) |
## 结果与验证
- 结果:每个冲突文件的取舍(worker 记录):api.ts 以 main 为基础,删除 U3 重复方法,redeem 改走 `/api/auth/recovery-grants/redeem`;AccountUsersPanel 保留 main 组件并补入 U3 的写后重读、刷新失败提示、游标重置、409/启停提示与凭据状态列;adminSections 补入 U3 恢复提案幂等重试、创建后重读与终态提示、迟到响应丢弃;LoginPage/AdminZone/MyAccountSettings/SettingsModal 用 main;RDM 选择器按 RDM U3 合同(mode/searchFields/docId/cursor,成员添加带 userId,职责指派/预览带 accountIds)补回。
- 验证:主机在 `72c941c`(配对 UI `5c2b103`)上 `npx tsc -b`、`npx vitest run`(48 文件 565 测试)、`npm run build`、`node scripts/check-knip.mjs` 通过,日志 `/tmp/cleanup-20261018/render/u3-fe-72c941c-verify.log`(SELF)。当前 HEAD `e4041b3` 的 CI `ci / test-and-build` 与 `governance / governance` 通过(GATE)。
- Review:全新只读子代理会话审阅 `1650b4f..72c941c`,阻塞项为 ci.yml 的 UI_SHA 仍是旧 `7c35a78`(复现 tsc 失败),已由 `afd9e75` 改为 `5c2b103` 并经 CI 证明;非阻塞记录:`accountCandidatePager` 的 `issuedFor` 共享闭包在迟到响应下可能丢游标;adminSections 任何 409 都回落为临时重置(main 既有行为)。
- 验收:按 merge_then_accept,用户在部署上验收,问题另立修复。
@@ -1,41 +0,0 @@
# 法规结构化引用前端呈现(U7) 任务卡
任务主记录:本文件随 PR 描述维护(PR 链接见下方“结果与验证”)。
状态:已完成(合入前闭合);owner:weichen。PR:[Frontend PR #109](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/109)
## 基线与授权
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
- branch_kind:standalone;initiative_id:无(跨仓大任务 Agent-System-Dev `feature/regulations-kb` 的 U7 前端子交付,Dev PR #614 登记)
- R0(initial_main_sha):0459567(origin/main);工作区:/home/weichen/projects/Agent-System-Frontend-wt-feature-regulations-kb-citations(worktree);分支:feature/regulations-kb-citations
- 父任务信息(仅子任务填写):无
- accepting_requirements:false
- 需求分流:verdict=JOIN(由 Dev 大任务承接);selected_initiative=Agent-System-Dev feature/regulations-kb;理由=计划 U7 前端范围
- 授权与交付终点:owner 以 /goal 授权按法规知识库计划全部实施;终点为本 PR 合入 main,再由 RDM/Dev 组合钉住。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标:按 Dev 规格 `docs/superpowers/specs/2026-10-18-regulations-citation-contract.md` 第 5、6 节,在审计工作区新增“法规核验”树(`rdm.inspect.tree kind=regs`)与批/引用详情;驾驶舱阅读器把正文中的 `[cit-…]` 渲染为编号引用标记并可展开元数据与原文片段;未核验、无效与待复核状态清晰区分。
非目标:不改 RDM 写路径与命令;不改设计令牌;不做部署。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| frontend-u7-regulation-citations | src/、test/ | implementation | cursor→antigravity→pi | 1(35d799203497) | cursor / not-observable(新进程,fresh_session=true) | success | 6a3f244(apply 快进,run 4cf12d61) | 否 | /tmp/regkb-u7fe-receipt.json |
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | 0459567 | 0459567 | 2026-10-18 | 一致 |
| 验收前 | 0459567(origin/main) | 6a3f244 | 2026-10-18 | 一致,无需同步 |
## 结果与验证(完成后填写)
- 结果:新增 `src/regCitations.ts`(契约类型、`[cit-…]` 标记解析与编号、状态/过期/省略原因文案、样式档、审计树请求参数)与 `src/components/RegCitation.tsx`(编号标记、引用卡片、引用清单,折叠用 @ui Collapsible,正文与片段仅作文本渲染);审计工作区新增“法规核验”树(证据批→引用)与批/引用详情(状态、原因、腿状态、封存、引用元数据、受披露约束的片段);驾驶舱阅读器正文渲染引用标记与引用清单。只有 verified 且未过期显示为有效依据,其余状态与无效引用使用明确的未核验/无效样式。样式只用令牌(`src/reg-citations.css`)。
- 验证:host 在临时 Dev 组合布局(modules/ui c918747)重跑 `bash /tmp/regkb-fe-verify.sh all`(SELF):vitest 37 文件 345 用例通过(含 design-guard 与新增 reg-citations 测试),knip 未用导出 50 ≤ 51,`tsc -b` 与 `vite build` 通过,日志 /tmp/regkb-u7fe-verify.log;`git diff --check` 通过。权威结论以 PR HEAD CI 与 Dev 组合 `make test` 为准。
- Review:host 独立审阅实施 diff(未复用实施会话):未使用 dangerouslySetInnerHTML 与 `<details>`,外链仅 http(s),字段名与 RDM U6 JSON 一致;无阻塞项。
- 影响与剩余项:数据由 RDM U7(`rdm.inspect.tree kind=regs`、`rdm.inspect.object`、`rdm.cockpit.node` citations)提供;RDM 未提供时界面为空态,不影响既有标签页。Dev 组合 pin 在 U8 推进。
@@ -1,19 +0,0 @@
command: scripts/dm governance check --repo .
cwd: /home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption
head: d64dbc3a15d29520402f0c97199cb88b3e4aa9af
exit: 0
utc: 20261017T071544Z
---
ok gitlink deps/development-guidelines
ok gitlink deps/development-manager
ok agent-execution
ok file AGENTS.md
ok file PROJECT.md
ok file policy/guidelines-adoption.md
ok file .task/templates/task-card.md
ok executable scripts/agent-exec
ok executable scripts/hooks/pre-commit
ok executable scripts/dm
ok executable scripts/task
ok adoption status
ok fixtures
@@ -1,7 +0,0 @@
command: scripts/agent-exec validate --repo .
cwd: /home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption
head: d64dbc3a15d29520402f0c97199cb88b3e4aa9af
exit: 0
utc: 20261017T071544Z
---
validate: ok
@@ -1,13 +0,0 @@
command: python3 /tmp/gov-neg/adopt_cases.py .
cwd: /home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption
head: d64dbc3a15d29520402f0c97199cb88b3e4aa9af
exit: 0
utc: 20261017T071544Z
---
[object] Agent-System-Frontend-wt-feature-governance-adoption HEAD=d64dbc3a15d29520402f0c97199cb88b3e4aa9af
PASS positive-run-apply: run exit=0 terminal=success apply exit=0 head=6fa967fff457
PASS missing-config: run exit=2 terminal=invalid-config validate exit=2
PASS bad-receipt: apply exit=4 head_unchanged=True msg=apply: no local integrity record: changed paths do not match the transport diff
PASS out-of-scope-path: run exit=5 terminal=scope-expansion transport=False
PASS unknown-side-effect: run exit=4 terminal=failed-unknown transport=False
[verdict] ALL PASS cases=5
-66
View File
@@ -1,66 +0,0 @@
# <任务标题> 任务卡
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
状态:<进行中 / 待审核 / 已合入 / 已放弃>;owner:<填写>。
## 基线与授权
- 权威仓:<仓 URL 或逻辑 ID>;root_target:main;immediate_target:<main 或 feature/initiative>
- branch_kind:<initiative | child | standalone>;initiative_id:<稳定 ID,普通任务为空>
- R0(initial_main_sha):<完整 SHA>;工作区:<路径>(worktree / clone);分支:<feature|fix|hotfix/...>
- 父任务信息(仅子任务填写):parent_branch=<父分支>;parent_head_sha=<SHA>;parent_pr=<父 PR>
- accepting_requirements:<true | false>(仅 initiative 可为 true;冻结后为 false)
- 需求分流:verdict=<JOIN | NEW>;selected_initiative=<分支或无>;候选=<分支@HEAD 列表或无>;理由=<不适配/适配依据>;triage 收据=<源码外路径与摘要>
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
## Agent 实施收据
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
## 复用材料(task-start 自动检索结果摘录)
- 相关近期提交:<git log -- <paths> 命中;无则写无>。
- 相关经验条目:<检索命中;无则写无>。
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | <R0> | <HEAD> | <日期时间> | <一致/已同步> |
| 验收前 | <填写> | <填写> | <填写> | <填写> |
字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。
## 交付调度(有多个就绪任务时)
- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。
- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。
- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。
- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。
定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
## 结果与验证(完成后填写)
- 结果:<交付了什么;未发生写无>。
- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。
- 影响与剩余项:<影响面、遗留风险、后续任务>。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
## 回收(task-finish)
- 合入核对:<分支已包含于目标分支的核对输出>。
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
-67
View File
@@ -1,67 +0,0 @@
# Project Agent Execution Configuration
# 项目工程 Agent 执行路由配置
适用范围:定义本项目中由 AI / Agent 主导的代码实施、修复、测试与审阅所调用的 Agent CLI 路由。
解析器识别正文中唯一一个 ```agent-execution 代码块。正文外的说明文字可根据项目需要调整。
通用硬规则见 Development-Guidelines 的 [Agent 执行与协作契约](deps/development-guidelines/standards/agent-execution.md)。
本配置为项目本地路由权威,声明具体的候选 profile、尝试次数上限、超时、集成入口与豁免路径;
代码块须通过 `deps/development-guidelines/templates/schemas/agent-execution.schema.json` 校验后才能登记为 active。
`fallback_to_host` 只能为 `never`;旧值 `all-unavailable` 仅供迁移工具读取,不能通过准入。
下例的 CLI 名称、命令与路径均为占位,按项目实际执行器替换。
```agent-execution
schema: agent-execution/v1
execution:
fresh_process_per_unit: true
permission: run-everything
fallback_to_host: never
max_parallel: 3
timeout_seconds: 7200
idle_timeout_seconds: 600
summary_bytes: 16384
raw_output_bytes: 10485760
max_attempts_per_profile: 3
emergency_profile: codex
roles:
implementation: [cursor, antigravity, pi]
repair: [cursor, antigravity, pi]
test: [pi, antigravity, cursor]
review: [pi, antigravity, cursor, codex]
profiles:
cursor:
adapter: cursor
executable: "cursor-agent-stable"
model: auto
enabled: true
antigravity:
adapter: antigravity
executable: agy
model: auto
enabled: true
pi:
adapter: pi
executable: pi
model: auto
enabled: true
codex:
adapter: codex
executable: codex
model: auto
enabled: true
integration:
exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"]
transport:
command: "scripts/agent-exec apply"
admission:
command: "scripts/agent-exec admit"
cleanup:
command: "scripts/agent-exec cleanup"
```
-18
View File
@@ -35,21 +35,3 @@
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
## Agent 执行与新项目准入(强制)
1. 缺 Guidelines 固定版本(`deps/development-guidelines` gitlink 未就绪或 HEAD 不符)时,Agent 只能只读分析,不得实施。
2. 缺有效 `AGENT-EXECUTION.md`(`scripts/dm governance check` 未通过)时禁止 AI 实施。
3. 缺共享执行器(`scripts/agent-exec` 或 `deps/development-manager` 不可用)时,状态为 `blocked/unavailable`,不得改由 host 替代。
4. 不允许 host 代写实施单元;实施、修复与测试必须经 `AGENT-EXECUTION.md` 登记的候选执行。
5. 人类直接开发按其自身授权与项目规则进行,不受本条款约束。
6. 分支保护与规范 tag 由用户负责设置与维护,Agent 不得代为设置。
入口:
- 需求分流:`scripts/task triage discover|decide|verify`
- 任务启动:`scripts/task start …`
- PR 检查:`scripts/task pr-check …`
- Agent 执行:`scripts/agent-exec run|apply|admit|validate`
AI 实施单元仅通过 `scripts/agent-exec run` 运行(依据 AGENT-EXECUTION.md),并经 `scripts/agent-exec apply` 集成。
-1
View File
@@ -10,5 +10,4 @@
- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/(Dev Makefile 拷贝至 backend web/dist 内嵌)。
- `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。
- 开发:`npm run dev`(Vite 代理 /api → 127.0.0.1:8787)。
- 治理:`scripts/agent-exec validate --repo .` — 校验 AGENT-EXECUTION.md 执行路由配置;`scripts/dm governance check --repo .` — 校验治理套件(Guidelines 固定版本、采纳记录与执行配置)。
- 依赖刻意最小:react + marked + dompurify(模型输出与文档预览先清洗再写入页面);无 UI 框架(设计令牌手写,见 styles.css)。
+200
View File
@@ -0,0 +1,200 @@
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.ADMIN_NOTES_REPAIR_BROWSER, 'Run against the task-owned UI preview')
// Fixed EasyList cosmetic rules that hid the real management controls on 8788.
// https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_general_hide.txt
const cosmeticRules = '.ad-body,.ad-hint,.ad-notice,.ad-row,.ad-table { display:none!important }'
const stamp = '2026-10-13T02:00:00Z'
const commands = ['rdm.role.create', 'rdm.role.action', 'rdm.role.assign', 'rdm.role.disable', 'rdm.role.list',
'rdm.inspect.capabilities', 'rdm.inspect.summary', 'rdm.inspect.tree', 'rdm.inspect.object',
'rdm.inspect.relations', 'rdm.inspect.events', 'rdm.inspect.history']
const roles = [
{ roleId: 'engineer', name: '工程师', disabled: false, version: 1, actions: { 'project.create': [true, true], 'doc.write': [false, true] }, accounts: [] },
{ roleId: 'reviewer', name: '审核员', disabled: false, version: 1, actions: { 'project.create': [false, false] }, accounts: [] },
]
const shell = (items: unknown[], nextCursor = '') => ({ items, hasMore: !!nextCursor, nextCursor, truncated: false, coverage: [] })
async function fixture(page: Page) {
let detailFailed = false
let childFailed = false
let releaseSlow!: () => void
let reportSlow!: () => void
const slow = new Promise<void>(resolve => { releaseSlow = resolve })
const slowStarted = new Promise<void>(resolve => { reportSlow = resolve })
page.on('close', releaseSlow)
await page.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
let body: unknown = []
if (path === '/api/health') body = { ok: true }
else if (path === '/api/me') body = { owner: 'repair-admin', name: '修复验证', auth: true, authVia: 'session', isAdmin: true, mustChangePassword: false }
else if (path === '/api/settings') body = {}
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
else if (path === '/api/admin-zone/sections') body = [{ id: 'rdm.audit', title: '项目追溯与审计', icon: 'search' }]
else if (path === '/api/admin/users') body = [{ owner: 'repair-admin', name: '修复验证', isAdmin: true, mustChangePassword: false, hasPassword: true, createdAt: stamp }]
else if (path.startsWith('/api/commands/')) {
const command = decodeURIComponent(path.slice('/api/commands/'.length))
const params = request.postDataJSON()?.params ?? {}
let payload: unknown
if (command === 'rdm.role.list') payload = { roles, count: 2 }
else if (command === 'rdm.role.action') payload = params
else if (command === 'rdm.inspect.capabilities') payload = { actor: 'repair-admin', crossProject: true, projects: [
{ projectId: 'p1', name: '主项目', canAudit: true }, { projectId: 'p2', name: '隔离项目', canAudit: true },
] }
else if (command === 'rdm.inspect.summary') payload = { projectId: params.projectId, counts: {}, watermark: 10, coverage: [] }
else if (command === 'rdm.inspect.tree') {
if (params.projectId === 'p2') payload = params.kind === 'notes'
? shell([{ kind: 'spine', id: 'other', title: '隔离骨架' }])
: shell([{ kind: 'doc', id: 'other-doc', title: '隔离文档' }])
else if (params.kind === 'docs') payload = shell(params.parent ? [{ kind: 'section', id: 'sec1', title: '章节一' }] : [{ kind: 'doc', id: 'doc1', title: '项目文档' }])
else if (!params.parent) payload = shell([
{ kind: 'spine', id: 'root', title: '阶段骨架' },
{ kind: 'slice', id: 'slow', title: '慢速资料' }, { kind: 'slice', id: 'fast', title: '快速资料' },
])
else if (params.parent === 'root') payload = params.cursor ? shell([{ kind: 'slice', id: 'second', title: '第二页资料' }]) : shell([{ kind: 'spine', id: 'child', title: '需求骨架' }], 'root-page-2')
else if (params.parent === 'child') {
if (!childFailed) {
childFailed = true
return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: 'temporary child query failure' } } })
}
payload = shell([{ kind: 'slice', id: 'meeting', title: '会议资料' }])
} else payload = shell([])
} else if (command === 'rdm.inspect.object') {
if (params.id === 'meeting' && !detailFailed) {
detailFailed = true
return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: 'temporary detail query failure' } } })
}
if (params.id === 'slow') { reportSlow(); await slow }
const titles: Record<string, string> = { root: '阶段骨架详情', child: '需求骨架详情', meeting: '会议资料详情', slow: '慢速资料详情', fast: '快速资料详情', second: '第二页资料详情', sec1: '章节一详情', other: '隔离骨架详情' }
payload = { kind: params.kind, id: params.id, projectId: params.projectId, title: titles[params.id], parentId: params.id === 'child' ? 'root' : '', childCount: 1, sliceCount: 1, relations: [], recentEvents: [], coverage: [] }
} else return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: `unexpected command ${command}` } } })
body = { receipt: { state: 'committed', commandId: command }, payload }
}
await route.fulfill({ status: 200, json: body })
})
await page.goto('/')
await expect(page.locator('.tsb-user')).toContainText('修复验证')
await page.locator('.tsb-user').click()
await page.getByText('系统管理…', { exact: true }).click()
return { slowStarted, releaseSlow }
}
async function notes(page: Page) {
await page.getByRole('button', { name: '项目追溯与审计', exact: true }).click()
await page.getByRole('tab', { name: '资料与文档' }).click()
await page.getByRole('button', { name: '笔记树', exact: true }).click()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toBeVisible()
}
test('advertising filters leave management forms and role tables visible', async ({ page }, testInfo) => {
await fixture(page)
await page.addStyleTag({ content: cosmeticRules })
await expect(page.getByPlaceholder('用户名(登录名)')).toBeVisible()
await page.getByPlaceholder('用户名(登录名)').fill('new-user')
await page.getByPlaceholder('初始密码(≥8 位)').fill('fixture-password')
await expect(page.getByRole('button', { name: '创建账号', exact: true })).toBeEnabled()
await expect(page.getByRole('cell', { name: 'repair-admin', exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('users-with-adblock.png') })
await page.getByRole('button', { name: 'RDM 角色权限', exact: true }).click()
await expect(page.getByRole('button', { name: '创建角色', exact: true })).toBeVisible()
await expect(page.getByRole('cell', { name: /工程师/ })).toBeVisible()
await expect(page.getByLabel('默认允许')).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('roles-with-adblock.png') })
})
test('selecting a role and action loads their actual permission checkboxes', async ({ page }) => {
await fixture(page)
await page.getByRole('button', { name: 'RDM 角色权限', exact: true }).click()
const selects = page.locator('.az-main select')
await selects.nth(0).selectOption('engineer')
await expect(page.getByLabel('默认允许')).toBeChecked()
await expect(page.getByLabel('上限允许')).toBeChecked()
const saved = page.waitForRequest(r => r.method() === 'POST' && r.url().endsWith('/rdm.role.action'))
const refreshed = page.waitForResponse(r => r.url().endsWith('/rdm.role.list'))
await page.getByRole('button', { name: '保存动作', exact: true }).click()
expect((await saved).postDataJSON()).toEqual({ params: {
roleId: 'engineer', action: 'project.create', default: true, cap: true,
} })
await refreshed
await expect(page.getByLabel('默认允许')).toBeChecked()
await expect(page.getByLabel('上限允许')).toBeChecked()
await selects.nth(1).selectOption('doc.write')
await expect(page.getByLabel('默认允许')).not.toBeChecked()
await expect(page.getByLabel('上限允许')).toBeChecked()
await selects.nth(1).selectOption('project.create')
await selects.nth(0).selectOption('reviewer')
await expect(page.getByLabel('默认允许')).not.toBeChecked()
await expect(page.getByLabel('上限允许')).not.toBeChecked()
})
test('notes expand nested spines, retry a child query, and inspect mounted slices', async ({ page }, testInfo) => {
await fixture(page)
await notes(page)
await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click()
await expect(page.locator('.rdm-audit-detail')).toContainText('阶段骨架详情')
await page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' }).click()
await expect(page.getByRole('button', { name: '重试列表', exact: true })).toBeVisible()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toBeVisible()
await page.getByRole('button', { name: '重试列表', exact: true }).click()
await page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' }).click()
await expect(page.getByRole('button', { name: '重试详情', exact: true })).toBeVisible()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toBeVisible()
await page.getByRole('button', { name: '重试详情', exact: true }).click()
await expect(page.locator('.rdm-audit-detail')).toContainText('会议资料详情')
await page.screenshot({ path: testInfo.outputPath('notes-detail.png') })
})
test('each expanded spine can load its next page', async ({ page }) => {
await fixture(page)
await notes(page)
await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toBeVisible()
await page.getByRole('button', { name: '加载更多', exact: true }).click()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '第二页资料' })).toBeVisible()
})
test('late detail responses cannot replace the newly selected object', async ({ page }) => {
const control = await fixture(page)
await notes(page)
await page.locator('.rdm-audit-tree-node').filter({ hasText: '慢速资料' }).click()
await control.slowStarted
await page.locator('.rdm-audit-tree-node').filter({ hasText: '快速资料' }).click()
await expect(page.locator('.rdm-audit-detail')).toContainText('快速资料详情')
const response = page.waitForResponse(r => r.url().endsWith('/rdm.inspect.object') && r.request().postDataJSON()?.params?.id === 'slow')
control.releaseSlow()
await response
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
await expect(page.locator('.rdm-audit-detail')).toContainText('快速资料详情')
})
test('project switching clears note descendants and in-flight object details', async ({ page }) => {
const control = await fixture(page)
await notes(page)
await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toBeVisible()
await page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' }).click()
await page.getByRole('button', { name: '重试列表', exact: true }).click()
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toBeVisible()
await page.locator('.rdm-audit-tree-node').filter({ hasText: '慢速资料' }).click()
await control.slowStarted
await page.locator('.rdm-audit-project').selectOption('p2')
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '隔离骨架' })).toBeVisible()
const response = page.waitForResponse(r => r.url().endsWith('/rdm.inspect.object') && r.request().postDataJSON()?.params?.id === 'slow')
control.releaseSlow()
await response
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toHaveCount(0)
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toHaveCount(0)
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toHaveCount(0)
await expect(page.locator('.rdm-audit-detail')).toHaveCount(0)
})
test('document expansion and section inspection remain available', async ({ page }) => {
await fixture(page)
await page.getByRole('button', { name: '项目追溯与审计', exact: true }).click()
await page.getByRole('tab', { name: '资料与文档' }).click()
await page.locator('.rdm-audit-tree-node').filter({ hasText: '项目文档' }).click()
await page.locator('.rdm-audit-tree-node').filter({ hasText: '章节一' }).click()
await expect(page.locator('.rdm-audit-detail')).toContainText('章节一详情')
})
-81
View File
@@ -1,81 +0,0 @@
// 防再发守卫:管理员建号路径(UsersPanel)必须持续符合后端待初始化契约。
// 背景:backend 8dfc223 将 POST /api/admin/users 改为严格 {owner, profile} 解码,
// 前端未跟进导致部署后 UI 建号 400(json: unknown field "name")——单测与 mock
// 都没有覆盖这条组合路径,本场景以真实浏览器把「填用户名 → 创建账号 →
// 确认『待初始化』提示与用户表行」钉进组合验收线。
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
// 与 vite preview;种子账号 e2e-auditor 为基座管理员(bootstrap-admin)。
// 环境契约(缺省值与 e2e.sh 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过):
// PLAYWRIGHT_BASE_URL / E2E_OWNER / E2E_PASSWORD / E2E_PASSWORD2
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
async function login(page: Page) {
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
// 三种落点:直接进工作台(密码已生效)|强制改密门|初始密码已失效(种子二次运行)→ 改用新密码重登。
const entered = await page.locator('.tsb-user').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (!entered) {
const gated = await page.getByText('修改密码').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
} else {
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
}
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
}
test('管理员建号:UI 请求新契约并给出待初始化提示', async ({ page }) => {
await login(page)
// 账户菜单 → 系统管理…(全屏独立视图)→ 用户管理分区。
await page.locator('.tsb-user').click()
const adminEntry = page.getByText('系统管理…', { exact: true })
await expect(adminEntry).toBeVisible()
await adminEntry.click()
await expect(page.locator('.az')).toBeVisible()
const usersSection = page.getByText('用户管理', { exact: true }).first()
if (await usersSection.count()) await usersSection.click()
// 建号表单:用户名每次运行唯一(重复运行不因 409 假红),显示名可空。
const newOwner = `e2e-ui-${Date.now().toString(36)}`
const userInput = page.getByPlaceholder('用户名(登录名)')
await expect(userInput).toBeVisible()
await userInput.fill(newOwner)
await page.getByPlaceholder('显示名(可空)').fill('组合验收')
// 创建 → 契约断言一:请求体是严格 {owner, profile:{name}},无 password/isAdmin。
const createBody = page.waitForRequest(
req => req.url().endsWith('/api/admin/users') && req.method() === 'POST',
).then(req => req.postDataJSON())
await page.getByRole('button', { name: '创建账号', exact: true }).click()
const body = await createBody
expect(body).toEqual({ owner: newOwner, profile: { name: '组合验收' } })
// 契约断言二:页面创建后账号可直接使用,不再提示 CLI 初始化。
await expect(page.locator('.ad-notice')).toContainText(`已创建 ${newOwner}`)
// 契约断言三:用户表出现该账号,状态为未设密码(HTTP 建号不能设密码/授权限)。
const row = page.locator('.ad-table tbody tr', { hasText: newOwner })
await expect(row).toBeVisible()
await expect(row).toContainText('未设密码')
await expect(row).not.toContainText('ADMIN')
// 界面说明与后端流程一致(激活 Key 由服务器本地 CLI 发放)。
await expect(page.locator('.ad-create .ad-hint')).toContainText('初始密码')
await expect(page.locator('.ad-create .ad-hint')).not.toContainText('user initialize-account')
})
+4 -11
View File
@@ -17,21 +17,14 @@ async function login(page: Page) {
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
// 受限设密门(2026-10-13 合同):heading 唯一定位(标题与按钮同名),
// 门挂载后登录卡已卸载,fill 不再与登录卡竞态。
const gate = page.getByRole('heading', { name: '设置新密码' })
const gate = page.getByText('修改密码')
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD2)
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
// 设密成功后服务端销毁会话:等成功页再回登录(在途点取消会被会话复验拒绝)。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
} else {
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
-775
View File
@@ -1,775 +0,0 @@
// 统一渲染 §11 浏览器验收:真实产品(RDM 二进制 auth on + dist preview)走通九项。
// 驱动方:/tmp/renderer-browser-acceptance/browser-acceptance.sh(种子写 $RBA_OUT/seed.env);
// 未提供 PLAYWRIGHT_BASE_URL 或 RBA_OUT 时整组跳过。
// 产品缺能力处用 expect.soft('GAP[仓] …') 记失败并继续取证;浏览器做不到、只能以本人会话
// 直调命令的步骤记为 API_ASSISTED。跨用例状态写 $RBA_OUT/state.json(失败会重启 worker)。
import { test, expect, type Browser, type Locator, type Page } from '@playwright/test'
import fs from 'node:fs'
import path from 'node:path'
const env = (k: string) => process.env[k] ?? ''
const OUT = env('RBA_OUT')
const EVID = OUT ? path.join(OUT, 'evidence') : ''
const RUN = path.basename(OUT || 'run')
const PW = env('RBA_PASSWORD')
const U = { admin: env('RBA_ADMIN'), author: env('RBA_AUTHOR'), reviewer: env('RBA_REVIEWER'), approver: env('RBA_APPROVER'), outsider: env('RBA_OUTSIDER') }
const A = { name: env('RBA_PROJECT_A'), pid: env('RBA_PID_A'), host: env('RBA_HOST_A'), doc: env('RBA_DOC_A'), title: env('RBA_DOC_A_TITLE') }
const B = { name: env('RBA_PROJECT_B'), pid: env('RBA_PID_B'), host: env('RBA_HOST_B'), doc: env('RBA_DOC_B') }
const UNCTRL = { doc: env('RBA_UNCTRL_DOC'), title: env('RBA_UNCTRL_TITLE') }
const GENERAL = { id: env('RBA_GENERAL_PROFILE'), version: env('RBA_GENERAL_VERSION') }
const TEMPLATE_PROFILE = `company.${env('RBA_TEMPLATE')}`
const ANNOTATION = `§11 批注 ${RUN}:请补充目标量化指标`
const RANGE_ANNOTATION = `§11 范围批注 ${RUN}:「目标」需可量化`
const REVISION_AFTER = `§11 修订 ${RUN}:目标改为三项可量化指标`
test.skip(!process.env.PLAYWRIGHT_BASE_URL || !OUT, 'set PLAYWRIGHT_BASE_URL and RBA_OUT (browser-acceptance.sh drives this suite)')
test.describe.configure({ mode: 'default', timeout: 240_000 })
// ---- 证据与跨用例状态 ----
const STATE = () => path.join(OUT, 'state.json')
const readState = (): Record<string, any> => { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')) } catch { return {} } }
const writeState = (patch: Record<string, unknown>) => fs.writeFileSync(STATE(), JSON.stringify({ ...readState(), ...patch }, null, 2))
let evidence: string[] = []
let notes: string[] = []
let openSessions = new Set<() => Promise<void>>()
const note = (s: string) => { notes.push(s) }
test.beforeEach(() => { evidence = []; notes = []; openSessions = new Set() })
test.afterEach(async ({}, info) => {
for (const close of [...openSessions]) await close().catch(() => {})
const items = info.annotations.filter(a => a.type === 'item').map(a => a.description)
const dir = path.join(EVID, 'results')
fs.mkdirSync(dir, { recursive: true })
const errors = info.errors.map(e => (e.message ?? String(e)).replace(/\u001b\[[0-9;]*m/g, '').slice(0, 1500))
fs.writeFileSync(path.join(dir, `${info.testId}.json`), JSON.stringify({
title: info.title, items, status: info.status, errors, evidence, notes,
apiAssisted: info.annotations.filter(a => a.type === 'API_ASSISTED').map(a => a.description),
durationMs: info.duration,
}, null, 2))
})
const items = (...ids: number[]) => { for (const id of ids) test.info().annotations.push({ type: 'item', description: String(id) }) }
const apiAssisted = (what: string) => test.info().annotations.push({ type: 'API_ASSISTED', description: what })
async function shot(page: Page, name: string, target?: Locator) {
const file = path.join(EVID, 'screens', `${name}.png`)
fs.mkdirSync(path.dirname(file), { recursive: true })
if (target) await target.screenshot({ path: file })
else await page.screenshot({ path: file, fullPage: false })
evidence.push(file)
}
interface NetEntry { url: string; method: string; status: number; request: string; body: string }
/** 每个会话一个上下文:主题经真实持久化入口 localStorage('as.ui') 注入;记录全部 /api 响应体。 */
async function session(browser: Browser, owner: string, tag: string, opts: { theme?: 'light' | 'dark'; width?: number; height?: number } = {}) {
const ctx = await browser.newContext({ viewport: { width: opts.width ?? 1440, height: opts.height ?? 900 }, acceptDownloads: true, locale: 'zh-CN' })
if (opts.theme) {
await ctx.addInitScript(theme => {
const prev = (() => { try { return JSON.parse(localStorage.getItem('as.ui') || '{}') } catch { return {} } })()
localStorage.setItem('as.ui', JSON.stringify({ ...prev, theme }))
}, opts.theme)
}
const page = await ctx.newPage()
const net: NetEntry[] = []
const pending: Promise<void>[] = []
page.on('response', res => {
const url = res.url()
if (!url.includes('/api/') || (res.headers()['content-type'] ?? '').includes('event-stream')) return
pending.push(Promise.race([res.text(), new Promise<string>(r => setTimeout(() => r('<<body timeout>>'), 5_000))])
.catch(() => '<<no body>>')
.then(body => { net.push({ url, method: res.request().method(), status: res.status(), request: res.request().postData() ?? '', body }) }))
})
const close = async () => {
openSessions.delete(close)
await Promise.all(pending)
const file = path.join(EVID, 'network', `${tag}.jsonl`)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, net.map(n => JSON.stringify(n)).join('\n') + '\n')
evidence.push(file)
await ctx.close()
}
openSessions.add(close)
await login(page, owner)
return { page, net, pending, close }
}
async function login(page: Page, owner: string) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(owner)
await page.getByPlaceholder('初始密码或常用密码').fill(PW)
await page.getByRole('button', { name: '登录', exact: true }).click()
await page.locator('.tsb-tool', { hasText: '追溯审计' }).waitFor({ state: 'attached', timeout: 20_000 })
}
/** 窄屏下侧栏收进「上下文」面板:先经工作台导航打开,再点审计入口。 */
async function clickAuditEntry(page: Page) {
const entry = page.locator('.tsb-tool', { hasText: '追溯审计' })
if (!(await entry.isVisible())) await page.getByRole('button', { name: '打开上下文' }).click()
await entry.click()
}
async function openAudit(page: Page, projectName: string) {
await clickAuditEntry(page)
await expect(page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 })
await page.locator('.rdm-audit-project').selectOption({ label: projectName })
}
async function openDoc(page: Page, title: string) {
await page.getByRole('tab', { name: '资料与文档' }).click()
const open = page.getByRole('button', { name: `阅读与交付:${title}`, exact: true })
for (let i = 0; i < 5 && !(await open.isVisible().catch(() => false)); i++) {
const more = page.locator('.rdm-audit-more')
if (await more.isVisible().catch(() => false)) await more.click()
else await page.waitForTimeout(500)
}
await open.click()
await expect(page.locator('.drw')).toBeVisible()
await expect(page.locator('.drw-canvas-node').first()).toBeVisible({ timeout: 20_000 })
}
const panel = (page: Page, label: string) =>
page.locator('.drw-panel').filter({ has: page.locator('.ui-seclabel', { hasText: new RegExp(`^${label}$`) }) })
const deliveryOf = (page: Page, mode: '预览' | '审阅' | '批准') =>
page.locator('.drw-delivery').filter({ has: page.locator('.ui-chip', { hasText: new RegExp(`^${mode}$`) }) }).first()
/** 以本人浏览器会话直调命令(与前端同一 /api/commands 通道、同一 cookie)。 */
async function command(page: Page, id: string, params: Record<string, unknown>, source?: string) {
const [conversationId, anchorMessageId] = (source ?? '').split(' ')
const res = await page.request.post(`/api/commands/${id}`, {
data: { params, ...(source ? { entrance: 'api', conversationId, anchorMessageId } : {}) },
})
const text = await res.text()
let body: any = null
try { body = JSON.parse(text) } catch { /* 非 JSON 原样保留 */ }
const file = path.join(EVID, 'api', `${test.info().testId}-${id}.json`)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, JSON.stringify({ id, params, status: res.status(), body: body ?? text }, null, 2))
evidence.push(file)
return { status: res.status(), text, body }
}
async function download(page: Page, delivery: Locator, label: string) {
const button = delivery.getByRole('button', { name: label, exact: true })
await expect(button, `下载入口「${label}」`).toBeVisible()
const [dl] = await Promise.all([page.waitForEvent('download', { timeout: 30_000 }), button.click()])
const name = dl.suggestedFilename()
const file = path.join(EVID, 'downloads', name)
fs.mkdirSync(path.dirname(file), { recursive: true })
await dl.saveAs(file)
evidence.push(file)
return { name, file, size: fs.statSync(file).size }
}
async function expectReadableFailures(page: Page) {
for (const msg of await page.locator('.drw-failure-msg').allTextContents()) {
expect(msg.trim().startsWith('{'), `失败文案不得是原始 JSON:${msg}`).toBe(false)
}
}
const parseJson = (text: string): any => { try { return JSON.parse(text) } catch { return null } }
/**
* 工作区整文决定的取证:先有同会话的已提交来源消息(POST /messages 响应),命令再以它为顶层锚点分发,
* params 不含来源字段,入口为页面动作;回执必须 committed。
*/
async function expectCommittedDecision(s: { net: NetEntry[] }, command: string, label: string) {
const cmdUrl = `/api/commands/${command}`
await expect.poll(() => s.net.some(n => n.url.includes(cmdUrl)), { message: `应分发 ${command}`, timeout: 15_000 }).toBe(true)
const dispatch = s.net.filter(n => n.url.includes(cmdUrl)).at(-1)!
const body = parseJson(dispatch.request)
expect(body.params, '命令参数不携带来源字段').not.toHaveProperty('anchorMessageId')
expect(body.entrance, '工作区动作入口').toBe('button')
const findSource = () => s.net.find(n => n.method === 'POST'
&& n.url.includes(`/api/conversations/${body.conversationId}/messages`)
&& parseJson(n.body)?.message?.id === body.anchorMessageId)
await expect.poll(() => findSource() !== undefined, { message: '锚点必须是同会话已提交的来源消息', timeout: 15_000 }).toBe(true)
expect(parseJson(findSource()!.request)?.text, '来源消息写明中文审签决定').toContain(`审签决定:${label}`)
expect(parseJson(dispatch.body)?.receipt?.state, `${command} 回执 committed`).toBe('committed')
}
// ---- §11 ----
test('§11-1 文档树 → 结构画布与阅读视图(共用选中与过滤)', async ({ browser }) => {
items(1)
const s = await session(browser, U.author, 'item1-author')
const { page } = s
await openAudit(page, A.name)
await page.getByRole('tab', { name: '资料与文档' }).click()
const docRow = page.locator('.rdm-audit-tree-row').filter({ hasText: A.title })
await expect(docRow).toBeVisible({ timeout: 15_000 })
await docRow.locator('.rdm-audit-tree-node').click()
await expect(page.locator('.rdm-audit-tree-children').first()).toBeVisible({ timeout: 15_000 })
await shot(page, 'item1-doc-tree')
await openDoc(page, A.title)
const nodes = page.locator('.drw-canvas-node')
const total = await nodes.count()
note(`结构画布节点数 ${total}`)
expect(total, '受控文档应展开为多节点结构').toBeGreaterThan(10)
const blank = (await page.locator('.drw-canvas-title').allTextContents()).filter(t => !t.trim()).length
expect(blank, '画布不得出现无标题的空白节点').toBe(0)
await shot(page, 'item1-canvas')
const target = page.locator('.drw-canvas-node[title="项目内容、目标及意义"]').first()
await target.click()
await expect(target).toHaveAttribute('aria-selected', 'true')
await page.getByLabel('过滤节点').fill('目标')
await page.getByRole('tab', { name: '阅读视图' }).click()
await expect(page.locator('.drw-reader')).toBeVisible()
await expect(page.locator('.drw-reader-title[aria-current="true"]')).toHaveText('项目内容、目标及意义')
await expect(page.getByLabel('过滤节点')).toHaveValue('目标')
await shot(page, 'item1-reader-filtered')
await page.getByLabel('过滤节点').fill('')
await page.getByRole('tab', { name: '结构画布' }).click()
await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toHaveAttribute('title', '项目内容、目标及意义')
// 键盘导航
await page.locator('.drw-canvas-node[aria-selected="true"]').focus()
await page.keyboard.press('ArrowDown')
await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toBeFocused()
await s.close()
})
test('§11-2b 规则绑定:项目设置权限可绑定与解绑(非受控文档)', async ({ browser }) => {
items(2)
const s = await session(browser, U.admin, 'item2b-admin')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, UNCTRL.title)
const binding = panel(page, '规则绑定')
await expect(binding, '具备项目设置权限的用户可见规则绑定区').toBeVisible({ timeout: 20_000 })
await page.locator('.drw-bind-rule', { hasText: `${GENERAL.id}@${GENERAL.version}` }).locator('input').check()
await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '绑定所选规则', exact: true }).click()
await expect(panel(page, '规则绑定').getByText('绑定规则已完成')).toBeVisible({ timeout: 30_000 })
await expectReadableFailures(page)
await expect(panel(page, '渲染规则').locator('.drw-profile-source', { hasText: '文档规则' }), '绑定后规则列表刷新出文档规则').toBeVisible({ timeout: 15_000 })
await shot(page, 'item2b-bound', binding)
await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '解绑', exact: true }).click()
await expect(panel(page, '规则绑定').getByText('解绑规则已完成')).toBeVisible({ timeout: 30_000 })
await expectReadableFailures(page)
await s.close()
})
test('§11-3b 受控文档拒绝绑定通用规则,失败以可读文案显示', async ({ browser }) => {
items(3)
const s = await session(browser, U.admin, 'item3b-admin')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const binding = panel(page, '规则绑定')
await expect(binding, '具备项目设置权限的用户可见规则绑定区').toBeVisible({ timeout: 20_000 })
await page.locator('.drw-bind-rule', { hasText: `${GENERAL.id}@${GENERAL.version}` }).locator('input').check()
await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '绑定所选规则', exact: true }).click()
await expect(binding.locator('.drw-failure-msg')).toContainText('受控文档', { timeout: 30_000 })
await expect(binding.locator('.drw-failure-msg')).not.toContainText('controlled_requires_exact_profile')
await shot(page, 'item3b-controlled-refusal', binding)
await s.close()
})
test('§11-3 受控文档:规则来源、版本与不回退说明', async ({ browser }) => {
items(3)
const s = await session(browser, U.author, 'item3-author')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const rules = panel(page, '渲染规则')
const option = rules.locator('.drw-profile').first()
await expect(option).toBeVisible({ timeout: 15_000 })
await expect(option.locator('.drw-profile-source')).toHaveText('模板规则')
await expect(option.locator('.drw-profile-id')).toHaveText(TEMPLATE_PROFILE)
await expect(option.locator('.drw-profile-ver')).toHaveText(`v${env('RBA_TEMPLATE_VERSION')}`)
// 44 个 DHF 专用规则均已发布(state=published、publicationAllowed=true):状态为已发布,可正式交付,无未发布原因。
await expect(option.locator('.ui-chip').first()).toHaveText('已发布')
await expect(option.locator('.ui-chip', { hasText: '可正式交付' })).toBeVisible()
await expect(option.locator('input[type="radio"]')).toBeEnabled()
await expect(option.locator('.drw-profile-reason')).toHaveCount(0)
await expect(rules.locator('.drw-notice')).toContainText('不会回退到项目或公司默认规则')
await expect(rules.locator('.drw-failure-msg')).toContainText('不得回退到项目或公司默认规则')
await expectReadableFailures(page)
await shot(page, 'item3-rules-controlled', rules)
await s.close()
})
test('§11-2 专用规则与公司通用规则可选', async ({ browser }) => {
items(2)
const s = await session(browser, U.author, 'item2-author')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const rules = panel(page, '渲染规则')
await expect(rules.locator('.drw-profile').first()).toBeVisible({ timeout: 15_000 })
const sources = await rules.locator('.drw-profile-source').allTextContents()
note(`受控文档可选规则来源:${sources.join('、')}`)
expect(sources.some(x => x === '模板规则' || x === '文档类型规则' || x === '文档规则'), '应提供专用(模板/文档类型)规则').toBe(true)
await shot(page, 'item2-controlled-rules', rules)
await page.getByRole('button', { name: '← 返回文档树' }).click()
await openDoc(page, UNCTRL.title)
const rulesU = panel(page, '渲染规则')
await expect(rulesU.locator('.drw-profile').or(rulesU.locator('.drw-failure')).or(rulesU.getByText('没有可用的渲染规则')).first()).toBeVisible({ timeout: 15_000 })
const uSources = await rulesU.locator('.drw-profile-source').allTextContents()
note(`非受控文档可选规则来源:${uSources.join('、') || '(无)'};拒绝:${(await rulesU.locator('.drw-failure-msg').allTextContents()).join(' | ')}`)
await shot(page, 'item2-uncontrolled-rules', rulesU)
expect.soft(uSources.includes('公司默认规则'),
`GAP[RDM] 已发布的公司通用规则 ${GENERAL.id}@${GENERAL.version} 不出现在非受控文档的规则列表:rdm.render.profiles 只读 rdm_render_profile_bindings,而产品无任何写绑定命令(organization-default/project-default/document-type 绑定不可达)`).toBe(true)
expect.soft(sources.length + uSources.length >= 2 && uSources.length >= 1,
'GAP[RDM] 浏览器内无法在同一文档上于专用规则与公司通用规则之间选择(专用规则只到 validated;通用规则无绑定)').toBe(true)
await s.close()
})
test('§11-4 生成预览并把诊断定位到节点', async ({ browser }) => {
items(4)
const s = await session(browser, U.author, 'item4-author')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const generate = page.getByRole('button', { name: '生成预览', exact: true })
await expect(generate).toBeEnabled({ timeout: 15_000 })
await generate.click()
const preview = panel(page, '预览与提交')
await expect(preview.locator('.drw-receipt').or(preview.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 90_000 })
await expectReadableFailures(page)
await shot(page, 'item4-preview-receipt')
await expect(preview.locator('.drw-op .drw-failure'), '预览不应失败').toHaveCount(0)
const receipt = preview.locator('.drw-receipt')
await expect(receipt.locator('.ui-chip').first()).toHaveText('已生成', { timeout: 60_000 })
await expect(receipt.getByText('诊断读取中')).toHaveCount(0, { timeout: 60_000 })
await expect(receipt.locator('.drw-diags').or(receipt.getByText('没有诊断问题')).or(receipt.locator('.drw-failure')).first()).toBeVisible()
await expectReadableFailures(page)
const summary = await receipt.locator('.drw-muted').first().textContent()
const located = receipt.locator('button.drw-diag-node')
const locatedCount = await located.count()
const unlocated = await receipt.locator('.drw-diag-node.unlocated').count()
note(`诊断摘要:${summary};可定位 ${locatedCount} 条(首屏),未定位 ${unlocated} 条`)
await shot(page, 'item4-diagnostics', preview)
await expect(deliveryOf(page, '预览')).toBeVisible({ timeout: 15_000 })
expect.soft(locatedCount,
'GAP[Renderer] 渲染器生产代码从不产出 warning/info 诊断、也不填 Diagnostic.NodeID(仅失败时产出无节点的 error);GAP[RDM] 受控 DHF 实例 172 个节点全为空/未开始,预览 diagnostics 为 null——无可定位到节点的诊断').toBeGreaterThan(0)
if (!locatedCount) { await s.close(); return }
const first = located.first()
const title = (await first.getAttribute('title')) ?? ''
await first.click()
await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toHaveAttribute('title', title)
const counted = receipt.locator('.drw-diag.sev-error button.drw-diag-node, .drw-diag.sev-warning button.drw-diag-node')
if (await counted.count()) {
const t = (await counted.first().getAttribute('title')) ?? ''
await expect(page.locator(`.drw-canvas-node[title="${t.replace(/"/g, '\\"')}"] .drw-badge`).first()).toBeVisible()
}
await shot(page, 'item4-diagnostic-located-canvas')
await page.getByRole('tab', { name: '阅读视图' }).click()
const section = page.locator('section.drw-reader-node.sel')
await expect(section.locator('.drw-reader-title')).toHaveText(title)
await expect(section.locator('a.drw-anchor')).toHaveAttribute('href', `#${await section.getAttribute('id')}`)
await shot(page, 'item4-diagnostic-located-reader')
await expect(deliveryOf(page, '预览')).toBeVisible({ timeout: 15_000 })
await s.close()
})
test('§11-5a 编制人提交审阅', async ({ browser }) => {
items(5)
const s = await session(browser, U.author, 'item5a-author')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const submit = page.getByRole('button', { name: '提交审阅', exact: true })
await expect(submit).toBeVisible({ timeout: 15_000 })
await shot(page, 'item5a-submit-state', panel(page, '预览与提交'))
const uiSubmit = await submit.isEnabled()
note(`浏览器「提交审阅」可用:${uiSubmit};原因:${await submit.getAttribute('title')}`)
expect.soft(uiSubmit,
'GAP[RDM] 受控文档的专用模板规则只能经 rdm.render.validate 到 validated,产品无发布路径(company.* 为保留前缀,candidate.publish 不绑定作用域),rdm.render.submit 要求 published → 浏览器无法提交审阅').toBe(true)
let submissionId = ''
let round = 0
if (uiSubmit) {
await submit.click()
await expect(panel(page, '预览与提交').getByText('提交审阅已完成')).toBeVisible({ timeout: 60_000 })
} else {
apiAssisted(`rdm.render.submit 以编制人会话显式指定已发布的公司通用规则 ${GENERAL.id}@${GENERAL.version}`)
const r = await command(page, 'rdm.render.submit', {
projectId: A.pid, docId: A.doc, operationId: `rba-submit-${RUN}`, profileId: GENERAL.id, profileVersion: GENERAL.version,
})
note(`API 提交回执:${r.text.slice(0, 300)}`)
expect.soft(r.body?.receipt?.state,
`BLOCKER[RDM] rdm.render.submit 被拒(${r.body?.receipt?.reason ?? r.status}):trustedRenderActor → LookupAccountDirectoryOwner(purpose=document-duties) 需要 RDM 注册 AccountDirectoryScopeResolver,而 RDM d63f1db 从未调用 RegisterAccountDirectoryScopeResolver,Backend 恒返回 ErrAccountForbidden;submit/annotate/revise 在真实产品中全部不可用`).toBe('committed')
}
const status = await command(page, 'rdm.review.doc.status', { projectId: A.pid, docId: A.doc })
submissionId = status.body?.payload?.submission?.submissionId ?? ''
round = status.body?.payload?.submission?.round ?? 0
writeState({ submissionId, round })
expect(submissionId, '提交后 rdm.review.doc.status 返回提交编号').not.toBe('')
await page.reload()
await openAudit(page, A.name)
await openDoc(page, A.title)
await expect(panel(page, '审阅').locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 15_000 })
await shot(page, 'item5a-submitted')
await s.close()
})
test('§11-5b 审核人添加批注与修订', async ({ browser }) => {
items(5)
const { submissionId } = readState()
const s = await session(browser, U.reviewer, 'item5b-reviewer')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const review = panel(page, '审阅')
if (!submissionId) {
await expect(review.getByText('暂无审阅提交')).toBeVisible({ timeout: 20_000 })
await shot(page, 'item5b-reviewer-no-submission')
expect(submissionId, 'BLOCKER[RDM] 无渲染审阅提交(见 §11-5a),审核人无法添加批注与修订').not.toBe('')
}
await expect(review.locator('.drw-mono'), '审核人在新会话中看到编制人的提交').toHaveAttribute('title', submissionId, { timeout: 20_000 })
await page.locator('.drw-canvas-node[title="项目内容、目标及意义"]').first().click()
await expect(review.locator('.drw-composer-node')).toHaveText('项目内容、目标及意义')
await review.getByLabel('批注内容').fill(ANNOTATION)
await review.getByRole('button', { name: '添加批注' }).click()
await expect(review.locator('.drw-anno-text', { hasText: ANNOTATION })).toBeVisible({ timeout: 30_000 })
await review.getByLabel('审阅动作').selectOption('revise')
await review.getByLabel('修订类型').selectOption('replace')
await review.getByLabel('修订前').fill('项目目标')
await review.getByLabel('修订后').fill(REVISION_AFTER)
await review.getByRole('button', { name: '提交修订' }).click()
await expect(review.locator('.drw-diff-add', { hasText: REVISION_AFTER })).toBeVisible({ timeout: 30_000 })
await expect(review.locator('.drw-anno-group .drw-anno-node').first()).toHaveText('项目内容、目标及意义')
await expect(page.locator('.drw-canvas-node[title="项目内容、目标及意义"] .drw-badge.review').first()).toBeVisible()
// 范围批注(06 §2):阅读视图选中节点正文「目标」(码点 5..7),载荷带偏移与摘录,不带 textHash/range。
await page.getByRole('tab', { name: '阅读视图' }).click()
const nodeText = page.locator('section.drw-reader-node.sel [data-anchor-node]')
await expect(nodeText).toHaveText('项目内容、目标及意义')
await nodeText.evaluate(el => {
const range = document.createRange()
range.setStart(el.firstChild!, 5)
range.setEnd(el.firstChild!, 7)
const sel = window.getSelection()!
sel.removeAllRanges()
sel.addRange(range)
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
})
await expect(review.locator('.drw-composer-quote')).toHaveText('「目标」')
await review.getByLabel('批注内容').fill(RANGE_ANNOTATION)
await review.getByRole('button', { name: '添加批注' }).click()
const annotateCalls = () => s.net.filter(n => n.url.includes('/api/commands/rdm.render.review.annotate') && parseJson(n.request)?.params?.text === RANGE_ANNOTATION)
await expect.poll(() => annotateCalls().length, { message: '应分发范围批注', timeout: 30_000 }).toBeGreaterThan(0)
const rangeCall = annotateCalls().at(-1)!
const rangeParams = parseJson(rangeCall.request)?.params
expect(rangeParams, '范围批注载荷').toMatchObject({ textStart: 5, textEnd: 7, quote: '目标' })
expect(rangeParams).not.toHaveProperty('textHash')
expect(rangeParams).not.toHaveProperty('range')
note(`范围批注回执:${rangeCall.body.slice(0, 300)}`)
const rangeItem = review.locator('.drw-anno', { hasText: RANGE_ANNOTATION })
const anchored = await rangeItem.locator('.drw-anno-quote').waitFor({ timeout: 30_000 }).then(() => true, () => false)
if (anchored) await expect(rangeItem.locator('.drw-anno-quote')).toHaveText('目标')
expect.soft(anchored, `GAP[RDM] 范围批注未按所选文字保存(回执:${rangeCall.body.slice(0, 200)})`).toBe(true)
await page.getByRole('tab', { name: '结构画布' }).click()
await expectReadableFailures(page)
await shot(page, 'item5b-review-annotations')
await s.close()
})
test('§11-5c 审核通过与批准(签署)', async ({ browser }) => {
items(5)
const { submissionId } = readState()
const r = await session(browser, U.reviewer, 'item5c-reviewer')
await openAudit(r.page, A.name)
await openDoc(r.page, A.title)
const reviewPanel = panel(r.page, '审阅')
if (!submissionId) {
await expect(reviewPanel.getByText('暂无审阅提交')).toBeVisible({ timeout: 20_000 })
expect.soft(await r.page.getByRole('button', { name: /审核通过|驳回|批准|签署|定稿/ }).count(),
'GAP[Frontend] 文档工作区没有审核通过/驳回/批准入口(rdm.review.doc.approve/final 需会话来源锚点,只能经对话代理)').toBeGreaterThan(0)
await shot(r.page, 'item5c-reviewer-no-submission', reviewPanel)
expect(submissionId, 'BLOCKER[RDM] 无渲染审阅提交(见 §11-5a),审核与批准签署无从进行').not.toBe('')
}
await expect(reviewPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
// 整文审签只走工作区入口:审核人待审时必须出现「审核通过」;成功与可读失败都必须可见。
const decisionPanel = panel(r.page, '整文审签')
const uiApprove = decisionPanel.getByRole('button', { name: '审核通过', exact: true })
await expect(uiApprove, 'GAP[Frontend] 审核人在待审状态下工作区没有「审核通过」入口').toBeVisible({ timeout: 20_000 })
await uiApprove.click()
const done = decisionPanel.getByText('整文审核通过已完成')
const failed = decisionPanel.locator('.drw-failure-msg')
await expect(done.or(failed).first()).toBeVisible({ timeout: 60_000 })
expect.soft(await done.count(), `审核通过应成功:${(await failed.allTextContents()).join(' | ')}`).toBe(1)
await expectReadableFailures(r.page)
await expectCommittedDecision(r, 'rdm.review.doc.approve', '审核通过')
await shot(r.page, 'item5c-reviewer-approved', decisionPanel)
await r.close()
const a = await session(browser, U.approver, 'item5c-approver')
await openAudit(a.page, A.name)
await openDoc(a.page, A.title)
const approverPanel = panel(a.page, '审阅')
await expect(approverPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
// 终批入口只在批准人且全员已审核通过时出现;载荷绑定界面所示轮次。
const finalPanel = panel(a.page, '整文审签')
const uiFinal = finalPanel.getByRole('button', { name: '终批', exact: true })
await expect(uiFinal, 'GAP[Frontend] 批准人在全员审核通过后工作区没有「终批」入口').toBeVisible({ timeout: 20_000 })
await uiFinal.click()
const finalDone = finalPanel.getByText('整文终批已完成')
const finalFailed = finalPanel.locator('.drw-failure-msg')
await expect(finalDone.or(finalFailed).first()).toBeVisible({ timeout: 60_000 })
expect.soft(await finalDone.count(), `终批应成功:${(await finalFailed.allTextContents()).join(' | ')}`).toBe(1)
await expectReadableFailures(a.page)
await expectCommittedDecision(a, 'rdm.review.doc.final', '终批')
await a.page.reload()
await openAudit(a.page, A.name)
await openDoc(a.page, A.title)
const decisions = panel(a.page, '审阅').locator('.drw-decision')
await expect(decisions.first()).toBeVisible({ timeout: 20_000 })
const decisionTexts = await decisions.allTextContents()
note(`签署记录:${decisionTexts.join(' | ')}`)
expect(decisionTexts.length, '审阅面板显示审核与批准两条签署').toBeGreaterThanOrEqual(2)
await shot(a.page, 'item5c-decisions', panel(a.page, '审阅'))
await a.close()
})
test('§11-6 下载 Markdown、批准稿、审阅留痕稿与完整包', async ({ browser }) => {
items(6)
const { submissionId } = readState()
if (!submissionId) {
// 无渲染提交:只能取证编制人预览交付的 Markdown/DOCX/清单下载,批准稿与留痕稿记为阻断
const s = await session(browser, U.author, 'item6-author-preview')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const preview = deliveryOf(page, '预览')
await expect(preview).toBeVisible({ timeout: 20_000 })
const md = await download(page, preview, 'Markdown')
expect(md.name).toMatch(/\.md$/)
const docx = await download(page, preview, '预览 DOCX')
expect(fs.readFileSync(docx.file).subarray(0, 2).toString()).toBe('PK')
const manifest = await download(page, preview, '清单(manifest)')
note(`预览交付下载:${[md, docx, manifest].map(f => `${f.name}(${f.size} B)`).join(';')}`)
writeState({ downloads: [md, docx, manifest].map(f => f.name) })
await shot(page, 'item6-preview-downloads', panel(page, '交付记录'))
expect.soft(await deliveryOf(page, '批准').count(), 'BLOCKER[RDM] 无渲染提交 → 无批准交付,批准稿不可下载').toBeGreaterThan(0)
expect.soft(await page.getByRole('button', { name: '审阅留痕 DOCX', exact: true }).count(), 'BLOCKER[RDM] 无渲染提交 → 无审阅交付,审阅留痕稿不可下载').toBeGreaterThan(0)
const pkg = await download(page, preview, '完整交付包(ZIP)')
expect(pkg.name).toMatch(/\.zip$/)
expect(fs.readFileSync(pkg.file).subarray(0, 2).toString()).toBe('PK')
await s.close()
return
}
const s = await session(browser, U.approver, 'item6-approver')
const { page } = s
await openAudit(page, A.name)
await openDoc(page, A.title)
const reviewPanel = panel(page, '审阅')
await expect(reviewPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
const ok = panel(page, '预览与提交')
await reviewPanel.getByRole('button', { name: '生成批准稿' }).click()
await expect(ok.getByText('生成批准稿已完成').or(ok.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 120_000 })
await expectReadableFailures(page)
await expect(ok.locator('.drw-op .drw-failure'), '生成批准稿不应失败').toHaveCount(0)
await reviewPanel.getByRole('button', { name: '生成留痕稿' }).click()
await expect(ok.getByText('生成留痕稿已完成').or(ok.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 120_000 })
await expect(ok.locator('.drw-op .drw-failure'), '生成留痕稿不应失败').toHaveCount(0)
const approved = deliveryOf(page, '批准')
const reviewed = deliveryOf(page, '审阅')
await expect(approved).toBeVisible({ timeout: 20_000 })
await expect(reviewed).toBeVisible({ timeout: 20_000 })
await shot(page, 'item6-deliveries', panel(page, '交付记录'))
const kinds = { approved: await approved.locator('.drw-download').allTextContents(), review: await reviewed.locator('.drw-download').allTextContents() }
note(`批准交付可下载:${kinds.approved.join('、')};审阅交付可下载:${kinds.review.join('、')}`)
writeState({ deliveryKinds: kinds })
const md = await download(page, approved, 'Markdown')
expect(md.name).toMatch(/\.md$/)
expect(fs.readFileSync(md.file, 'utf8').length).toBeGreaterThan(50)
const docx = await download(page, approved, '批准稿 DOCX')
expect(docx.name).toMatch(/\.docx$/)
expect(fs.readFileSync(docx.file).subarray(0, 2).toString()).toBe('PK')
const traceHolder = (await reviewed.getByRole('button', { name: '审阅留痕 DOCX', exact: true }).count()) ? reviewed : approved
const trace = await download(page, traceHolder, '审阅留痕 DOCX')
expect(trace.name).toMatch(/\.docx$/)
expect(fs.readFileSync(trace.file).subarray(0, 2).toString()).toBe('PK')
const manifest = await download(page, approved, '清单(manifest)')
note(`下载:${[md, docx, trace, manifest].map(f => `${f.name}(${f.size} B)`).join(';')}`)
// 长文件名:显示截断,title 保留完整文件名(§11-9 一并取证)
const lastName = panel(page, '交付记录').locator('.drw-filename').first()
await expect(lastName).toHaveAttribute('title', manifest.name)
const shown = (await lastName.textContent()) ?? ''
note(`文件名显示「${shown}」,完整「${manifest.name}」`)
if (manifest.name.length > 36) expect(shown).toContain('…')
await shot(page, 'item6-downloaded-longname', panel(page, '交付记录'))
writeState({ downloads: [md, docx, trace, manifest].map(f => f.name) })
// 完整包:rdm.render.download kind=package,服务端确定性 ZIP;与单件下载同一鉴权路径。
const pkg = await download(page, approved, '完整交付包(ZIP)')
expect(pkg.name).toMatch(/\.zip$/)
expect(fs.readFileSync(pkg.file).subarray(0, 2).toString()).toBe('PK')
note(`完整交付包:${pkg.name}(${pkg.size} B)`)
writeState({ downloads: [md, docx, trace, manifest, pkg].map(f => f.name) })
await s.close()
})
test('§11-7 追溯网显示来源、规则、修订、签署与交付物', async ({ browser }) => {
items(7)
const s = await session(browser, U.admin, 'item7-admin')
const { page } = s
await openAudit(page, A.name)
await page.getByRole('tab', { name: '关联追溯' }).click()
const pick = page.locator('.rdm-audit-pick button', { hasText: /^项目内容、目标及意义$/ }).first()
await expect(pick).toBeVisible({ timeout: 60_000 })
await pick.click()
await expect(page.locator('.rdm-audit-trace-head')).toBeVisible()
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 20_000 })
await shot(page, 'item7-trace-section')
await Promise.all(s.pending)
const relationBodies = s.net.filter(n => n.url.includes('rdm.inspect.relations')).map(n => n.body).join('\n')
const text = (await page.locator('.rdm-audit-body').textContent()) ?? ''
note(`关联追溯页面文本(截断):${text.slice(0, 300)}`)
const has = (re: RegExp) => re.test(text) || re.test(relationBodies)
expect.soft(has(/source|来源依据([1-9]/), 'GAP[RDM] 追溯网未给出该章节的来源依据(rdm.inspect.relations 只含通用网/合规边)').toBe(true)
expect.soft(has(/profile|渲染规则|org\.general|company\.dhf/), 'GAP[RDM] 追溯网不含渲染规则(profile)节点或边').toBe(true)
expect.soft(has(/revision|修订/), 'GAP[RDM] 追溯网不含审阅修订').toBe(true)
expect.soft(has(/decision|approve|final|签署|批准/), 'GAP[RDM] 追溯网不含审核/批准签署').toBe(true)
expect.soft(has(/delivery|交付|deliveryId/), 'GAP[RDM] 追溯网不含渲染交付物(preview/review/approved delivery)').toBe(true)
// 补充取证:操作记录是否留下渲染与签署事件(不计入追溯网判定)
await page.getByRole('button', { name: '← 返回' }).click()
await page.getByRole('tab', { name: '操作记录' }).click()
await page.waitForTimeout(2_000)
await shot(page, 'item7-timeline')
const timeline = (await page.locator('.rdm-audit-body').textContent()) ?? ''
note(`操作记录含 render:${/render/.test(timeline)};含 review:${/review/.test(timeline)}`)
await s.close()
})
test('§11-8 无权用户的页面与网络响应不含受限内容', async ({ browser }) => {
items(8)
const st = readState()
const s = await session(browser, U.outsider, 'item8-outsider')
const { page } = s
await clickAuditEntry(page)
await expect(page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 })
const options = await page.locator('.rdm-audit-project option').allTextContents()
note(`无权用户项目列表:${options.join('、')}`)
expect(options).not.toContain(A.name)
expect(options).toContain(B.name)
await page.getByRole('tab', { name: '资料与文档' }).click()
await expect(page.locator('.rdm-audit-tree-row').first()).toBeVisible({ timeout: 15_000 })
await page.getByRole('tab', { name: '关联追溯' }).click()
await page.waitForTimeout(1_500)
await page.getByRole('tab', { name: '操作记录' }).click()
await page.waitForTimeout(1_500)
await shot(page, 'item8-outsider-audit')
const pageText = (await page.locator('body').textContent()) ?? ''
for (const m of [A.name, A.title, UNCTRL.title, ANNOTATION, REVISION_AFTER]) expect(pageText, `页面不得出现受限内容「${m}」`).not.toContain(m)
const passiveCount = s.net.length
// 主动探测:以无权用户会话直调 A 的读取与下载命令(请求中自带的 id 不算泄露)
const probes: [string, Record<string, unknown>][] = [
['rdm.inspect.summary', { projectId: A.pid }],
['rdm.inspect.tree', { projectId: A.pid, kind: 'docs' }],
['rdm.inspect.tree', { projectId: A.pid, kind: 'docs', parent: A.doc }],
['rdm.inspect.relations', { projectId: A.pid, kind: 'doc', id: A.doc, depth: 1 }],
['rdm.render.profiles', { projectId: A.pid, docId: A.doc }],
['rdm.render.deliveries', { projectId: A.pid, docId: A.doc, limit: 20 }],
['rdm.review.doc.status', { projectId: A.pid, docId: A.doc }],
...(st.submissionId ? [['rdm.render.review.view', { projectId: A.pid, submissionId: st.submissionId }] as [string, Record<string, unknown>]] : []),
]
const probeResults: { id: string; status: number; state: string; hasPayload: boolean }[] = []
const probeBodies: { url: string; body: string }[] = []
for (const [id, params] of probes) {
const r = await command(page, id, params)
probeBodies.push({ url: `probe:${id}`, body: r.text })
const state = r.body?.receipt?.state ?? ''
const payload = r.body?.payload
const hasPayload = payload !== undefined && payload !== null && !(typeof payload === 'object' && Object.keys(payload).length === 0)
probeResults.push({ id, status: r.status, state, hasPayload })
expect.soft(r.status >= 400 || state === 'rejected', `${id} 对无权用户应被拒绝:HTTP ${r.status} ${r.text.slice(0, 200)}`).toBe(true)
expect.soft(hasPayload, `${id} 不得返回受限载荷`).toBe(false)
}
for (const kind of ['markdown', 'docx', 'manifest']) {
const r = await command(page, 'rdm.render.download', { projectId: A.pid, deliveryId: 'probe', kind })
probeBodies.push({ url: `probe:rdm.render.download:${kind}`, body: r.text })
probeResults.push({ id: `rdm.render.download:${kind}`, status: r.status, state: r.body?.receipt?.state ?? '', hasPayload: !!r.body?.payload })
expect.soft(!r.body?.payload?.dataBase64, 'rdm.render.download 不得返回文件内容').toBe(true)
}
const hostProjects = await page.request.get('/api/projects')
const hostText = await hostProjects.text()
expect.soft(hostText.includes(A.host), '宿主项目列表不得含项目 A').toBe(false)
const hostConv = await page.request.get(`/api/projects/${A.host}/conversations`)
const hostConvText = await hostConv.text()
probeBodies.push({ url: '/api/projects', body: hostText }, { url: `/api/projects/${A.host}/conversations`, body: hostConvText })
note(`宿主 A 会话列表 HTTP ${hostConv.status()}`)
expect.soft(hostConv.status() >= 400 || !hostConvText.includes('§11'), '宿主 A 会话不得可见').toBe(true)
note(`主动探测:${JSON.stringify(probeResults)}`)
await Promise.all(s.pending)
// 全部网络响应体扫描:内容标记任何响应都不得出现;A 的标识只在被动浏览响应中检查
const contentMarkers = [A.name, A.title, UNCTRL.title, ANNOTATION, REVISION_AFTER, ...(st.downloads ?? [])]
const idMarkers = [A.pid, A.host, st.submissionId].filter(Boolean)
const leaks: string[] = []
s.net.forEach((n, i) => {
for (const m of contentMarkers) if (m && n.body.includes(m)) leaks.push(`${n.url} 含「${m}」`)
if (i < passiveCount) for (const m of idMarkers) if (n.body.includes(m)) leaks.push(`${n.url} 含标识 ${m}`)
})
for (const p of probeBodies) for (const m of contentMarkers) if (m && p.body.includes(m)) leaks.push(`${p.url} 含「${m}」`)
const scanned = path.join(EVID, 'network', 'item8-scan.json')
fs.mkdirSync(path.dirname(scanned), { recursive: true })
fs.writeFileSync(scanned, JSON.stringify({ pageResponses: s.net.length, passive: passiveCount, probeResponses: probeBodies.length, contentMarkers, idMarkers, leaks, probeResults }, null, 2))
evidence.push(scanned)
expect(leaks, `受限内容泄露:${leaks.join(';')}`).toEqual([])
await s.close()
// 反向:A 的审核人不是 B 成员,看不到 B
const r = await session(browser, U.reviewer, 'item8-reviewer-vs-B')
await clickAuditEntry(r.page)
await expect(r.page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 })
expect(await r.page.locator('.rdm-audit-project option').allTextContents()).not.toContain(B.name)
const bProbe = await command(r.page, 'rdm.render.profiles', { projectId: B.pid, docId: B.doc })
expect.soft(bProbe.status >= 400 || bProbe.body?.receipt?.state === 'rejected', `审核人读 B 应被拒绝:${bProbe.text.slice(0, 200)}`).toBe(true)
await r.close()
})
test('§11-9 浅色/深色/窄屏/长文档/长文件名可用', async ({ browser }) => {
items(9)
for (const theme of ['light', 'dark'] as const) {
for (const [w, h, tag] of [[1440, 900, 'wide'], [390, 844, 'narrow']] as const) {
const s = await session(browser, U.author, `item9-${theme}-${tag}`, { theme, width: w, height: h })
const { page } = s
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe(theme)
await openAudit(page, A.name)
await openDoc(page, A.title)
await expect(panel(page, '渲染规则').locator('.drw-profile').first()).toBeVisible({ timeout: 15_000 })
const overflow = await page.locator('.drw').evaluate(el => el.scrollWidth - el.clientWidth)
note(`${theme}/${tag}:.drw 横向溢出 ${overflow}px`)
expect.soft(overflow, `${theme}/${tag} 工作区不得横向溢出`).toBeLessThanOrEqual(1)
const title = page.locator('.drw-title')
await expect(title).toHaveAttribute('title', A.title)
const clipped = await title.evaluate(el => el.scrollWidth > el.clientWidth)
note(`${theme}/${tag}:${A.title.length} 字长标题截断显示 ${clipped}`)
expect(clipped, '长标题在页头截断显示(完整标题保留在 title)').toBe(true)
expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis')
const bodyOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect.soft(bodyOverflow, `${theme}/${tag} 页面不得横向滚动`).toBeLessThanOrEqual(1)
await shot(page, `item9-${theme}-${tag}-canvas`)
if (tag === 'wide') {
const delivery = page.locator('.drw-delivery').first()
await expect(delivery, '长文件名取证需要至少一条交付记录').toBeVisible({ timeout: 15_000 })
const file = await download(page, delivery, 'Markdown')
const name = panel(page, '交付记录').locator('.drw-filename').first()
await expect(name).toHaveAttribute('title', file.name)
const shown = (await name.textContent()) ?? ''
note(`${theme}:下载文件名「${file.name}」(${file.name.length} 字)显示为「${shown}」——RDM 交付文件名恒为 <docId>.<ext>,产品不产生长文件名;长名称以 ${A.title.length} 字文档标题检验`)
expect(shown.length).toBeLessThanOrEqual(36)
await shot(page, `item9-${theme}-filename`, panel(page, '交付记录'))
}
await page.getByRole('tab', { name: '阅读视图' }).click()
const sections = await page.locator('section.drw-reader-node').count()
expect(sections, '长文档阅读视图完整列出节点').toBeGreaterThan(10)
await page.locator('section.drw-reader-node').last().scrollIntoViewIfNeeded()
await expect(page.locator('section.drw-reader-node').last()).toBeInViewport()
await shot(page, `item9-${theme}-${tag}-reader-end`)
await page.locator('.drw-side').scrollIntoViewIfNeeded()
await shot(page, `item9-${theme}-${tag}-side`, page.locator('.drw-side'))
await page.getByRole('button', { name: '← 返回文档树' }).click()
await expect(page.getByRole('button', { name: `阅读与交付:${A.title}`, exact: true })).toBeVisible()
await s.close()
}
}
})
-623
View File
@@ -1,623 +0,0 @@
// IAM U4 阶段 7 确定性浏览器切片(DR6–DR10):真实 React/Picker 交互 +
// 完全受控的 HTTP 应答,不依赖任何账号或数据库。活体隔离产品层由
// Agent-System-RDM scripts/iam-u4-role-acceptance.sh 以真实 Backend/RDM
// 授权路径复用同一场景(live 模式不拦截路由)。
import { test, expect, type Page, type Route } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(ok => { resolve = ok })
return { promise, resolve }
}
interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; systemActions?: string[] }
interface Perm { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
interface RolePerm { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
interface Role { roleId: string; name: string; description: string; enabled: boolean; roleVersion: number; bindingCount: number; permissions: RolePerm[] }
interface Account {
userId: string; owner: string; profile: { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
profileVersion: number; enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
initializationState: string; initializationGeneration: number
}
const FULL_ACTIONS = ['system.users.view', 'system.users.create', 'system.users.edit', 'system.users.disable',
'system.roles.view', 'system.roles.manage', 'system.roles.assign', 'system.permissions.view', 'system.permissions.manage']
function perm(actionId: string, label: string, group: string, scope: 'system' | 'project', extra: Partial<Perm> = {}): Perm {
return { actionId, label, description: `${label}说明`, group, scope, protected: false, ...extra }
}
const CATALOG: Perm[] = [
perm('project.create', '创建项目', '项目入口', 'system'),
perm('project.member.manage', '管理项目成员', '项目入口', 'project'),
perm('project.override.set', '设置项目覆盖', '项目入口', 'project'),
perm('document.read', '读取文档', '文档动作', 'project'),
perm('document.author', '编制文档', '文档动作', 'project'),
perm('document.approve', '批准文档', '文档动作', 'project'),
perm('export.create', '创建导出', '平台动作', 'project'),
perm('audit.read', '读取审计', '平台动作', 'project'),
perm('system.users.manage', '管理用户账号', '系统动作', 'system', { protected: true }),
]
function emptyProfile(name = '') {
return { name, phone: '', email: '', staffNo: '', department: '', notes: '' }
}
function account(userId: string, owner: string, profile = emptyProfile(), overrides: Partial<Account> = {}): Account {
return { userId, owner, profile, profileVersion: 1, enabled: true, stateVersion: 3, isAdmin: false, passwordSet: true, initializationState: 'done', initializationGeneration: 0, ...overrides }
}
function rp(actionId: string, allowed = false, capAllowed = false): RolePerm {
return { actionId, allowed, capAllowed, permissionVersion: 1 }
}
/** 可变角色/绑定状态:一个 fixture 贯穿用例内全部请求,支持版本化 409。 */
function world(me: Me) {
const roles: Role[] = [
{ roleId: 'role_eng', name: '软件工程师', description: '研发职位', enabled: true, roleVersion: 3, bindingCount: 2, permissions: [rp('project.create', true, true), rp('document.read', true, true)] },
{ roleId: 'role_ops', name: '运维工程师', description: '运维职位', enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] },
{ roleId: 'role_review', name: '审核工程师', description: '质量职位', enabled: false, roleVersion: 2, bindingCount: 1, permissions: [rp('document.read', true, true), rp('document.approve', false, true)] },
]
const bindings: Record<string, string> = { u1: 'role_eng', u3: 'role_eng' }
const users: Account[] = [
account('u1', 'alice', { ...emptyProfile('张三'), phone: '13800000001', staffNo: 'A001' }),
account('u2', 'bob'),
account('u3', 'carol', { ...emptyProfile('李四'), email: 'carol@example.com' }),
]
const grants: Record<string, { actions: string[]; authorityEpoch: number }> = {}
const calls: { method: string; path: string; query: Record<string, string>; body?: Record<string, unknown> }[] = []
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
const failNext = new Map<string, number>()
let grantEpoch = 5
const PAGE = 2
const visible = (q: URLSearchParams): Role[] => roles.filter(role => {
const status = q.get('status') ?? 'all'
if (status === 'enabled' && !role.enabled) return false
if (status === 'disabled' && role.enabled) return false
const action = q.get('action') ?? ''
if (action && !role.permissions.some(item => item.actionId === action && item.allowed)) return false
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
if (!query) return true
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
return (q.get('mode') ?? 'fuzzy') === 'exact' ? fields.some(value => value === query) : fields.some(value => value.includes(query))
})
const recount = () => {
for (const role of roles) role.bindingCount = Object.values(bindings).filter(roleId => roleId === role.roleId).length
}
const notFound = () => ({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'not found' }) })
const deny = (route: Route, status: number) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ error: `code:${status}` }) })
const ok = (route: Route, payload: unknown) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(payload) })
return {
roles, bindings, users, grants, calls,
hold(key: string) {
const pending = { started: deferred(), release: deferred() }
holds.set(key, pending)
return pending
},
failNextTimes(path: string, times: number) { failNext.set(path, times) },
async route(route: Route) {
const request = route.request()
const url = new URL(request.url())
const path = decodeURIComponent(url.pathname)
const method = request.method()
const raw = request.postData()
calls.push({ method, path, query: Object.fromEntries(url.searchParams), body: raw ? JSON.parse(raw) as Record<string, unknown> : undefined })
const hold = holds.get(`${method} ${path}`)
if (hold) { holds.delete(`${method} ${path}`); hold.started.resolve(); await hold.release.promise }
const fails = failNext.get(path) ?? 0
if (fails > 0) { failNext.set(path, fails - 1); return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'boom' }) }) }
if (path === '/api/health') return ok(route, { ok: true })
if (path === '/api/me') return ok(route, me)
if (path === '/api/projects') return ok(route, [])
if (path === '/api/settings') return ok(route, { schema: [], defaults: {}, user: {}, project: {} })
if (path === '/api/commands') return ok(route, [])
if (path === '/api/admin-zone/sections') return ok(route, [])
if (path.endsWith('/cards')) return ok(route, { cards: [] })
if (path === '/api/admin/permissions/catalog') return ok(route, { permissions: CATALOG })
if (path === '/api/admin/roles' && method === 'GET') {
const list = visible(url.searchParams)
const start = Number(url.searchParams.get('cursor') ?? 0) || 0
const page = list.slice(start, start + PAGE)
return ok(route, { roles: page, nextCursor: start + PAGE < list.length ? String(start + PAGE) : '' })
}
if (path === '/api/admin/roles' && method === 'POST') {
const body = request.postDataJSON() as { name: string; description: string }
const role: Role = { roleId: `role_${body.name}`, name: body.name, description: body.description, enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] }
roles.unshift(role)
return ok(route, role)
}
const roleMatch = path.match(/^\/api\/admin\/roles\/([^/]+)(\/permissions)?$/)
if (roleMatch) {
const role = roles.find(item => item.roleId === roleMatch[1])
if (!role) return route.fulfill(notFound())
if (!roleMatch[2]) {
if (method === 'GET') return ok(route, role)
const body = request.postDataJSON() as { expectedRoleVersion: number; name?: string; description?: string; enabled?: boolean }
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
if (body.name !== undefined) role.name = body.name
if (body.description !== undefined) role.description = body.description
if (body.enabled !== undefined) role.enabled = body.enabled
role.roleVersion += 1
return ok(route, role)
}
if (method === 'PUT') {
const body = request.postDataJSON() as { expectedRoleVersion: number; changes: { actionId: string; allowed: boolean; capAllowed: boolean }[] }
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
role.permissions = body.changes.map(change => ({ ...change, permissionVersion: 1 }))
role.roleVersion += 1
return ok(route, { receiptId: 'r1', roleVersion: role.roleVersion, authorityEpoch: 6 })
}
}
if (path === '/api/admin/users/search' && method === 'GET') {
const q = url.searchParams
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
const fields = (q.get('fields') ?? 'owner,name,userId').split(',')
const status = q.get('status') ?? 'all'
const list = users.filter(item => {
if (status === 'enabled' && !item.enabled) return false
if (status === 'disabled' && item.enabled) return false
if (!query) return true
const values = (fields.includes('default') ? ['owner', 'name', 'userId'] : fields)
.map(field => field === 'userId' ? item.userId : field === 'owner' ? item.owner : item.profile[field as 'name'] ?? '')
return (q.get('mode') ?? 'fuzzy') === 'exact' ? values.some(value => value.toLowerCase() === query) : values.some(value => value.toLowerCase().includes(query))
})
return ok(route, { accounts: list, nextCursor: '', directoryVersion: 'd1' })
}
if (path === '/api/admin/users' && method === 'POST') {
const body = request.postDataJSON() as { owner: string; profile: Account['profile'] }
if (users.some(item => item.owner === body.owner)) return deny(route, 409)
const created = account(`u-${body.owner}`, body.owner, { ...emptyProfile(), ...body.profile })
users.push(created)
return ok(route, created)
}
const userMatch = path.match(/^\/api\/admin\/users\/([^/]+)(\/role|\/profile|\/state|\/system-grants)?$/)
if (userMatch) {
const user = users.find(item => item.userId === userMatch[1])
if (!user) return route.fulfill(notFound())
const sub = userMatch[2] ?? ''
if (sub === '' && method === 'GET') return ok(route, user)
if (sub === '/profile' && method === 'PATCH') {
const body = request.postDataJSON() as { expectedProfileVersion: number; patch: Record<string, string> }
if (body.expectedProfileVersion !== user.profileVersion) return deny(route, 409)
user.profile = { ...user.profile, ...body.patch }
user.profileVersion += 1
return ok(route, user)
}
if (sub === '/state' && method === 'PUT') {
const body = request.postDataJSON() as { expectedStateVersion: number; enabled: boolean }
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
user.enabled = body.enabled
user.stateVersion += 1
return ok(route, user)
}
if (sub === '/role' && method === 'GET') {
const role = roles.find(item => item.roleId === bindings[user.userId])
return ok(route, role ? {
binding: { bindingId: `b-${user.userId}`, userId: user.userId, roleId: role.roleId, active: true, bindingVersion: 1, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch },
role,
} : {})
}
if (sub === '/role' && method === 'PUT') {
const body = request.postDataJSON() as { roleId: string; expectedStateVersion: number }
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
bindings[user.userId] = body.roleId
recount()
return ok(route, { bindingId: `b-${user.userId}`, userId: user.userId, roleId: body.roleId, active: true, bindingVersion: 2, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch })
}
if (sub === '/system-grants' && method === 'GET') {
return ok(route, grants[user.userId] ?? { actions: [], authorityEpoch: grantEpoch })
}
if (sub === '/system-grants' && method === 'PUT') {
const body = request.postDataJSON() as { actions: string[]; expectedAuthorityEpoch: number }
if (body.expectedAuthorityEpoch !== (grants[user.userId]?.authorityEpoch ?? grantEpoch)) return deny(route, 409)
const hadManage = (grants[user.userId]?.actions ?? []).includes('system.permissions.manage')
if (hadManage && !body.actions.includes('system.permissions.manage')) {
return route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: 'cannot remove the final permission manager' }) })
}
grants[user.userId] = { actions: body.actions, authorityEpoch: ++grantEpoch }
return ok(route, { receiptId: 'g1', authorityEpoch: grantEpoch })
}
}
// 未点名端点(profiles/plugins/conversations/workspaces 等启动探测)按空列表兜底。
return route.fulfill({ contentType: 'application/json', body: '[]' })
},
}
}
type World = ReturnType<typeof world>
async function openAdmin(page: Page, me: Me): Promise<World> {
const w = world(me)
await page.route('**/api/**', route => { void w.route(route) })
await page.goto('/')
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
return w
}
const openAdminAsAdmin = (page: Page) => openAdmin(page, { owner: 'root', name: '管理员', auth: true, isAdmin: true, systemActions: [...FULL_ACTIONS] })
async function openRolesSection(page: Page) {
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
// 面板加载带 180ms 防抖,等待首笔角色清单返回后再交互。
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '搜索与过滤' }).click()
}
async function openUsersSection(page: Page) {
await page.getByRole('button', { name: '用户管理', exact: true }).click()
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
}
const engRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '软件工程师' })
const reviewRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '审核工程师' })
test('DR10 细粒度管理员:仅用户查看权时看不到角色分区,也不发出角色请求', async ({ page }) => {
const w = await openAdmin(page, { owner: 'mgr', name: '用户管理员', auth: true, isAdmin: false, systemActions: ['system.users.view'] })
await expect(page.getByRole('button', { name: '用户管理', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '角色与权限', exact: true })).toHaveCount(0)
await openUsersSection(page)
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByText(/当前角色 无角色查看权限/)).toBeVisible()
await expect(page.getByLabel('用户生效角色')).toHaveCount(0)
expect(w.calls.filter(call => call.path.endsWith('/role'))).toEqual([])
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
})
test('DR10 权限管理者无查看权:出现守护提示且不拉取目录', async ({ page }) => {
const w = await openAdmin(page, { owner: 'mgr', name: '权限管理者', auth: true, isAdmin: false, systemActions: ['system.users.view', 'system.permissions.manage'] })
await openUsersSection(page)
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByText('需要同时具备系统权限查看权,才能安全编辑现有授权')).toBeVisible()
await expect(page.getByRole('button', { name: '明确系统权限' })).toHaveCount(0)
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
})
test('DR6 创建带描述的角色,后续修改保持稳定 roleId', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await page.getByLabel('新角色名称').fill('数据工程师')
await page.getByLabel('新角色描述').fill('数据管道职位')
await page.getByRole('button', { name: '创建角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师')
await page.getByLabel('角色描述', { exact: true }).fill('数据管道与质量职位')
await page.getByRole('button', { name: '保存资料', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色资料已保存')
expect(w.calls.filter(call => call.method === 'POST' && call.path === '/api/admin/roles')).toHaveLength(1)
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path.startsWith('/api/admin/roles/'))
expect(patches).toHaveLength(1)
expect(patches[0].path).toBe('/api/admin/roles/role_数据工程师')
})
test('DR6 停用角色需显式确认;取消不发出任何请求', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByRole('button', { name: '停用角色', exact: true }).click()
const dialog = page.getByRole('alertdialog', { name: '确认停用角色 软件工程师' })
await expect(dialog).toContainText('软件工程师')
await expect(dialog).toContainText('2 个用户绑定')
await expect(dialog).toContainText('不再授予权限')
await dialog.getByRole('button', { name: '取消', exact: true }).click()
await expect(dialog).toHaveCount(0)
expect(w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')).toEqual([])
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限')
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
expect(patches).toHaveLength(1)
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3, enabled: false })
})
test('DR6 停用版本冲突:刷新最新状态并保留重试上下文', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
// 并发方先推进版本,首次确认停用必然 409。
w.roles.find(role => role.roleId === 'role_eng')!.roleVersion = 4
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('角色状态版本冲突')
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用')
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
expect(patches).toHaveLength(2)
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3 })
expect(patches[1].body).toMatchObject({ expectedRoleVersion: 4 })
expect(w.calls.some(call => call.method === 'GET' && call.path === '/api/admin/roles/role_eng')).toBe(true)
})
test('DR9 过滤、翻页与视图切换按 roleId 保留草稿且永不串角色', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
await expect(page.getByText('变更预览(1)')).toBeVisible()
// 切到第二页的审核工程师:不带走软件工程师的草稿。
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await expect(page.getByText('变更预览(0)')).toBeVisible()
await expect(page.getByLabel('编制文档 默认允许')).not.toBeChecked()
// 切回软件工程师:草稿原样保留。
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
await engRow(page).click()
await expect(page.getByText('变更预览(1)')).toBeVisible()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
// 状态过滤触发重载:草稿仍在。
await page.getByLabel('角色状态').selectOption('disabled')
await expect(reviewRow(page)).toBeVisible()
await page.getByLabel('角色状态').selectOption('all')
await engRow(page).click()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
// 单角色 ↔ 矩阵切换保留;矩阵中当前角色的勾选状态一致。
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
await expect(page.getByLabel('软件工程师 编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
expect(w.calls.filter(call => call.path === '/api/admin/roles/role_ops')).toEqual([])
})
test('DR9 离开管理区须显式决定;确认离开后草稿被放弃', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
const dialogs: string[] = []
let leave = false
page.on('dialog', async dialog => {
dialogs.push(dialog.message())
if (dialog.type() === 'confirm') leave ? await dialog.accept() : await dialog.dismiss()
else await dialog.accept()
})
await page.getByRole('button', { name: '返回工作台' }).click()
expect(dialogs).toHaveLength(1)
expect(dialogs[0]).toContain('软件工程师')
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
leave = true
await page.getByRole('button', { name: '返回工作台' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toHaveCount(0)
// 重新进入后草稿不复存在(离开即放弃)。
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await openRolesSection(page)
await engRow(page).click()
await expect(page.getByText('变更预览(0)')).toBeVisible()
})
test('DR9 409 只重放同一角色草稿;另一角色草稿不受影响', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await page.getByLabel('批准文档 默认允许').check()
// 切回软件工程师再保存:并发方已推进版本并改变服务端基线,保存触发 409 rebase。
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
await engRow(page).click()
const eng = w.roles.find(role => role.roleId === 'role_eng')!
eng.roleVersion = 4
eng.permissions = [rp('project.create', false, false), rp('document.read', true, true)]
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('权限版本冲突;已基于最新版本重放本地草稿')
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/roles/role_review')).toEqual([])
// 重放后:本地对编制文档的修改保留;再次保存基于最新版本成功。
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色权限已保存')
const puts = w.calls.filter(call => call.method === 'PUT' && call.path === '/api/admin/roles/role_eng/permissions')
expect(puts).toHaveLength(2)
expect(puts[0].body).toMatchObject({ expectedRoleVersion: 3 })
expect(puts[1].body).toMatchObject({ expectedRoleVersion: 4 })
expect((puts[1].body!.changes as { actionId: string; allowed: boolean }[]).find(change => change.actionId === 'document.author')).toMatchObject({ allowed: true, capAllowed: true })
// 审核工程师的草稿未被动过:切回后仍在。
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await expect(page.getByLabel('批准文档 默认允许')).toBeChecked()
})
test('DR9 矩阵批量编辑只作用于当前可见页', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
await page.locator('.rp-matrix button', { hasText: '软件工程师' }).click()
await page.getByRole('button', { name: '当前页默认全选', exact: true }).click()
for (const label of ['创建项目', '管理项目成员', '设置项目覆盖', '读取文档', '编制文档', '批准文档']) {
await expect(page.getByLabel(`软件工程师 ${label} 默认允许`)).toBeChecked()
}
await page.getByRole('button', { name: '下一组', exact: true }).click()
await expect(page.getByLabel('软件工程师 创建导出 默认允许')).not.toBeChecked()
await page.getByRole('button', { name: '上一组', exact: true }).click()
await page.getByRole('button', { name: '当前页上限关闭', exact: true }).click()
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).not.toBeChecked()
// 变更预览在单角色视图:批量编辑按 roleId 保留,切回可见 2 项相对基线变化。
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
await expect(page.getByText('变更预览(2)')).toBeVisible()
})
test('DR10 角色清单失败不拖垮目录加载,可经检索重试恢复', async ({ page }) => {
const w = await openAdminAsAdmin(page)
w.failNextTimes('/api/admin/roles', 1)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('500')
// 目录请求独立发出并成功(互不拖垮)。
expect(w.calls.some(call => call.path === '/api/admin/permissions/catalog')).toBe(true)
await page.getByRole('button', { name: '搜索与过滤' }).click()
await page.getByLabel('搜索角色').fill('软件')
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('alert')).toHaveCount(0)
})
test('DR10 权限目录失败不影响角色列表呈现', async ({ page }) => {
const w = await openAdminAsAdmin(page)
w.failNextTimes('/api/admin/permissions/catalog', 1)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('权限目录加载失败')
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible()
expect(w.calls.some(call => call.path === '/api/admin/roles')).toBe(true)
})
test('DR10 键盘完成核心流程,保存后焦点稳定且状态可被播报', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).focus()
await page.keyboard.press('Enter')
await expect(page.getByLabel('角色名称', { exact: true })).toHaveValue('软件工程师')
await page.getByLabel('编制文档 默认允许').focus()
await page.keyboard.press('Space')
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '保存权限', exact: true }).focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('status')).toContainText('角色权限已保存')
const focused = await page.evaluate(() => (document.activeElement as HTMLElement | null)?.textContent ?? '')
expect(focused).toContain('保存权限')
})
test('DR10 窄屏矩阵横向可导航且可操作', async ({ page }) => {
await openAdminAsAdmin(page)
await page.setViewportSize({ width: 480, height: 800 })
await openRolesSection(page)
// 窄屏下视图切换按钮存在持续的布局微抖动,Playwright 稳定性检查无法通过;
// 用 DOM 事件切换视图,随后的横向滚动与勾选仍是真实交互。
await page.getByRole('button', { name: '角色矩阵', exact: true }).dispatchEvent('click')
const wrap = page.locator('.rp-matrix-wrap')
await expect(wrap).toBeVisible()
const scroll = await wrap.evaluate(el => ({ sw: el.scrollWidth, cw: el.clientWidth }))
expect(scroll.sw).toBeGreaterThan(scroll.cw)
await page.getByLabel('软件工程师 创建项目 默认允许').check()
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).toBeChecked()
})
test('DR7 两人共享同一角色;换绑后仅一个生效角色且计数随之变化', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openUsersSection(page)
const openUser = async (owner: string) => {
await page.locator('tr', { hasText: owner }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByRole('button', { name: '返回列表' })).toBeVisible()
}
await openUser('bob')
await page.getByLabel('用户生效角色').selectOption({ label: '软件工程师' })
await page.getByRole('button', { name: '替换角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('用户角色已替换')
expect(w.bindings.u2).toBe('role_eng')
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await openUser('alice')
await expect(page.getByText(/当前角色 软件工程师/)).toBeVisible()
await page.getByLabel('用户生效角色').selectOption({ label: '运维工程师' })
await page.getByRole('button', { name: '替换角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('用户角色已替换')
expect(w.bindings.u1).toBe('role_ops')
expect(w.roles.find(role => role.roleId === 'role_eng')!.bindingCount).toBe(2) // bob + carol
// 用户角色视图始终单角色:GET /role 只返回一个 binding。
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/u1/role').length).toBeGreaterThan(0)
})
test('DR8 资料可全空创建,并支持按电话等字段检索', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openUsersSection(page)
await page.getByLabel('新用户登录名').fill('dave')
await page.getByRole('button', { name: '创建用户', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建 dave')
const created = w.calls.find(call => call.method === 'POST' && call.path === '/api/admin/users')
expect(created!.body!.profile).toEqual(emptyProfile())
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await page.getByLabel('用户搜索字段').selectOption({ label: '电话' })
await page.getByLabel('搜索用户').fill('13800000001')
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
await expect(page.locator('tr', { hasText: 'carol' })).toHaveCount(0)
const searches = w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/search')
expect(searches.at(-1)!.query.fields).toContain('phone')
})
test('DR10 加载与空态:加载期间无结果无报错,过滤为空给出选择提示', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '搜索与过滤' }).click()
const pending = w.hold('GET /api/admin/roles')
await page.getByLabel('搜索角色').fill('不存在的角色')
await pending.started.promise
await expect(page.locator('.rp-role-list button').first()).toBeDisabled()
await expect(page.getByRole('alert')).toHaveCount(0)
pending.release.resolve()
await expect(page.locator('.rp-role-list button')).toHaveCount(0)
await expect(page.getByText('请选择角色')).toBeVisible()
})
// ── 活体层(IAM_E2E_LIVE=1):同一文件在真实 Backend/RDM 组合上运行真实授权路径。
// 由 Agent-System-RDM scripts/iam-u4-role-acceptance.sh 物化冻结候选源码、
// 构建内嵌产品、全新 SQLite 数据根与随机回环端口后驱动;不拦截任何路由。
const LIVE = process.env.IAM_E2E_LIVE === '1'
async function liveLogin(page: Page) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(process.env.IAM_E2E_OWNER ?? '')
await page.getByPlaceholder('初始密码或常用密码').fill(process.env.IAM_E2E_PASSWORD ?? '')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
}
async function openAdminZoneLive(page: Page) {
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
}
test('LIVE 真实登录加载真实权限目录,创建、配置并停用角色', async ({ page }) => {
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
test.setTimeout(90_000)
await liveLogin(page)
await openAdminZoneLive(page)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await page.getByRole('button', { name: '搜索与过滤' }).click()
await expect(page.getByLabel('新角色名称')).toBeVisible({ timeout: 15_000 })
// 真实空库无角色:先经真实 Backend 创建,选中后权限表才渲染真实目录。
await page.getByLabel('新角色名称').fill('数据工程师')
await page.getByLabel('新角色描述').fill('活体验收职位')
await page.getByRole('button', { name: '创建角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师', { timeout: 15_000 })
// 真实目录的首页可能全是受保护系统授权(复选框禁用):逐组翻到可编辑动作。
const firstDefault = page.locator('input[aria-label$="默认允许"]:not([disabled])').first()
for (let pageIdx = 0; (await firstDefault.count()) === 0 && pageIdx < 15; pageIdx++) {
await page.getByRole('button', { name: '下一组', exact: true }).click()
await page.waitForTimeout(150)
}
await expect(firstDefault).toBeVisible({ timeout: 15_000 })
await firstDefault.check()
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色权限已保存', { timeout: 15_000 })
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await expect(page.getByRole('alertdialog')).toContainText('数据工程师')
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限', { timeout: 15_000 })
})
test('LIVE DR13 非成员账号被拒读取项目业务内容;创建者放行', async ({ request }) => {
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
const base = process.env.PLAYWRIGHT_BASE_URL!
const projectId = process.env.IAM_E2E_PROJECT_ID
test.expect(projectId).toBeTruthy()
const memberList = async (owner: string, password: string) => {
await request.post(`${base}/api/auth/login`, { data: { owner, password } })
const res = await request.post(`${base}/api/commands/rdm.project.member.list`, {
data: { params: { projectId } },
})
const body = await res.json().catch(() => null)
return { ok: res.ok(), body }
}
const observer = await memberList(process.env.IAM_E2E_OBSERVER_OWNER ?? '', process.env.IAM_E2E_OBSERVER_PASSWORD ?? '')
const denied = !observer.ok || observer.body?.receipt?.state === 'rejected'
expect(denied, `observer 应被拒绝:${JSON.stringify(observer.body).slice(0, 240)}`).toBe(true)
const creator = await memberList(process.env.IAM_E2E_OWNER ?? '', process.env.IAM_E2E_PASSWORD ?? '')
const allowed = creator.ok && creator.body?.receipt?.state !== 'rejected'
expect(allowed, `创建者应放行:${JSON.stringify(creator.body).slice(0, 240)}`).toBe(true)
})
-103
View File
@@ -1,103 +0,0 @@
// 管理员发放一次性登录密码与登录入口回归:管理员生成一次性密码
// → 用户密码登录 → 强制改密;登录页不再提供一次性 Key 登录 tab。
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
import { test, expect, type Page } from '@playwright/test'
const ADMIN = process.env.E2E_ADMIN || 'e2e-admin'
const ADMIN_BOOT = process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin#2026'
const ADMIN_PW = process.env.E2E_ADMIN_PASSWORD2 || 'E2eAdmin#2026b'
const USER = process.env.E2E_LOGIN_KEY_USER || 'e2e-user'
const USER_PW = process.env.E2E_LOGIN_KEY_PASSWORD2 || 'E2eUser#2026b'
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (auth-on instance required)')
async function passwordLogin(page: Page, owner: string, password: string) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(owner)
await page.getByPlaceholder('初始密码或常用密码').fill(password)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
/** 管理员登录;初始密码(must-change)则先完成受限设密再以新密码重登。
* 初始密码单次有效:已跑过本用例的环境(boot 已被改掉)自动改用新密码。 */
async function adminLogin(page: Page) {
await passwordLogin(page, ADMIN, ADMIN_BOOT)
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位,
// getByText 双命中会触发 strict-mode 抛错被 catch 吞掉、误判 gated=false。
let gate = page.getByRole('heading', { name: '设置新密码' })
let gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (!gated) {
// boot 密码失败(此前运行已完成改密):换正式密码重登。
const loginErr = await page.locator('.lg-err').waitFor({ state: 'visible', timeout: 3_000 }).then(() => true).catch(() => false)
if (!loginErr) throw new Error('admin login neither gated nor errored')
await passwordLogin(page, ADMIN, ADMIN_PW)
gate = page.getByRole('heading', { name: '设置新密码' })
gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
}
if (gated) {
const pw = page.locator('.lg-card input[type="password"]')
await pw.nth(0).fill(ADMIN_PW)
await pw.nth(1).fill(ADMIN_PW)
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
// 等设密事务完成再回登录:提交在途时点取消会登出受限会话、
// 服务端按会话复验拒绝该次设密(产品正确行为,测试必须同步成功页)。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await passwordLogin(page, ADMIN, ADMIN_PW)
}
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
}
test('管理员生成一次性登录密码:用户登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
test.setTimeout(120_000)
// 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。
await adminLogin(page)
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
const zone = page.getByRole('dialog', { name: '系统管理' })
await expect(zone).toBeVisible()
const row = zone.locator('tr', { hasText: USER })
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
const temporaryPassword = zone.locator('input[aria-label="一次性密码"]')
await expect(temporaryPassword).toBeVisible({ timeout: 10_000 })
const password = await temporaryPassword.inputValue()
expect(password.length).toBeGreaterThan(0)
// 2) 管理员退出系统管理与登录态,回到登录页。
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
await zone.getByRole('button', { name: '返回工作台' }).click()
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
// 3) 用户使用临时密码登录后进入强制改密门。
await passwordLogin(page, USER, password)
const gate = page.locator('.lg-card')
await expect(gate.getByText(`账号 ${USER}`)).toBeVisible({ timeout: 10_000 })
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(1)
const pw = gate.locator('input[type="password"]')
await pw.nth(0).fill(password)
await pw.nth(1).fill(USER_PW)
await pw.nth(2).fill(USER_PW)
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
// 4) 成功页:提示用新密码登录,唯一出口回登录页。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
// 5) 新密码登录进工作台。
await passwordLogin(page, USER, USER_PW)
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
// 6) 登录页不再提供一次性 Key 登录入口。
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('tab', { name: '一次性登录 Key' })).toHaveCount(0)
await expect(page.getByPlaceholder('alk_…')).toHaveCount(0)
})
+1 -21
View File
@@ -71,7 +71,7 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
await page.getByRole('tab', { name: '资料与文档' }).click()
await page.locator('.rdm-audit-tree-node').first().click()
const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node')
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: '章节' }) }).first()
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: 'section' }) }).first()
await expect(sectionNode).toBeVisible()
await sectionNode.click()
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
@@ -123,24 +123,4 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
)
})
test('概览导出:生成后显示水位与校验和,并可下载', async ({ page }) => {
await login(page)
await openAudit(page)
const exportBox = page.locator('.rdm-audit-export')
await expect(exportBox).toBeVisible()
await exportBox.getByLabel('导出类型').selectOption('cgraph-docx')
await exportBox.getByRole('button', { name: '生成', exact: true }).click()
const draft = exportBox.getByRole('button', { name: '生成草稿' })
if (await draft.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true).catch(() => false)) {
await draft.click()
}
await expect(exportBox.getByText(/水位 \d+/)).toBeVisible({ timeout: 15_000 })
await expect(exportBox.getByText(/sha256 [0-9a-f]{64}/)).toBeVisible()
await expect(exportBox.getByText(/有效期/)).toBeVisible()
const download = page.waitForEvent('download')
await exportBox.getByRole('button', { name: '下载' }).click()
const file = await download
expect(file.suggestedFilename()).toMatch(/\.docx$/)
})
})
-182
View File
@@ -1,182 +0,0 @@
// I4 组合验收(Playwright):文档驾驶舱七场景(rollout §3.I4 最小场景集)。
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制(含 I1/I3 命令面)+ frontend dist,
// 种子:审计演示项目(rdm-self + cockpit-e2e 结构装载;两个章节经草稿流落正文;
// 一个章节留空;第二个账号 e2e-reader 不入项目做权限负例)。
// 环境契约与 rdm-audit.playwright.ts 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过。
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
const READER = process.env.E2E_READER || 'e2e-reader'
const READER_PASSWORD = process.env.E2E_READER_PASSWORD || 'E2eReader#2026'
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
async function login(page: Page, user: string, password: string) {
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
await page.getByPlaceholder('由管理员分配').fill(user)
await page.getByPlaceholder('初始密码或常用密码').fill(password)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
// 侧栏入口两种形态:常规侧栏按钮(.tsb-tool);窄宽紧凑布局换图标栏
// (.icon-only 内 title 以「文档驾驶舱」开头的按钮)——入口都可达。
function cockpitEntry(page: Page) {
return page.locator('.tsb-tool', { hasText: '文档驾驶舱' })
.or(page.locator('.icon-only button[title^="文档驾驶舱"]')).first()
}
async function loginOwner(page: Page) {
await login(page, OWNER, PASSWORD)
// 登录后身份纪元与能力探针仍可能各跳一次;应用在身份变更时会关闭已开
// 工作区——等落定再点入口,避免 overlay 被身份刷新关掉(竞态实测)。
await page.waitForLoadState('networkidle').catch(() => {})
await page.waitForTimeout(1_500)
}
async function openCockpit(page: Page) {
// 窄宽是单窗格布局:登录后默认「对话」窗格,先切到「上下文」窗格。
if (!(await cockpitEntry(page).isVisible().catch(() => false))) {
await page.getByRole('button', { name: '打开上下文' }).click()
}
await cockpitEntry(page).click()
await expect(page.locator('.cockpit-project')).toBeVisible({ timeout: 15_000 })
// 默认选中首个项目(种子=审计演示项目)。
await expect(page.locator('.cockpit-stats')).toBeVisible({ timeout: 15_000 })
}
async function openReaderOnDoc(page: Page, title: string) {
await page.locator('.cockpit-tree-title', { hasText: title }).first().click()
await page.getByRole('button', { name: '在阅读器中打开' }).click()
await expect(page.locator('.cockpit-reader')).toBeVisible()
}
test.describe.serial('I4 文档驾驶舱组合验收', () => {
// 场景 1:首次进入项目——统计带、结构树、全景可见(DV-R01/R02)。
test('1 首屏:统计、树、全景可见且统计可判读', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
// placeholder
await expect(page.locator('.cockpit-stat', { hasText: '完成度' })).toBeVisible()
await expect(page.locator('.cockpit-stat', { hasText: '文档' })).toBeVisible()
// 空 vs 不可算不得互相冒充:有数据项目的空缺计数显示数字。
await expect(page.locator('.cockpit-stat', { hasText: '空缺节点' }).locator('.cockpit-stat-num')).toHaveText(/^\d+$/)
await expect(page.locator('.cockpit-tree-title', { hasText: '验证主体报告' }).first()).toBeVisible()
await expect(page.getByText('文档概览')).toBeVisible()
await expect(page.locator('.cockpit-snap')).toContainText(/快照 wm:\d+/)
})
// 场景 2:文档→章节,全景与阅读器双向定位(DV-R03)。
test('2 定位:树选章节→阅读器正文;返回全景仍同一节点', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
// 展开目标文档(树按标题排序,branch 作用域防跨文档串扰),点「概述」。
const branch = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
await branch.locator('.cockpit-tree-caret').click()
const sec = branch.locator('.cockpit-tree-title', { hasText: '概述' }).first()
await expect(sec).toBeVisible()
sec.click()
await expect(page.locator('.cockpit-nodehead')).toBeVisible()
// 全景→阅读器:同一 nodeRef(阅读器显示该节标题与正文)。
await page.getByRole('button', { name: '在阅读器中打开' }).click()
await expect(page.locator('.cockpit-reader')).toBeVisible()
await expect(page.locator('.cockpit-nodetitle', { hasText: '概述' })).toBeVisible()
const body = page.locator('.cockpit-reader-para', { hasText: '本报告' }).first()
await expect(body).toBeVisible()
// 阅读器→全景:返回后仍是选中节点(节点头仍在)。
await page.getByRole('button', { name: '← 返回全景' }).click()
await expect(page.locator('.cockpit-nodehead')).toBeVisible()
})
// 场景 3:四面板展开并正确显示计数;受限与空态诚实(DV-R04)。
test('3 面板:来源/历史/影响/冲突计数与空态、截断旗标', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
const branch = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
await branch.locator('.cockpit-tree-caret').click()
await branch.locator('.cockpit-tree-title', { hasText: '概述' }).first().click()
const panel = page.locator('.cockpit-panel')
await expect(panel).toBeVisible()
// 来源:草稿落版本挂了依据切片(种子经 ingest 提供)。
await panel.getByRole('tab', { name: '内容来源' }).click()
await expect(panel.locator('.cockpit-panel-count')).toContainText(/来源 \d+ 条/)
// 历史:已提交事件(structure + draft 链)。
await panel.getByRole('tab', { name: '修改历史' }).click()
await expect(panel.locator('.cockpit-panel-count')).toContainText('已提交事件')
await expect(panel.locator('.cockpit-panel-row').first()).toBeVisible()
// 影响:一跳;空/计数二态都诚实。
await panel.getByRole('tab', { name: '影响对象' }).click()
await expect(panel.locator('.cockpit-panel-count')).toContainText(/影响 \d+ 项/)
// 冲突:种子无开放碰撞 → 明确「无开放冲突」而非空白。
await panel.getByRole('tab', { name: '冲突与风险' }).click()
await expect(panel.getByText('无开放冲突')).toBeVisible()
// 版本块(I3):当前版本+差异折叠入口可见。
await expect(page.locator('.cockpit-versions-toggle')).toBeVisible()
})
// 场景 4:后台变更后所有投影一致(DV-R05)——测试经 API 采纳新版本,
// 驾驶舱刷新后统计水位/树时间/详情版本块同步前进。
test('4 一致性:API 采纳新版本后,刷新入口与各投影同步', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
const branch4 = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
await branch4.locator('.cockpit-tree-caret').click()
await branch4.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
const snapBefore = await page.locator('.cockpit-snap').innerText()
// 通过应用自身 API 落一个已提交变更(需求账本新增条目,唯一键防幂等跳过;
// 采纳走高危确认卡、服务端不允许客户端伪造 Confirmed,故用低危提交面驱动)。
await page.request.post('/api/commands/rdm.reqs.import', {
data: { params: { projectId: await page.locator('.cockpit-project').inputValue(),
items: [{ kind: 'UR', title: `UR-PROBE-${Date.now()} 一致性探针`, statement: '刷新一致性驱动条目', source: 'e2e-probe' }] } },
failOnStatusCode: false,
})
await page.getByRole('button', { name: '刷新' }).click()
await expect(page.locator('.cockpit-snap')).not.toHaveText(snapBefore)
// 版本块前进:出现新版本条目。
await expect(page.locator('.cockpit-versions-toggle')).toContainText(/版本 v\d+/)
})
// 场景 5:断线/失败保留旧快照并给出重试(DV-R08)——page.route 掐断命令面。
test('5 失败:命令面断连时保留旧快照并出现重试入口', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
await expect(page.locator('.cockpit-stats')).toBeVisible()
// 掐断后续命令面:切换项目触发重取 → 失败横幅 + 旧内容保留。
await page.route('**/api/commands/rdm.cockpit.**', route => route.abort())
await page.locator('.cockpit-refresh').click()
await expect(page.locator('.cockpit-banner-err')).toBeVisible()
await expect(page.locator('.cockpit-stats')).toBeVisible() // 旧快照未被清空
await page.unroute('**/api/commands/rdm.cockpit.**')
await page.locator('.cockpit-refresh').click()
await expect(page.locator('.cockpit-banner-err')).toBeHidden()
})
// 场景 6:权限负例——非成员账号看不到项目(无项目诚实空态;不泄露存在性)。
test('6 权限:非成员账号无项目可见', async ({ page }) => {
await login(page, READER, READER_PASSWORD)
await page.locator('.tsb-tool', { hasText: '文档驾驶舱' }).click()
await expect(page.getByText('暂无可见研发项目')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('.cockpit-tree-title', { hasText: '验证主体报告' })).toHaveCount(0)
})
// 场景 7:窄屏、键盘、减少动效(DV-R07)。
test('7 可用性:390 宽无横向溢出、键盘可达、减少动效可用', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.setViewportSize({ width: 390, height: 844 })
await loginOwner(page)
await openCockpit(page)
// 无横向溢出。
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
// 键盘:统计卡/树行可聚焦(tab 序含驾驶舱交互件)。
await page.locator('.cockpit-tree-title').first().focus()
await expect(page.locator('.cockpit-tree-title').first()).toBeFocused()
// 18px 大字(html font-size 注入)不破坏统计带布局。
await page.addStyleTag({ content: 'html{font-size:18px !important}' })
await expect(page.locator('.cockpit-stats')).toBeVisible()
const overflowBig = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflowBig).toBeLessThanOrEqual(8)
})
})
-101
View File
@@ -1,101 +0,0 @@
// 文档职责三视图:确定性 HTTP,不连接真实账号或数据库。
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(ok => { resolve = ok })
return { promise, resolve }
}
async function dutiesPage(page: Page) {
const writes: { command: string; params: Record<string, unknown> }[] = []
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
let generation = 0
const commands = ['rdm.project.member.list', 'rdm.duty.matrix', 'rdm.duty.preview',
'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.scan', 'rdm.duty.legacy.apply']
await page.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
let body: unknown = []
if (path === '/api/health') body = { ok: true }
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: true }
else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} }
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
else if (path.startsWith('/api/commands/')) {
const command = decodeURIComponent(path.substring('/api/commands/'.length))
const params = request.postDataJSON().params as Record<string, unknown>
expect(params.projectId).toBe('domain-7')
const hold = holds.get(command)
if (hold) { hold.started.resolve(); await hold.release.promise }
const author = generation === 0 ? 'alice' : 'bob'
if (command === 'rdm.duty.matrix') {
body = { payload: {
canAssign: true,
docs: [{ docId: 'doc-A', code: 'A', title: '文档A', typeKey: 'design', stage: 'BD', dutyVersion: 1, authors: 1, reviewers: 1, approvers: 1, conflicts: 0, legacySectionOverrides: 0 }],
members: ['alice', 'bob', 'carol'].map(owner => ({ owner, role: 'member', valid: true, canAuthor: true, canReview: true, canApprove: true })),
cells: [
{ docId: 'doc-A', owner: 'alice', duty: author === 'alice' ? 'author' : 'none' },
{ docId: 'doc-A', owner: 'bob', duty: author === 'bob' ? 'author' : 'reviewer' },
{ docId: 'doc-A', owner: 'carol', duty: 'approver' },
],
} }
} else if (command === 'rdm.duty.preview') {
writes.push({ command, params })
body = { payload: { ok: true, docs: [{ docId: 'doc-A', title: '文档A', replacedAuthor: 'alice' }] } }
} else if (command === 'rdm.duty.legacy.scan') {
body = { payload: { findings: [{ docId: 'doc-A', title: '文档A', sectionTitle: '范围', kind: 'ambiguous' }] } }
} else {
writes.push({ command, params })
generation += 1
body = { payload: { applied: 1 } }
}
} else if (path.endsWith('/cards')) body = { cards: [] }
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/')
await page.getByRole('button', { name: '项目设置', exact: true }).click()
await page.getByRole('tab', { name: '文档职责', exact: true }).click()
await expect(page.getByText('本页只分配已有文档的职责,不创建或生成文档。')).toBeVisible()
return { writes, hold(command: string) {
const pending = { started: deferred(), release: deferred() }
holds.set(command, pending)
return pending
} }
}
test('three views share one draft and do not offer review actions', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByLabel('选择成员').selectOption('bob')
await page.getByLabel('bob 在 文档A 的职责').selectOption({ label: '编制(将替换alice)' })
await page.getByRole('tab', { name: '按文档' }).click()
await page.getByLabel('选择文档').selectOption('doc-A')
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
await page.getByRole('tab', { name: '矩阵' }).click()
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
await expect(page.getByRole('button', { name: /提交|审核通过|驳回|终批|生成|新建文档/ })).toHaveCount(0)
await page.getByRole('button', { name: '保存职责' }).click()
await expect.poll(() => fixture.writes.map(item => item.command)).toEqual(['rdm.duty.preview', 'rdm.review.assign.doc.batch'])
})
test('a stale matrix response does not replace the current draft', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByLabel('选择成员').selectOption('alice')
const held = fixture.hold('rdm.duty.matrix')
await page.getByLabel('筛选文档名称或编号').fill('文档A')
await held.started.promise
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
held.release.resolve()
await expect(page.getByLabel('alice 在 文档A 的职责')).toHaveValue('reviewer')
})
test('a narrow viewport points away from the full matrix', async ({ page }) => {
await dutiesPage(page)
await page.setViewportSize({ width: 390, height: 800 })
await page.getByRole('tab', { name: '矩阵' }).click()
await expect(page.getByText('当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。')).toBeVisible()
})
-107
View File
@@ -1,107 +0,0 @@
// Isolated browser component regression: actual React workspace, deliberately deferred API responses.
// This checks client request ownership, not server authorization or business acceptance.
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_COMPONENT_BASE_URL, 'requires the isolated Vite component test server')
test.use({ baseURL: process.env.PLAYWRIGHT_COMPONENT_BASE_URL })
async function mount(page: Page, delayed: string) {
const index = await page.request.get('/')
const html = (await index.text()).replace(/<script[^>]+src="\/src\/main\.tsx[^" ]*"[^>]*><\/script>/, '')
// Let Vite prepare ReactDOM before requesting its optimized module directly.
await page.request.get('/src/main.tsx')
await page.route('**/__export_test', route => route.fulfill({ contentType: 'text/html',
body: html }))
await page.goto('/__export_test')
await page.waitForFunction(() => typeof (window as any).$RefreshReg$ === 'function')
await page.evaluate(async delayed => {
const load = (path: string) => import(/* @vite-ignore */ path)
const { default: React } = await load('/node_modules/.vite/deps/react.js')
const { default: ReactDOM } = await load('/node_modules/.vite/deps/react-dom_client.js')
const { RdmAuditWorkspace } = await load('/src/components/RdmAuditWorkspace.tsx')
const { api } = await load('/src/api.ts')
const { auth } = await load('/src/auth.ts')
const { store } = await load('/src/store.ts')
const { RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } = await load('/src/workspaces.ts')
const calls: { command: string; params: any }[] = []
let actor = 'alice'
const pending: (() => void)[] = []
let downloads = 0
URL.createObjectURL = () => { downloads++; return 'blob:export-test' }
URL.revokeObjectURL = () => {}
HTMLAnchorElement.prototype.click = () => {}
api.listPlugins = async () => []
api.listWorkspaces = async () => []
api.listCommands = async () => [...RDM_AUDIT_COMMANDS, ...RDM_EXPORT_COMMANDS].map(id => ({ id, enabled: true }))
api.adminZoneSections = async () => []
api.getSettings = async () => ({ user: {} })
api.rdmCommand = async (command: string, params: any = {}) => {
calls.push({ command, params })
const result = command === 'rdm.inspect.capabilities'
? { actor, crossProject: false, projects: ['A', 'B'].map(projectId => ({ projectId, name: actor + projectId, canAudit: true })) }
: command === 'rdm.inspect.summary'
? { projectId: params.projectId, counts: {}, watermark: 7 }
: command === 'rdm.inspect.export.create'
? { exportId: params.projectId + '-report', watermark: 7, sha256: 'abc', fileName: 'report.docx' }
: command === 'rdm.inspect.export.status'
? { expiresAt: '2099-01-01', watermark: 7, sha256: 'abc' }
: { fileName: 'report.docx', mime: 'application/octet-stream', dataB64: 'QQ==', sha256: 'abc' }
if (command === delayed && params.projectId === 'A') {
await new Promise<void>(resolve => pending.push(resolve))
}
return result
}
await store.refreshPlugins()
const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(React.createElement(RdmAuditWorkspace))
;(window as any).exportTest = { calls, release: () => pending.splice(0).forEach(resolve => resolve()),
downloads: () => downloads, identity: async () => { actor = 'bob'; auth.beginSession(); await store.refreshPlugins() },
unmount: () => root.unmount() }
}, delayed)
await expect(page.locator('.rdm-audit-export')).toBeVisible()
}
async function release(page: Page) {
await page.evaluate(async () => { (window as any).exportTest.release(); await new Promise(resolve => setTimeout(resolve, 0)) })
}
const generate = (page: Page) => page.getByRole('button', { name: '生成', exact: true }).click()
for (const stage of ['create', 'status']) {
test(`ignores old ${stage} response after project A -> B -> A`, async ({ page }) => {
await mount(page, 'rdm.inspect.export.' + stage)
await generate(page)
await expect.poll(() => page.evaluate(stage => (window as any).exportTest.calls
.some((c: any) => c.command === 'rdm.inspect.export.' + stage), stage)).toBe(true)
await page.getByLabel('选择项目').selectOption('B')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
await page.getByLabel('选择项目').selectOption('A')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
await release(page)
await expect(page.getByRole('button', { name: '下载', exact: true })).toHaveCount(0)
if (stage === 'create') expect(await page.evaluate(() => (window as any).exportTest.calls
.filter((c: any) => c.command === 'rdm.inspect.export.status').length)).toBe(0)
await expect(page.getByRole('button', { name: '生成', exact: true })).toBeEnabled()
})
}
for (const change of ['project', 'identity', 'unmount']) {
test(`suppresses delayed download after ${change} change`, async ({ page }) => {
await mount(page, 'rdm.inspect.export.download')
await generate(page)
await page.getByRole('button', { name: '下载', exact: true }).click()
if (change === 'project') {
await page.getByLabel('选择项目').selectOption('B')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
} else if (change === 'identity') {
await page.evaluate(() => (window as any).exportTest.identity())
await expect(page.getByLabel('选择项目').locator('option').first()).toHaveText('bobA')
} else await page.evaluate(() => (window as any).exportTest.unmount())
await release(page)
expect(await page.evaluate(() => (window as any).exportTest.downloads())).toBe(0)
})
}
test('current identity can generate and download normally', async ({ page }) => {
await mount(page, '')
await generate(page)
await page.getByRole('button', { name: '下载', exact: true }).click()
await expect.poll(() => page.evaluate(() => (window as any).exportTest.downloads())).toBe(1)
await expect(page.locator('.rdm-audit-export-result')).toContainText('水位 7')
})
-202
View File
@@ -1,202 +0,0 @@
import { test, expect } from '@playwright/test'
import { readFileSync, readlinkSync } from 'node:fs'
import { createHash } from 'node:crypto'
const manifestPath=process.env.WORK_CHECKPOINT_HTTP_MANIFEST
test.skip(!manifestPath,'Run explicitly with a task-owned mechanism instance manifest')
const manifest=manifestPath?JSON.parse(readFileSync(manifestPath,'utf8')):null
if(manifest&&(manifest.mode!=='mechanism'||!manifest.dataDir.includes('fast-slow-u2-u3-')||readlinkSync(`/proc/${manifest.pid}/exe`)!==manifest.binary||createHash('sha256').update(readFileSync(manifest.binary)).digest('hex')!==manifest.binarySHA256))throw new Error('Owned mechanism instance verification failed')
type Scenario='normal'|'fast-ack'|'queue-hold'|'slow-stages'|'zero-failure'|'partial-failure'|'choice'
async function setup(request:any,scenario:Scenario='normal',awaitTask=true,options:{text?:string,small?:boolean,startFailure?:boolean}={}){
const project=await(await request.post(manifest.base+'/api/projects',{data:{name:'browser-'+Date.now()}})).json()
const conv=await(await request.post(manifest.base+`/api/projects/${project.id}/conversations`,{data:{title:'材料整理',profileId:'checkpoint-eval'}})).json()
const ids:string[]=[]
for(let i=0;i<(options.small?1:3);i++){
const marker=i===0&&scenario!=='normal'?`[fixture:${scenario}]\n`:''
const res=await request.post(manifest.base+`/api/projects/${project.id}/uploads`,{multipart:{file:{name:`来源${i+1}.txt`,mimeType:'text/plain',buffer:Buffer.from(marker+'上线六月。验收条件需要补齐,预算50万元尚未审批。交付包含12项需求,其中3项依赖外部接口。第一期先完成8项核心需求。\n\n'+(options.small?'':('整理记录说明:保留日期、金额、限制和待确认事项。\n\n').repeat(options.text?250:100)))}}})
expect(res.ok()).toBeTruthy();ids.push((await res.json()).artifactId)
}
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:true}})).status()).toBe(204)
let sendBody:any
try{
const sent=await request.post(manifest.base+`/api/conversations/${conv.id}/messages`,{data:{text:options.text??'',attachmentIds:ids,requestId:'browser-'+Date.now()}})
expect(sent.ok()).toBeTruthy()
sendBody=await sent.json()
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${sendBody.run.id}`)).json()).run.status).toBe('completed')
}finally{
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:false}})).status()).toBe(204)
}
if(awaitTask)await expect.poll(async()=>{
const out=await(await request.get(manifest.base+`/api/conversations/${conv.id}/cards`)).json()
return out.cards.find((c:any)=>c.task)?.task.executionState
}).toBe(scenario.endsWith('failure')?'failed':'completed')
return {project,conv,ids,sendRunId:sendBody.run.id}
}
async function waitForFailedWorkCard(request:any,seed:any){
await expect.poll(async()=>{
const out=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()
const card=out.cards.find((c:any)=>c.task)
return card?.task.executionState==='failed'&&card.status==='failed'&&Boolean(card.work)
}).toBe(true)
}
async function expectOnlySendRun(request:any,seed:any){
const current=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
expect(current.runs.filter((r:any)=>r.kind==='conversation').map((r:any)=>r.id)).toEqual([seed.sendRunId])
}
async function open(page:any,seed:any){
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
await page.goto('/')
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
}
test('attachment-only intake gives a safe first reply before sourced findings',async({page,request})=>{
const seed=await setup(request,'fast-ack',false)
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
expect(cards.find((c:any)=>c.task).task.checkpointSeq??0).toBe(0)
await open(page,seed)
const reply=page.getByText(/已收到 3 份材料.*待确认/)
await expect(reply).toBeVisible()
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
await page.setViewportSize({width:390,height:844})
await expect(reply).toBeVisible()
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT.replace(/\.png$/, '-mobile.png'),fullPage:true})
await page.reload()
await expect(reply).toBeVisible()
})
test('text with long material keeps the user focus in the host reply',async({page,request})=>{
const seed=await setup(request,'fast-ack',false,{text:'重点核对预算审批'})
await open(page,seed)
await expect(page.getByText(/你提出的工作重点已带入整理任务.*待确认/)).toBeVisible()
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
})
test('running and completed resends reuse one material card',async({page,request})=>{
const seed=await setup(request,'slow-stages',false)
const resend=async(key:string)=>{
const response=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:seed.ids,requestId:key}})
expect(response.ok()).toBeTruthy()
const body=await response.json()
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${body.run.id}`)).json()).run.status).toBe('completed')
}
await resend('active-resend-'+Date.now())
await open(page,seed)
await expect(page.getByText(/这批材料仍在整理.*待确认/)).toBeVisible()
await expect.poll(async()=>{
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
return cards.find((c:any)=>c.task)?.task.executionState
}).toBe('completed')
await resend('completed-resend-'+Date.now())
await expect(page.getByText(/这批材料已整理完成.*待确认/)).toBeVisible()
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
})
test('text with a small attachment retains the ordinary reply path',async({page,request})=>{
const seed=await setup(request,'normal',true,{text:'请先解释材料要点',small:true})
await open(page,seed)
await expect(page.getByText('已收到材料,正在后台整理;请结合阶段发现继续工作。').first()).toBeVisible()
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
})
test('real HTTP task recovery, source navigation and changed-version limitation',async({page,request})=>{
const seed=await setup(request);const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message));await open(page,seed)
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
await expect(page.locator('[data-conversation-work]')).toContainText('材料未说明具体归属')
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
await page.getByRole('button',{name:'查看依据',exact:true}).first().click()
await page.getByRole('button',{name:/查看来源/}).first().click()
await expect(page.locator('.preview-pane')).toBeVisible();await expect(page.getByText('任务处理时引用的来源版本;下方是本项发现引用的片段')).toBeVisible()
const card=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.find((c:any)=>c.task)
const source=card.task.findings[0].sources[0]
const update=await request.put(manifest.base+`/api/artifacts/${source.artifactId}/content`,{data:'上线七月。文件内容已经更新。',headers:{'Content-Type':'text/plain'}});expect(update.ok()).toBeTruthy()
const old=await request.get(manifest.base+`/api/artifacts/${source.artifactId}/content?expectedHash=${source.contentHash}`);expect(old.status()).toBe(409)
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('依据文件已更新')
await expect(page.locator('[data-conversation-work] .acard-findings').first()).not.toContainText('上线六月')
expect(errors).toEqual([])
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT,fullPage:true})
})
test('background replay does not steal the current file, focus or viewport',async({page,request})=>{
const seed=await setup(request);await open(page,seed)
await page.getByRole('button',{name:'查看依据',exact:true}).first().click();await page.getByRole('button',{name:/查看来源/}).first().click();await expect(page.locator('.preview-pane')).toBeVisible()
const tabs=await page.locator('.tab-strip').textContent()
const composer=page.locator('textarea').last();await composer.fill('正在记录自己的想法');await composer.focus()
const focus=await composer.evaluate((el:any)=>document.activeElement===el);expect(focus).toBe(true)
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'重点核对预算',attachmentIds:seed.ids,requestId:'another-'+Date.now()}});expect(sent.ok()).toBeTruthy()
await expect.poll(async()=>{
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
return cards.filter((c:any)=>c.task?.executionState==='completed').length
}).toBe(2)
await expect(composer).toBeFocused();await expect(composer).toHaveValue('正在记录自己的想法')
expect(await page.locator('.tab-strip').textContent()).toBe(tabs)
})
test('two sourced stages appear while the material task is still running',async({page,request})=>{
const seed=await setup(request,'slow-stages',false)
await open(page,seed)
const cards=async()=>(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('running')
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState==='running'&&card.task.checkpointSeq>=2&&card.task.findings?.length>1}).toBe(true)
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('completed')
})
for(const scenario of ['zero-failure','partial-failure'] as const){
test(`${scenario} shows the saved scope without a deliverable choice`,async({page,request})=>{
const seed=await setup(request,scenario)
await waitForFailedWorkCard(request,seed)
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
await page.goto('/')
const work=page.locator('[data-conversation-work]')
await expect(work).toContainText('执行失败')
if(scenario==='zero-failure')await expect(work).toContainText('暂无阶段结论记录')
else await expect(work).toContainText('上线六月')
await expect(page.locator('.work-choice')).toHaveCount(0)
await expect(page.locator('.hitl-choice')).toHaveCount(0)
await expectOnlySendRun(request,seed)
})
}
test('completed sourced work offers only its grounded suggestions',async({page,request})=>{
const seed=await setup(request,'choice')
await open(page,seed)
const work=page.locator('[data-conversation-work]')
await expect(work.locator('.work-choice')).toHaveCount(3)
for(const label of ['会议摘要','验收清单','联调计划'])await expect(work.locator('.work-choice').filter({hasText:label})).toHaveCount(1)
await expect(work.locator('.work-decision legend')).toContainText('接下来需要做什么?')
await page.reload()
await expect(work.locator('.work-choice')).toHaveCount(3)
})
test('failed material registration reports no started task or unsupported finding',async({page,request})=>{
const seed=await setup(request,'normal',false,{startFailure:true})
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
await page.goto('/')
await expect(page.getByText(/后台整理未能启动/).first()).toBeVisible()
const conversation=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
expect(conversation.messages.some((m:any)=>m.role==='assistant'&&(m.parts??[]).some((p:any)=>p.type==='text'&&p.text?.includes('未能启动')))).toBe(true)
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
expect(cards.filter((c:any)=>c.task)).toHaveLength(0)
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
})
test('saturated material slots show queued status without claiming execution',async({page,request})=>{
const seed=await setup(request,'queue-hold',false)
const sendAnother=async(label:string,hold:boolean)=>{
const uploaded=await request.post(manifest.base+`/api/projects/${seed.project.id}/uploads`,{
multipart:{file:{name:`${label}.txt`,mimeType:'text/plain',buffer:Buffer.from((hold?'[fixture:queue-hold]\n':'')+'上线六月。验收条件需要补齐。'+label)}}
})
expect(uploaded.ok()).toBeTruthy()
const artifactId=(await uploaded.json()).artifactId
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:[artifactId],requestId:'queue-'+label}})
expect(sent.ok()).toBeTruthy()
const runId=(await sent.json()).run.id
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${runId}`)).json()).run.status).toBe('completed')
}
await sendAnother('占用二',true)
await sendAnother('占用三',true)
await sendAnother('等待四',false)
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
await page.goto('/')
await expect(page.locator('[data-conversation-work]')).toContainText('排队中')
await expect(page.getByText(/整理任务正在排队.*待确认/).first()).toBeVisible()
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.filter((c:any)=>c.task)
expect(cards).toHaveLength(4)
expect(cards.filter((c:any)=>c.task.executionState==='queued')).toHaveLength(1)
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
})
+13 -369
View File
@@ -1,5 +1,5 @@
import { test, expect, type Page } from '@playwright/test'
import type { ActivityCard, Artifact, Conversation, Part, StreamEvent, WorkCardAction } from '../src/contracts'
import type { ActivityCard, Artifact, Conversation, StreamEvent } from '../src/contracts'
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
@@ -21,20 +21,18 @@ const confirmation = work('confirm1', 'action_required', { kind: 'confirm', titl
actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }],
})
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14,
history?: { events: StreamEvent[]; cardRef?: string; savedParts?: Part[] }, theme: 'light' | 'dark' | 'molv' = 'light') {
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14) {
let cards = [...initialCards]
let actionCount = 0
let releaseAction: (() => void) | undefined
let holdAction = false
let sent: { text: string; attachmentIds: string[]; taskRef?: { cardId: string; resultVersion: number } } | undefined
const workActions: { cardId: string; actionId: string; payload: WorkCardAction }[] = []
let sent: { text: string; attachmentIds: string[] } | undefined
let uploadNumber = 0
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
await page.addInitScript(({ fontSize, theme }) => {
await page.addInitScript(({ fontSize }) => {
localStorage.setItem('as.conv', 'c1')
localStorage.setItem('as.ui', JSON.stringify({ theme, fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
const streams: { url: string; emit: (event: unknown) => void }[] = []
class TestEventSource {
onopen: (() => void) | null = null
@@ -50,7 +48,7 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
close() {}
}
Object.assign(window, { EventSource: TestEventSource, __workStreams: streams })
}, { fontSize, theme })
}, { fontSize })
await page.route('**/api/**', async route => {
const request = route.request()
const url = new URL(request.url())
@@ -66,21 +64,8 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
body = { conversation: conversations.find(c => c.id === id), messages: id === 'c1' ? [
{ id: 'm1', conversationId: id, role: 'user', parts: [{ type: 'text', text: '请整理这三份材料,看看需要优先处理什么' }], createdAt: stamp },
{ id: 'm2', conversationId: id, role: 'assistant', parts: [{ type: 'text', text: '先核对时间安排,再补齐验收条件。其他内容仍在整理。' }], createdAt: stamp },
...(history ? [{ id: 'm3', conversationId: id, role: 'user', runId: 'run-history', parts: [{ type: 'text', text: '继续核对' }], createdAt: stamp },
{ id: 'm4', conversationId: id, role: 'assistant', runId: 'run-history', parts: history.savedParts ?? [
{ type: 'thinking', process: true, text: '先核对日期' },
{ type: 'text', process: true, text: '开始读取材料' },
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' } },
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok', summary: '读取成功' } },
{ type: 'text', text: '最终回复已完成' },
...(history.cardRef ? [{ type: 'card', card: { cardId: history.cardRef, kind: 'result', data: [] } }] : []),
], createdAt: stamp }] : []),
] : [], runs: history ? [{ id: 'run-history', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'completed', createdAt: stamp, endedAt: stamp }] : [], activeHitl: [], lastSeq: 10 }
] : [], runs: [], activeHitl: [], lastSeq: 10 }
} else if (path === '/api/conversations/c1/cards') body = { cards }
else if (path === '/api/conversations/c1/runs/run-history/events') {
const after = Number(url.searchParams.get('after') ?? 0)
body = { events: (history?.events ?? []).filter(event => event.seq > after).slice(0, 500) }
}
else if (path === '/api/conversations/c2/cards') body = { cards: [] }
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
else if (path === '/api/projects/p1/artifacts') body = [artifact]
@@ -92,22 +77,6 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
else if (/\/messages$/.test(path) && request.method() === 'POST') {
sent = request.postDataJSON()
body = { message: { id: 'sent', conversationId: 'c1', role: 'user', parts: [{ type: 'text', text: sent?.text ?? '' }], createdAt: stamp }, run: { id: 'run1', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'running', createdAt: stamp } }
} else if (/^\/api\/cards\/[^/]+\/actions\/work\./.test(path)) {
const cardId = path.split('/')[3]
const actionId = path.split('/').at(-1)!
const payload = request.postDataJSON() as WorkCardAction
workActions.push({ cardId, actionId, payload })
cards = cards.map(card => {
if (card.id !== cardId || !card.work) return card
const next = { ...card, work: { ...card.work } }
if (actionId === 'work.read') next.work.readVersion = payload.resultVersion
else if (next.work.decision) next.work.decision = { ...next.work.decision,
state: actionId === 'work.defer' ? 'deferred' : actionId === 'work.reopen' ? 'open' : 'submitted',
...(actionId === 'work.submit' ? { selectedIds: payload.selectedIds, freeText: payload.freeText, respondedAt: stamp } : {}),
}
return next
})
body = { card: cards.find(card => card.id === cardId) }
} else if (path === '/api/cards/confirm1/actions/confirm') {
actionCount++
if (holdAction) await new Promise<void>(resolve => { releaseAction = resolve })
@@ -119,7 +88,7 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
await page.goto('/')
await expect(page.locator('.chat-title')).toHaveText('整理项目材料')
if (initialCards.length) await expect(page.locator('[data-conversation-work]')).toBeVisible()
return { errors, workActions, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
return { errors, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
async emit(type: StreamEvent['type'], payload: unknown, seq: number) {
await page.evaluate(({ type, payload, seq }) => {
const win = window as typeof window & { __workStreams: { url: string; emit: (e: unknown) => void }[] }
@@ -131,145 +100,6 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
}
}
function optionalWork(id = 'optional-work', mode: 'single' | 'multiple' = 'single'): ActivityCard {
return work(id, 'done', { title: '说明书材料整理', sourceRunId: 'run-history',
work: { resultVersion: 2, readVersion: 0, completedAt: stamp,
result: '# 说明书重点\n\n已整理软件结构、角色权限与操作流程,原文件保留。\n\n'
+ '- **使用角色**:管理员、医生和患者\n- **软件结构**:患者端与医生端连接同一服务端\n- **核心内容**:安装维护、训练操作与安全说明\n\n'
+ '## 可继续整理的内容\n\n可以先阅读摘要了解全貌,也可以单独提取性能参数、设备要求与需要进一步确认的事项。'
+ '整理结果仅依据当前上传的说明书;缺少的材料和不能确认的内容会单独列出,不会补写成已确认的结论。'
+ '如果继续生成交付物,新的结果会更新到独立工作卡,您可以同时讨论其他话题。'
+ '每项结论都应保留材料范围和来源,便于后续复核。\n\n'
+ '## 后续核对\n\n在正式采用整理结果前,建议把产品版本、适用设备、使用人员与预期交付形式分别确认。'
+ '材料中直接写明的内容和整理时发现的问题会分开记录。对没有原文依据的数字、范围或归属,整理结果会保持待确认状态。'
+ '需要比较其他版本时,可补充相应文件,并说明希望重点比较的部分。\n\n最后一条:需要确认使用场景。',
decision: { mode, state: 'open', options: [{ id: 'summary', label: '生成摘要' }, { id: 'facts', label: '提取参数' }] },
},
})
}
test('keeps optional choices nonblocking and supports defer, reopen, free input and saved answers', async ({ page }) => {
const state = await fixture(page, [optionalWork('optional-work', 'multiple')])
const inline = page.locator('[data-conversation-work] [data-card-id="optional-work"]')
await expect(inline.getByRole('checkbox', { name: '生成摘要', exact: true })).toBeVisible()
await inline.getByRole('button', { name: '先不选择', exact: true }).click()
await expect(inline.getByText('已暂缓选择,您可以继续对话')).toBeVisible()
await expect(inline.getByRole('checkbox')).toHaveCount(0)
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('先聊一下本周的安排')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect.poll(() => state.sent?.text).toBe('先聊一下本周的安排')
expect(state.sent?.taskRef).toBeUndefined()
// Receiving the POST is earlier than rendering its legitimate foreground reply.
await expect(page.locator('.msg.assistant[data-msg-id="live:run1"]')).toBeVisible()
const assistantCount = await page.locator('.msg.assistant').count()
await inline.getByRole('button', { name: '继续选择', exact: true }).click()
await inline.getByRole('checkbox', { name: '生成摘要', exact: true }).check()
await inline.getByRole('checkbox', { name: '提取参数', exact: true }).check()
await inline.getByRole('textbox', { name: '补充要求或直接输入需求' }).fill('保留原文中的来源和限制')
await inline.getByRole('button', { name: '开始处理', exact: true }).click()
await expect(inline.getByText('已提交需求', { exact: true })).toBeVisible()
await expect(inline.getByText('保留原文中的来源和限制', { exact: true })).toBeVisible()
await expect(inline.getByRole('textbox')).toHaveCount(0)
expect(state.workActions.filter(action => action.actionId === 'work.submit')).toEqual([{
cardId: 'optional-work', actionId: 'work.submit', payload: { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '保留原文中的来源和限制' },
}])
await expect(page.locator('.msg.assistant')).toHaveCount(assistantCount)
expect(state.errors).toEqual([])
})
test('opens full results in one place, preserves unread until explicit action and references a version in the composer', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1050 })
const state = await fixture(page, [optionalWork()])
await expect(page.locator('.work-result-notice')).toContainText('1 项工作有新结果')
await page.locator('.work-result-notice').click()
const card = page.locator('.right-panel [data-card-id="optional-work"]')
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await expect(card.getByRole('radio', { name: '生成摘要', exact: true })).toBeVisible()
if (process.env.WORKBENCH_FLOW_SCREENSHOT) await page.screenshot({ path: process.env.WORKBENCH_FLOW_SCREENSHOT, fullPage: true })
await card.getByRole('button', { name: '查看完整结果', exact: true }).click()
await expect(card.getByText('最后一条:需要确认使用场景。', { exact: false })).toBeVisible()
expect(state.workActions.filter(action => action.actionId === 'work.read')).toHaveLength(0)
await card.getByRole('button', { name: '基于此结果继续', exact: true }).click()
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toContainText('第 2 版结果')
await expect.poll(() => state.workActions.filter(action => action.actionId === 'work.read').length).toBe(1)
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('解释一下需要确认的事项')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect.poll(() => state.sent?.taskRef).toEqual({ cardId: 'optional-work', resultVersion: 2 })
expect(state.sent?.text).toBe('解释一下需要确认的事项')
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toHaveCount(0)
await expect(page.locator('.msg.user time[datetime]')).toHaveCount(2)
await expect(page.locator('.msg.assistant time[datetime]')).toHaveCount(2)
expect(state.errors).toEqual([])
})
test('retains text, uploads and the result reference when mixed input is not supported', async ({ page }) => {
const state = await fixture(page, [optionalWork()])
await page.locator('.work-result-notice').click()
await page.getByRole('button', { name: '基于此结果继续', exact: true }).click()
await page.getByLabel('添加材料', { exact: true }).setInputFiles({ name: '补充材料.txt', mimeType: 'text/plain', buffer: Buffer.from('补充要求') })
const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true })
await input.fill('结合补充材料解释重点')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect(input).toHaveValue('结合补充材料解释重点')
await expect(page.locator('.attach-chip')).toContainText('补充材料.txt')
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toBeVisible()
expect(state.sent).toBeUndefined()
})
test('keeps middle and right scrolling independent while retaining full execution output', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 800 })
const events: StreamEvent[] = [
{ seq: 1, type: 'thinking.delta', payload: { text: '已保存的核对思路' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
{ seq: 2, type: 'tool.call', payload: { callId: 'call-record', name: 'fs_read_file', arguments: '{"path":"说明书"}' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
{ seq: 3, type: 'tool.output', payload: { callId: 'call-record', text: '完整的文档读取记录' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
{ seq: 4, type: 'tool.result', payload: { callId: 'call-record', status: 'ok' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
]
const cards = [optionalWork(), ...Array.from({ length: 15 }, (_, index) => work(`active-${index}`, 'active', { title: `其他工作 ${index + 1}` }))]
const state = await fixture(page, cards, 14, { events, savedParts: [{ type: 'text', text: '继续讨论当前主题。\n\n'.repeat(100) }] })
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const right = page.locator('.right-panel [data-work-scroll]')
const chat = page.locator('.chat-body')
await right.evaluate(element => { element.scrollTop = 180 })
const rightTop = await right.evaluate(element => element.scrollTop)
await chat.evaluate(element => { element.scrollTop = 80; element.dispatchEvent(new Event('scroll', { bubbles: true })) })
expect(await right.evaluate(element => element.scrollTop)).toBe(rightTop)
const chatTop = await chat.evaluate(element => element.scrollTop)
await right.evaluate(element => { element.scrollTop = 240; element.dispatchEvent(new Event('scroll', { bubbles: true })) })
expect(await chat.evaluate(element => element.scrollTop)).toBe(chatTop)
await page.locator('.right-panel [data-card-id="optional-work"]').getByRole('button', { name: '查看完整执行记录', exact: true }).click()
const record = page.getByRole('region', { name: '说明书材料整理的执行记录' })
await expect(record.getByText('已保存的核对思路', { exact: true })).toBeVisible()
await record.getByRole('button', { name: /查看记录/ }).click()
await expect(record.getByText('完整的文档读取记录', { exact: true })).toBeVisible()
await expect(page.locator('.work-execution-record')).toHaveCount(1)
await record.getByRole('button', { name: '返回工作', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id="optional-work"]')).toBeVisible()
expect(state.errors).toEqual([])
})
test('folds read task history newest first and retains submitted choices after reload', async ({ page }) => {
const older = optionalWork('older')
const latest = optionalWork('latest')
older.title = '旧材料整理'; latest.title = '新材料整理'
older.work = { ...older.work!, readVersion: 2, decision: { mode: 'single', state: 'submitted', selectedIds: ['summary'], freeText: '保留参数表', respondedAt: stamp,
options: [{ id: 'summary', label: '生成摘要' }] } }
latest.work = { ...latest.work!, readVersion: 2, decision: { mode: 'single', state: 'deferred' } }
latest.updatedAt = '2026-10-13T10:00:00Z'
const state = await fixture(page, [older, latest])
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(0)
await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(2)
expect(await page.locator('.right-panel [data-card-id]').evaluateAll(elements => elements.map(element => element.getAttribute('data-card-id')))).toEqual(['latest', 'older'])
await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible()
await page.reload()
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible()
await expect(page.locator('.right-panel [data-card-id="older"]').getByRole('textbox')).toHaveCount(0)
expect(state.errors).toEqual([])
})
test('keeps real summary and details accessible with right pane closed', async ({ page }) => {
const state = await fixture(page)
await expect(page.locator('.right-panel')).toBeHidden()
@@ -284,105 +114,17 @@ test('keeps real summary and details accessible with right pane closed', async (
expect(state.errors).toEqual([])
})
test('places the full work card only in the desktop work pane', async ({ page }) => {
test('keeps one expanded ordinary summary when the desktop work pane opens', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const state = await fixture(page)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await expect(page.locator('.right-panel .acard')).toHaveCount(1)
await expect(page.locator('[data-conversation-work] .acard')).toHaveCount(0)
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await expect(page.locator('.companion-topbar').getByRole('button', { name: '打开工作' })).toHaveCount(0)
await expect(page.locator('.right-panel').getByRole('button', { name: /工作/ }).first()).toBeVisible()
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: /整理项目材料 · 查看摘要/ })).toBeVisible()
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
expect(state.errors).toEqual([])
})
test('uses a focused reading column and a compact authored-message shape', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 })
const state = await fixture(page)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const conversation = page.locator('.chat-body .msg').first()
const authored = page.locator('.chat-body .msg.user').first()
const answer = page.locator('.chat-body .msg.assistant').first()
const [conversationBox, authoredBox, answerBox] = await Promise.all([
conversation.boundingBox(), authored.boundingBox(), answer.boundingBox(),
])
expect(conversationBox).not.toBeNull()
expect(authoredBox).not.toBeNull()
expect(answerBox).not.toBeNull()
expect(conversationBox!.width).toBeLessThanOrEqual(840)
expect(authoredBox!.width).toBeLessThanOrEqual(680)
expect(authoredBox!.x + authoredBox!.width).toBeCloseTo(answerBox!.x + answerBox!.width, 0)
const surfaces = await page.evaluate(() => {
const pick = (selector: string) => {
const element = document.querySelector(selector)
if (!element) throw new Error(`missing ${selector}`)
const style = getComputedStyle(element)
return {
background: style.backgroundColor,
color: style.color,
borderRadius: Number.parseFloat(style.borderRadius),
paddingLeft: Number.parseFloat(style.paddingLeft),
}
}
return {
chrome: pick('.companion-topbar'),
context: pick('.left-nav'),
conversation: pick('.main'),
answer: pick('.msg.assistant'),
authored: pick('.msg.user .bubble'),
}
})
const rgb = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
const distance = (a: string, b: string) => rgb(a).reduce((sum, value, index) => sum + Math.abs(value - rgb(b)[index]), 0)
expect(distance(surfaces.chrome.background, surfaces.conversation.background)).toBeGreaterThan(300)
expect(distance(surfaces.context.background, surfaces.conversation.background)).toBeGreaterThan(300)
expect(distance(surfaces.authored.background, surfaces.answer.background)).toBeGreaterThan(300)
expect(surfaces.answer.borderRadius).toBeGreaterThanOrEqual(12)
expect(surfaces.answer.paddingLeft).toBeGreaterThanOrEqual(20)
const activeCard = page.locator('.right-panel .acard.status-active')
await expect(activeCard).toBeVisible()
const cardStyle = await activeCard.evaluate(element => {
const style = getComputedStyle(element)
return { background: style.backgroundColor, borderLeftWidth: style.borderLeftWidth }
})
const panelBackground = await page.locator('.right-panel').evaluate(element => getComputedStyle(element).backgroundColor)
expect(cardStyle.background).not.toBe(panelBackground)
expect(Number.parseFloat(cardStyle.borderLeftWidth)).toBeGreaterThanOrEqual(3)
expect(state.errors).toEqual([])
})
for (const theme of ['light', 'dark', 'molv'] as const) {
test(`keeps the workbench frame legible in ${theme}`, async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const state = await fixture(page, [progress], 14, undefined, theme)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const surfaces = await page.evaluate(() => ['.companion-topbar', '.left-nav', '.msg.user .bubble', '.msg.assistant'].map(selector => {
const element = document.querySelector(selector)
if (!element) throw new Error(`missing ${selector}`)
const style = getComputedStyle(element)
return { selector, foreground: style.color, background: style.backgroundColor }
}))
const channels = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
const luminance = (value: string) => {
const linear = channels(value).map(channel => {
const normalized = channel / 255
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
})
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
}
for (const surface of surfaces) {
const lighter = Math.max(luminance(surface.foreground), luminance(surface.background))
const darker = Math.min(luminance(surface.foreground), luminance(surface.background))
expect((lighter + 0.05) / (darker + 0.05), `${surface.selector} contrast in ${theme}`).toBeGreaterThanOrEqual(4.5)
}
expect(state.errors).toEqual([])
})
}
test('preserves the selected material, physical scroll and focus when background work finishes', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const tabbed = work('work1', 'active', { body: [...progress.body!, {
@@ -418,117 +160,19 @@ test('preserves the selected material, physical scroll and focus when background
expect(state.errors).toEqual([])
})
test('keeps one confirmation action location when the work pane is visible', async ({ page }) => {
test('shares submitting feedback between conversation and work confirmations', async ({ page }) => {
const state = await fixture(page, [confirmation])
await page.getByRole('button', { name: '打开工作', exact: true }).click()
state.hold()
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true }).click()
await page.locator('[data-conversation-work]').getByRole('button', { name: '确认生成', exact: true }).click()
await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
await expect(page.locator('.chat-body').getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: '暂不生成', exact: true })).toBeDisabled()
expect(state.actionCount).toBe(1)
state.release()
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
expect(state.errors).toEqual([])
})
test('keeps each run process folded and reloadable after the final reply', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
({ seq, type, payload, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 })
const events = [
event(1, 'thinking.delta', { index: 0, text: '先核对日期' }),
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
event(4, 'tool.output', { callId: 'call-1', text: '完整工具输出' }),
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
event(6, 'run.phase', { phase: 'finalizing' }),
event(7, 'message.delta', { index: 1, text: '最终回复已完成' }),
]
const state = await fixture(page, [], 14, { events })
const message = page.locator('[data-msg-id="m4"]')
await expect(message.getByText('最终回复已完成')).toBeVisible()
await expect(message.getByText('开始读取材料')).toHaveCount(0)
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('先核对日期')).toBeVisible()
await expect(message.getByText('开始读取材料')).toBeVisible()
await message.locator('.tool-block-head').click()
await expect(message.getByText('完整工具输出')).toBeVisible()
await page.reload()
const restored = page.locator('[data-msg-id="m4"]')
await expect(restored.getByText('最终回复已完成')).toBeVisible()
await restored.getByRole('button', { name: /执行记录/ }).click()
await expect(restored.getByText('先核对日期')).toBeVisible()
await restored.locator('.tool-block-head').click()
await expect(restored.getByText('完整工具输出')).toBeVisible()
expect(state.errors).toEqual([])
})
test('replay enriches saved notes and keeps collaboration progress after completion', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
const events = [
event(1, 'message.delta', { index: 0, text: '<think>已解析的思考</think>' }),
event(2, 'message.delta', { index: 1, text: '开始读取' }),
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
event(4, 'tool.output', { callId: 'call-1', text: '完整输出留存' }),
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
event(6, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(7, 'run.phase', { phase: 'finalizing' }),
event(8, 'message.delta', { index: 1, text: '最终回复已完成' }),
event(9, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
event(10, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
]
const savedParts: Part[] = [
{ type: 'text', process: true, text: '模型在输出前失败,正在重试' },
{ type: 'thinking', process: true, text: '已解析的思考' },
{ type: 'text', process: true, text: '开始读取材料' },
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell' } },
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } },
{ type: 'text', text: '最终回复已完成' },
]
await fixture(page, [], 14, { events, savedParts })
const message = page.locator('[data-msg-id="m4"]')
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('模型在输出前失败,正在重试')).toBeVisible()
await expect(message.getByText('已解析的思考')).toBeVisible()
await expect(message.getByText('开始读取材料')).toBeVisible()
await expect(message.getByText('开始读取', { exact: true })).toHaveCount(0)
await expect(message.getByText('协作进展:正在核对附录')).toBeVisible()
await expect(message.getByText('协作结束:附录已核对')).toBeVisible()
await expect(message.getByText('<think>已解析的思考</think>')).toHaveCount(0)
await message.locator('.tool-block-head').click()
await expect(message.getByText('完整输出留存')).toBeVisible()
})
test('keeps a child completion note when the final reply uses the same status words', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
await fixture(page, [], 14, { events: [
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(2, 'run.phase', { phase: 'finalizing' }),
event(3, 'message.delta', { index: 0, text: '已完成' }),
event(4, 'agent.result', { agentRun: 'child', status: 'completed', summary: '已完成' }, 'child'),
], savedParts: [{ type: 'text', process: true, text: '记录已完成' }, { type: 'text', text: '已完成' }] })
const message = page.locator('[data-msg-id="m4"]')
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('协作结束:已完成')).toBeVisible()
})
test('locates a completed card even when work is filtered to project pending items', async ({ page }) => {
const result = work('result1', 'done', { kind: 'result', title: '材料核对结果',
body: [{ type: 'markdown', markdown: '完整结论在这里' }] })
await fixture(page, [result], 14, { events: [], cardRef: 'result1' })
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const workPane = page.locator('.right-panel')
await workPane.locator('.inbox-scope').getByRole('button', { name: '项目' }).click()
await workPane.locator('.inbox-scope').getByRole('button', { name: '待处理' }).click()
await expect(workPane.getByText('完整结论在这里')).toHaveCount(0)
await page.locator('[data-msg-id="m4"] .card-anchor-locate').click()
await expect(workPane.getByText('完整结论在这里')).toBeVisible()
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '本会话' })).toHaveClass(/active/)
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '全部' })).toHaveClass(/active/)
})
test('opens the one file preview and preserves reading during background updates', async ({ page }) => {
const result = work('result1', 'done', { kind: 'result', title: '材料摘要已准备好', body: [{ type: 'doc-ref', docId: 'doc1' }] })
const state = await fixture(page, [result])
+1 -1
View File
@@ -1,3 +1,3 @@
{
"exports": 58
"exports": 43
}
-40
View File
@@ -1,40 +0,0 @@
# 通用规范采纳记录
- 项目/负责人:Agent-System-Frontend / weichen
- 状态:active
- 规范位置:`deps/development-guidelines`
- 规范性文档目录:`policy/`(项目规则与制度放此处;入口为 `policy/README.md`)
- 过程文档位置:任务卡在 `.task/`(如 `.task/feature-governance-adoption.md`);任务主记录为对应 PR 描述
- 版本权威:本仓 gitlink `deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`;可读标签:无(候选 `0.1.0-draft.12`,尚未发布)
- 批准/生效依据:`feature/governance-adoption PR(Agent-System-Frontend)`
- 替代范围和迁移说明:无替代既有采纳记录。遗留:`policy/README.md` 与 `PROJECT.md` 的采纳基线行仍写有旧路径 `development/guidelines`(指向 Dev 仓),本次未改,后续单独修正为本仓 gitlink。
| 规范/工作流 | 适用条件与范围 | 采纳或不适用理由 | 执行方式/证据 |
|---|---|---|---|
| core/baseline.md | 日常工作 | 采纳:所有任务的硬边界、授权与完成声明基线 | manual |
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 采纳:本仓日常交付、Bug 排障与会话交接均按此执行,任务卡与 PR 为记录载体 | manual |
| standards/collaboration.md | 并行共同起点、两级分支、节点同步与陈旧候选拒绝 | 采纳:存在 feature 分支并行开发,合入前需同步最新目标分支 | manual;有真实平台负例再登记 enforced |
| standards/merge-queue.md | 多个就绪任务的最终同步、CI、Review 与合入调度 | 不适用:单 owner、单仓串行合入,无共享 CI 预算或多任务合入窗口 | 不适用 |
| standards/agent-execution.md | 工程 Agent CLI 执行路由、会话隔离与受控集成 | 采纳:允许 AI 协助实施,配置见 AGENT-EXECUTION.md | AGENT-EXECUTION.md + scripts/agent-exec(validate / admit);证据:governance check + 正负例(`.task/feature-governance-adoption.md`) |
| standards/verification.md | 验证与结论 | 采纳:功能与修复的验收须附实际命令输出 | manual;本地命令见 PROJECT.md「验证」节(CI 仅运行治理检查) |
| standards/module-contracts.md | 模块边界、公开接口与契约测试 | 采纳:`src/` 模块与 Core 契约形状测试(vitest)遵循该契约 | manual |
| workflows/design-verification.md | 功能增量的设计与验收对应 | 采纳:新功能与跨模块变更须写明验收与验证范围 | manual |
| standards/testability.md | 模块测试、mock 边界与可独立验证的设计 | 采纳:单元与契约测试依赖保持最小,真实适配器单独验证 | manual |
| standards/failure-containment.md | 故障影响控制、运行配额与故障注入 | 不适用:本仓为前端 SPA(构建产物由后端内嵌),无自有服务进程、队列或持久化,故障影响控制由后端仓承担 | 不适用 |
## 项目差异
| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 |
|---|---|---|---|
| 无 | 无 | 无 | 无 |
默认做法差异说明理由;硬边界冲突须有明确有效决策,不能用此表自授权。建议不采用无需登记。
## 本次升级或首次接入验证
- 入口:`AGENTS.md`(治理入口,合并 Guidelines 短入口与本仓「Agent 执行与新项目准入」)、`AGENT-EXECUTION.md`、`PROJECT.md`、本文件;相对链接指向 `deps/development-guidelines/...`,目标文件已在固定 SHA 中核对存在。
- 占位符:本文件与 `AGENTS.md` 中的模板占位符已全部替换。
- 版本获取:`governance init --adopt`(dm-server bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)以固定 SHA 检出 `deps/development-guidelines`(2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb)与 `deps/development-manager`(bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)。
- 冲突检查:既有 `AGENTS.md`、`PROJECT.md`、`AGENT-EXECUTION.md` 原样保留;本仓无其他采纳记录(如 `policy/adoption.md`),无需引用。
- 正负例与 Review/CI 证据:见 `.task/feature-governance-adoption.md`「结果与验证」节。
- 回退:删除 `.gitmodules` 中两个治理条目及 `deps/` gitlink,并移除本次生成的治理脚本与 `.gitea/workflows/governance.yml`;`AGENTS.md`、`PROJECT.md` 恢复到采纳前版本。
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
# 共享 Agent 执行器薄入口:转调 scripts/dm 的 agent-exec 子命令(validate / run / apply / admit / cleanup)。
set -euo pipefail
exec "$(dirname "$0")/dm" agent-exec "$@"
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bash
# 共享 dm-server 薄入口:读取 deps/development-manager 的 gitlink 钉住提交,
# 按该提交构建并缓存 dm-server,再 exec 其子命令(agent-exec、task、governance)。执行语义与退出码以该二进制为准。
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
dm="$root/deps/development-manager"
die() { echo "dm: $*" >&2; exit 2; }
# git 钩子会导出仓库本地变量(GIT_DIR、GIT_INDEX_FILE 等),它们会让 `git -C "$dm"` 解析到外层仓库。
# 所有针对 DM 检出的 git 与 go 调用都在清除这些变量后执行;读取外层 gitlink 与最终 exec 仍保留继承环境。
dm_local_env="GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_GRAFT_FILE GIT_NAMESPACE GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT GIT_SHALLOW_FILE GIT_IMPLICIT_WORK_TREE $(git rev-parse --local-env-vars 2>/dev/null || true)"
dm_clean_env() { for v in $dm_local_env; do unset "$v"; done; }
dmgit() ( dm_clean_env; git -C "$dm" "$@" )
# 1. 钉住版本只来自索引中的 gitlink(mode 160000),不读取工作区文件。
entry=$(git -C "$root" ls-files -s -- deps/development-manager 2>/dev/null || true)
[ -n "$entry" ] || die "索引中缺少 deps/development-manager 的 gitlink,无法确定钉住的 DM 版本"
read -r mode pin _stage _path <<<"$entry"
[ "$mode" = 160000 ] || die "deps/development-manager 不是 gitlink(mode=$mode)"
[[ "$pin" =~ ^[0-9a-f]{40}$ ]] || die "gitlink 提交格式无效:$pin"
# 2. 检出必须存在且确为 DM 独立仓库(避免 git -C 误解析到外层仓库),HEAD 必须等于钉住提交。
[ -e "$dm/.git" ] || die "deps/development-manager 未检出;请按钉住提交 $pin 检出该 gitlink"
top=$(dmgit rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$top" ] && [ "$top" = "$(cd "$dm" && pwd -P)" ] \
|| die "deps/development-manager 不是独立检出;请按钉住提交 $pin 检出该 gitlink"
head=$(dmgit rev-parse HEAD 2>/dev/null || true)
[ "$head" = "$pin" ] \
|| die "deps/development-manager HEAD(${head:-未知})与钉住提交 $pin 不一致;请按钉住提交检出"
# 3. 检出工作树必须干净(忽略嵌套 submodule 状态)。
dirty=$(dmgit status --porcelain --ignore-submodules=all) \
|| die "无法读取 deps/development-manager 的工作树状态"
[ -z "$dirty" ] || die "deps/development-manager 存在未提交改动,拒绝运行(请先清理该检出)"
# 4. 二进制按钉住提交缓存;缺失时在缓存目录内临时构建,完成后原子改名,避免留下半成品。
cache="${DM_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/development-manager/dm-server}"
bin="$cache/dm-server-$pin"
if [ ! -x "$bin" ]; then
command -v go >/dev/null 2>&1 || die "缺少 go,无法构建 dm-server($pin)"
mkdir -p "$cache"
cache=$(cd "$cache" && pwd -P)
bin="$cache/dm-server-$pin"
work=$(mktemp -d "$cache/build-$pin.XXXXXX")
if ! (dm_clean_env; cd "$dm" && GOWORK=off go build -trimpath -o "$work/dm-server" ./cmd/dm-server); then
rm -rf "$work"
die "构建 dm-server 失败($pin)"
fi
mv -f "$work/dm-server" "$bin"
rm -rf "$work"
fi
exec "$bin" "$@"
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env bash
# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。
branch=$(git rev-parse --abbrev-ref HEAD)
case "$branch" in
main|master)
echo "pre-commit: 拒绝——集成分支 ${branch} 不得直接提交,走 feature 分支 + PR(collaboration.md)" >&2
exit 1 ;;
esac
echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/<slug>,当前:${branch}" >&2
exit 1
}
root=$(git rev-parse --show-toplevel)
# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子,
# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。
"$root/scripts/agent-exec" admit --repo "$root"
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
# 通用任务入口:转调 scripts/dm 的 task 子命令(triage / start / pr-check)。
set -euo pipefail
exec "$(dirname "$0")/dm" task "$@"
+10 -19
View File
@@ -77,7 +77,7 @@ export function App() {
const h = (e: KeyboardEvent) => {
if (authState === 'ok' && (e.metaKey || e.ctrlKey)) {
const key = e.key.toLowerCase()
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened).catch(() => undefined); return }
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened); return }
if (key === 'b') { e.preventDefault(); openContext(); return }
}
workspaceShortcuts(e)
@@ -91,14 +91,11 @@ export function App() {
try {
const r = await auth.fetch('/api/health')
if (r.status === 401) { setAuthState('login'); return }
// 已持有有效会话 cookie(或密钥)时直接放行。设密门依据 sessionScope 判定
//(2026-10-13 合同):恢复 Key 兑换与 must-change 首登都建立
// set_password_only 受限会话,仅看 mustChangePassword 会漏掉已有密码的
// 恢复会话。改密接口只认会话登录:API key 凭证进改密门是死路(提交必
// 401),key 持有者清 key 回登录页,改走密码登录完成改密。
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
// key 持有者清 key 回登录页,改走密码登录完成改密。
const me = await api.me().catch(() => null)
const gated = me?.sessionScope === 'set_password_only' || me?.mustChangePassword
if (gated) {
if (me?.mustChangePassword) {
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
}
@@ -114,14 +111,10 @@ 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 }
store.refreshMe()
store.refreshProjects()
store.refreshProfiles()
store.refreshPlugins()
}, [authState])
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
@@ -180,11 +173,9 @@ export function App() {
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
}
if (authState === 'mustchange') {
// 受限会话设密成功后服务端销毁全部会话并返回 reloginRequired=true:
// 唯一后继动作是回密码登录页,不得因历史 onDone 误放行进工作台。
return <ChangePasswordGate
owner={mustChangeOwner}
onDone={() => { api.logout().catch(() => undefined); setAuthState('login') }}
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
}
if (authState !== 'ok') {
-38
View File
@@ -1,38 +0,0 @@
// 账号管理动作呈现门控(超级管理员 R3/R4/R8):只决定 UI 暴露哪些入口,
// 服务端授权仍是唯一权威——隐藏的入口经直连 API 调用同样会被拒绝。
interface AccountIdentity { isAdmin?: boolean; isSuperAdmin?: boolean }
type AccountIdentityKind = 'super' | 'admin' | 'user'
export const accountIdentityKind = (account: AccountIdentity): AccountIdentityKind =>
account.isSuperAdmin ? 'super' : account.isAdmin ? 'admin' : 'user'
/** 创建管理员账号与任命/撤销管理员仅对超级管理员开放。 */
export const canAppointAdmins = (me: AccountIdentity | null | undefined) => !!me?.isSuperAdmin
interface AccountActionPolicy {
/** 资料、角色与系统权限维护。 */
maintain: boolean
toggleEnabled: boolean
/** 管理员重置临时密码与恢复提案。 */
resetPassword: boolean
adminChange: 'promote' | 'demote' | null
protectedReason: string
}
export function accountActionPolicy(me: AccountIdentity | null | undefined, target: AccountIdentity): AccountActionPolicy {
if (target.isSuperAdmin) {
const self = !!me?.isSuperAdmin
return {
maintain: self, toggleEnabled: false, resetPassword: false, adminChange: null,
protectedReason: self
? '超级管理员账号不能停用、降级或由管理员重置密码;本人密码请在「设置 → 账号」修改'
: '超级管理员账号受保护:普通管理员不能编辑、停用、降级或重置其密码',
}
}
return {
maintain: true, toggleEnabled: true, resetPassword: true,
adminChange: canAppointAdmins(me) ? (target.isAdmin ? 'demote' : 'promote') : null,
protectedReason: '',
}
}
+17 -151
View File
@@ -1,11 +1,10 @@
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
import { auth } from './auth'
import { commandRequestBody, rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard, SurfaceMeta, SurfaceSnapshot, WorkCardAction,
ActivityCard, SurfaceMeta, SurfaceSnapshot,
} from './contracts'
const BASE = ''
@@ -14,103 +13,20 @@ const BASE = ''
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
export interface RolePermissionRecord { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
export interface RoleRecord {
roleId: string; name: string; description: string; enabled: boolean; roleVersion: number
bindingCount: number; permissions: RolePermissionRecord[]
}
interface RoleListPage { roles: RoleRecord[]; nextCursor?: string }
export interface UserRoleBinding {
bindingId: string; userId: string; roleId: string; active: boolean; bindingVersion: number
assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number
}
export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord }
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
* current_required 必须校验当前密码。 */
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
export interface AccountRecord {
userId: string; owner: string; profile: AccountProfile; profileVersion: number
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; isSuperAdmin?: boolean; passwordSet?: boolean
initializationState?: string; initializationGeneration?: number; createdAt?: string
}
/** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */
export interface ManagedAccountRecord extends AccountRecord {
enabled: boolean; stateVersion: number; isAdmin: boolean; isSuperAdmin: boolean; passwordSet: boolean
initializationState: string; initializationGeneration: number
}
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
export interface AdminIssuedOneTimePassword {
owner: string
id: string
oneTimePassword: string
mustChangePassword: boolean
}
export interface ExecEnvView {
configured: boolean
root?: string
python?: string
base?: string
layers?: string[]
requests?: { id: string; conversationId: string; owner: string; spec: string; reason: string; status: string; layerId: string; detail: string; approver: string }[]
bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[]
}
async function checked(res: Response): Promise<Response> {
async function json<T>(res: Response): Promise<T> {
if (!res.ok) {
const text = await res.text()
throw new Error(`${res.status}: ${text}`)
}
return res
}
async function json<T>(res: Response): Promise<T> {
await checked(res)
return res.json() as Promise<T>
}
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 } = {}) => {
const params = new URLSearchParams()
if (request.query) params.set('query', request.query)
if (request.mode) params.set('mode', request.mode)
if (request.status) params.set('status', request.status)
if (request.action) params.set('action', request.action)
if (request.cursor) params.set('cursor', request.cursor)
if (request.limit) params.set('limit', String(request.limit))
const query = params.toString()
return auth.fetch(`${BASE}/api/admin/roles${query ? `?${query}` : ''}`).then(r => json<RoleListPage>(r))
},
getRole: (roleId: string) => auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`).then(r => json<RoleRecord>(r)),
createRole: (name: string, description: string) => auth.fetch(`${BASE}/api/admin/roles`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description }),
}).then(r => json<RoleRecord>(r)),
patchRole: (roleId: string, expectedRoleVersion: number, patch: { name?: string; description?: string; enabled?: boolean }) =>
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(r)),
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<{ receiptId: string; roleVersion: number; authorityEpoch: number }>(r)),
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roleId, expectedStateVersion, idempotencyKey }) }).then(r => json<UserRoleBinding>(r)),
getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)),
replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<{ receiptId: string; authorityEpoch: number }>(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)),
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
@@ -126,54 +42,20 @@ 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; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
auth.beginSession()
return me
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
}),
logout: () => {
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
auth.clear()
return request
},
logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }),
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
}),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
// isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。
createAdminUser: (owner: string, name: string, password?: string, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST' }).then(r => json<AdminIssuedOneTimePassword>(r)),
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
if (request.cursor) params.set('cursor', request.cursor)
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
},
createManagedUser: (owner: string, profile: Partial<AccountProfile>, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile, ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<ManagedAccountRecord>(r)),
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<ManagedAccountRecord>(r)),
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<ManagedAccountRecord>(r)),
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
// 任命/撤销管理员:仅超级管理员(R3);超级管理员本身不可降级(R5)。
adminSetUserAdmin: (userId: string, expectedStateVersion: number, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, isAdmin }) }).then(r => json<ManagedAccountRecord>(r)),
// ---- 本人:资料(须完整密码会话)----
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
adminExecEnvGC: () => auth.fetch(`${BASE}/api/admin/exec-env/gc`, { method: 'POST' }).then(r => json<{ removed: string[] | null }>(r)),
adminExecEnvWheel: (file: File, conversationId: string, reason: string) => {
const body = new FormData()
body.set('wheel', file)
body.set('conversationId', conversationId)
body.set('reason', reason)
return auth.fetch(`${BASE}/api/admin/exec-env/wheels`, { method: 'POST', body }).then(r => json<{ file: string; outcome: { status: string; requestId?: string; detail?: string } }>(r))
},
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
@@ -181,13 +63,8 @@ export const api = {
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
.then(r => json<{ events: StreamEvent[] }>(r)),
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string, taskRef?: { cardId: string; resultVersion: number }) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId,
...(taskRef ? { taskRef: { cardId: taskRef.cardId, resultVersion: taskRef.resultVersion } } : {}) }) })
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId }) })
.then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }>
@@ -199,12 +76,10 @@ export const api = {
auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) })
.then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)),
taskSource: (cardId:string,sourceId:string) => auth.fetch(`${BASE}/api/cards/${encodeURIComponent(cardId)}/sources/${encodeURIComponent(sourceId)}`).then(r=>json<{source:import('./contracts').TaskSourceRef;versionState:string;artifact?:Artifact}>(r)),
listCardsSnapshot: (conversationId: string): Promise<{cards: ActivityCard[]; replaySeq?: number}> => auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{cards: ActivityCard[]; replaySeq?: number}>(r)),
listCards: (conversationId: string) =>
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) =>
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(typeof payload === 'object' ? payload : { freeText: payload }) })
cardAction: (cardId: string, actionId: string, freeText?: string) =>
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ freeText }) })
.then(r => json<{ card: ActivityCard; receipt?: Message }>(r)),
cardPin: (cardId: string, pinned: boolean) =>
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
@@ -241,11 +116,7 @@ export const api = {
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r =>
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
// P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。
getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r =>
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown> }>(r)),
putSystemSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
@@ -270,15 +141,10 @@ export const api = {
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}): Promise<T> => {
const error = rdmProjectCommandError(project, id)
if (error) throw new Error(error)
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
},
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}, source?: CommandSource): Promise<T> =>
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(commandRequestBody(params, source)),
body: JSON.stringify({ params }),
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
.then(out => {
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
+3 -14
View File
@@ -17,22 +17,11 @@ function pickStorage(): KV {
}
const storage = pickStorage()
const identityListeners = new Set<() => void>()
function identityChanged() { identityListeners.forEach(listener => listener()) }
export const auth = {
apiKey(): string { return storage.getItem(KEY) ?? '' },
setKey(key: string) {
const next = key.trim()
if (next === this.apiKey()) return
storage.setItem(KEY, next)
identityChanged()
},
clear() { storage.removeItem(KEY); identityChanged() },
onIdentityChange(listener: () => void) {
identityListeners.add(listener)
return () => { identityListeners.delete(listener) }
},
setKey(key: string) { storage.setItem(KEY, key.trim()) },
clear() { storage.removeItem(KEY) },
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
beginSession() { this.clear() },
@@ -44,7 +33,7 @@ export const auth = {
headers.set('Authorization', `Bearer ${key}`)
return fetch(input, { ...init, headers })
},
// 退出登录:立即清客户端投影并回登录门;调用者先经 api.logout 注销服务端会话。
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
logout() {
this.clear()
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
+12 -112
View File
@@ -3,11 +3,8 @@
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
min-height: 64px;
padding: 12px 24px;
background: var(--bg-panel);
padding: 12px 20px;
}
.work-companion-chat .chat-title { font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.025em; }
.work-companion-chat .chat-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-companion-chat .chat-header > .mk-col-root { position: relative; }
.work-companion-chat .chat-header > .mk-col-root > .mk-col {
@@ -39,71 +36,9 @@
.companion-setting select { max-width: 100%; padding: 4px 8px; background: var(--bg-panel); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-caption); }
.companion-setting .session-note { flex: 1 1 100%; min-width: 0; width: 100%; }
.companion-setting-note { flex: 1 1 100%; margin: 0; color: var(--text-dim); font-size: var(--fs-ui-sm); line-height: 1.6; }
.work-companion-chat .chat-body {
display: flex;
flex-direction: column;
align-items: center;
padding: 36px clamp(20px, 6vw, 96px) 48px;
scroll-padding-block: 36px;
}
.work-companion-chat .chat-body > * {
width: min(100%, 840px);
margin-left: auto;
margin-right: auto;
}
.work-companion-chat .chat-body > .msg { margin-bottom: 32px; }
.work-companion-chat .chat-body > .msg.user {
width: min(80%, 680px);
margin-right: max(0px, calc((100% - 840px) / 2));
margin-left: auto;
}
.work-companion-chat .chat-body > .msg.assistant {
padding: 20px 24px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--bg-raised);
box-shadow: var(--shadow-md);
}
.work-companion-chat .msg-role {
margin-bottom: 10px;
color: var(--text-dim);
font-size: var(--fs-ui-caption);
font-weight: 600;
}
.work-companion-chat .msg.user .msg-role { justify-content: flex-end; }
.message-timestamp { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 400; white-space: nowrap; }
.work-companion-chat .msg.assistant .message-timestamp { margin-left: auto; }
.work-result-notice, .composer-work-reference { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; box-sizing: border-box; padding: 8px 12px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-raised); color: var(--text-dim); font-size: var(--fs-ui-caption); text-align: left; }
.work-result-notice { cursor: pointer; }
.work-result-notice:hover { background: var(--bg-hover); color: var(--text); }
.work-result-notice > span:last-child { margin-left: auto; color: var(--accent-text); }
.composer-work-reference > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.work-execution-record { min-width: 0; padding: 16px; color: var(--text); background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); }
.work-record-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.work-record-heading strong { min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-ui-base); }
.work-record-heading .btn { flex-shrink: 0; font-size: var(--fs-ui-caption); }
.work-execution-record .tl { margin-top: 16px; }
.work-execution-record .part-text { overflow-wrap: anywhere; }
.work-companion-chat .msg.assistant .role-mark {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
}
.work-companion-chat .msg.assistant .part-text { color: var(--text); font-size: var(--fs-ui-base); line-height: 1.8; }
.work-companion-chat .input-bar {
padding: 12px clamp(20px, 6vw, 96px) 20px;
border-top: 1px solid var(--border-soft);
background: var(--bg-panel);
}
.work-companion-chat .input-bar > * { width: min(100%, 840px); margin-left: auto; margin-right: auto; }
.work-companion-chat .input-row {
flex-direction: column;
align-items: stretch;
padding: 14px;
border-color: var(--border-strong);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
}
.work-companion-chat .chat-body { padding: 24px clamp(16px, 5vw, 64px); }
.work-companion-chat .input-bar { padding: 8px clamp(16px, 5vw, 64px) 16px; }
.work-companion-chat .input-row { flex-direction: column; align-items: stretch; padding: 12px; box-shadow: none; }
.work-companion-chat .input-row textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 4px; line-height: 1.6; font-size: var(--fs-ui-base); }
.companion-compose-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
.companion-compose-spacer { flex: 1; }
@@ -112,7 +47,7 @@
.companion-add-material:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.companion-file-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); }
.companion-input-hint { font-size: var(--fs-ui-sm); color: var(--text-dim); }
.companion-operation-trigger { margin-bottom: 8px; color: var(--text-dim); }
.companion-operation-trigger { margin-bottom: 8px; }
.companion-operation-body { padding: 12px; margin-bottom: 12px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); }
.companion-operation-body .companion-setting { margin-bottom: 8px; }
.work-companion-chat .input-status { flex-wrap: wrap; gap: 4px 8px; padding: 8px 0; line-height: 1.5; }
@@ -122,63 +57,28 @@
.companion-remove-material { display: inline-flex; align-items: center; justify-content: center; padding: 4px; color: var(--text-dim); background: transparent; border: 0; border-radius: var(--radius-xs); cursor: pointer; }
.companion-remove-material:hover { background: var(--bg-hover); color: var(--text); }
.work-companion-chat .attach-chip { max-width: 100%; overflow-wrap: anywhere; }
.companion-empty { min-height: 320px; height: 100%; text-align: center; line-height: 1.7; }
.companion-empty .es-mark { width: 52px; height: 52px; margin-bottom: 8px; background: var(--bg-panel); box-shadow: var(--shadow-md); }
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.02em; }
.companion-empty { min-height: 240px; height: 100%; text-align: center; line-height: 1.7; }
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); }
.companion-examples { display: grid; gap: 8px; width: min(100%, 32rem); margin-top: 12px; }
.companion-example { padding: 14px 16px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
.companion-example:hover { color: var(--text); background: var(--bg-raised); border-color: var(--border-strong); }
.companion-example { padding: 12px 16px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
.companion-example:hover { color: var(--text); background: var(--bg-hover); }
.conversation-work { margin: 24px 0 16px; min-width: 0; }
.conversation-work-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 12px; color: var(--text-dim); font-size: var(--fs-ui-caption); }
.conversation-work-heading > span:first-child { color: var(--text); font-size: var(--fs-ui-lg); font-weight: 650; }
.conversation-work-heading > span:first-child { color: var(--text); font-weight: 600; }
.conversation-work-current, .conversation-work-history { display: grid; gap: 12px; min-width: 0; }
.conversation-work-more { padding: 12px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); }
.conversation-work .acard { margin: 0; }
.conversation-action-anchor { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; width: 100%; margin-top: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); color: var(--text); text-align: left; font-size: var(--fs-ui-caption); cursor: pointer; }
.conversation-action-anchor > span:last-child { color: var(--accent-text); }
.conversation-action-anchor:hover { background: var(--bg-hover); }
.work-companion-chat .live-bar { flex-wrap: wrap; }
.work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; }
@media (max-width: 540px) {
.work-companion-chat .chat-header { padding: 12px 16px; gap: 8px; }
.work-companion-chat .chat-body { padding: 24px 16px 32px; }
.work-companion-chat .chat-body > .msg.user { width: 92%; margin-right: 0; }
.work-companion-chat .input-bar { padding: 10px 12px 14px; }
.companion-input-hint { display: none; }
.companion-empty { min-height: 200px; }
.work-companion-chat .hitl-choices { grid-template-columns: minmax(0, 1fr); }
}
.work-companion-chat .msg.user .bubble {
padding: 14px 18px;
background: var(--primary);
border: 1px solid var(--primary);
border-radius: var(--radius-lg) var(--radius-lg) var(--radius-xs) var(--radius-lg);
color: var(--primary-foreground);
line-height: 1.7;
box-shadow: var(--shadow-md);
}
.work-companion-chat .process-collapse {
margin: 12px 0 16px;
border-color: var(--border);
background: var(--bg-panel);
}
.work-companion-chat .process-collapse .process-collapse-summary { padding: 9px 14px; color: var(--text-dim); }
.work-companion-chat .process-collapse .process-collapse-summary:hover { background: var(--bg-hover); color: var(--text); }
.work-companion-chat .process-collapse .body { padding: 8px 14px 14px; border-top: 1px solid var(--border-soft); }
.work-companion-chat .msg.user .bubble { background: var(--bg-active); }
.companion-message-copy { padding: 4px 8px; border: 0; background: transparent; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; }
.companion-message-copy:hover { background: var(--bg-hover); color: var(--text); }
.companion-message-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 0;
opacity: 0;
}
.work-companion-chat .msg:hover .companion-message-actions,
.work-companion-chat .msg:focus-within .companion-message-actions { opacity: 1; }
.companion-message-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; }
.work-companion-chat .msg-tools { gap: 4px; }
@media (hover: none) {
.companion-message-actions { opacity: 1; }
}
+22 -117
View File
@@ -9,20 +9,6 @@
.companion-app .left-nav,
.companion-app .main,
.companion-app .right-panel { min-height: 0; max-width: 100%; overflow: hidden; }
.companion-app .left-nav,
.companion-app .companion-topbar {
--text: var(--workbench-chrome-text);
--text-dim: var(--workbench-chrome-dim);
--text-faint: var(--workbench-chrome-faint);
--bg-hover: var(--workbench-chrome-hover);
--bg-active: var(--workbench-chrome-active);
--border: var(--workbench-chrome-active);
--border-soft: var(--workbench-chrome-hover);
--border-strong: var(--workbench-chrome-dim);
background: var(--workbench-chrome);
color: var(--workbench-chrome-text);
border-color: var(--workbench-chrome-hover);
}
.companion-app .left-nav { transition: none; }
.companion-app .left-nav[hidden],
.companion-app .right-panel[hidden],
@@ -30,58 +16,12 @@
.companion-app .icon-only[hidden] { display: none !important; }
.shell-context-content { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
.companion-app .companion-topbar {
height: auto;
min-height: 52px;
gap: 12px;
padding: 8px 16px;
box-shadow: var(--shadow-md);
}
.companion-app .companion-topbar { height: auto; min-height: 44px; gap: 8px; padding: 4px 12px; }
.topbar-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
.companion-topbar .topbar-name { font-size: var(--fs-ui-lg); font-weight: 650; letter-spacing: -0.02em; white-space: nowrap; }
.companion-app .right-panel .tabs {
min-height: 52px;
align-items: flex-end;
gap: 16px;
padding: 0 20px;
background: var(--bg-panel);
}
.companion-app .right-panel .tab {
min-height: 52px;
padding: 0 2px;
color: var(--text-dim);
font-size: var(--fs-ui-base);
font-weight: 600;
}
.companion-app .right-panel .tab:hover { color: var(--text); }
.companion-app .right-panel .tab-body { padding: 20px; }
.companion-app .right-panel .inbox-zone-label { margin: 20px 0 8px; color: var(--text); font-size: var(--fs-ui-caption); font-weight: 650; }
.companion-app .right-panel .acard { margin: 12px 0; border-color: var(--border); padding: 18px; }
.companion-app .right-panel .acard.status-active {
border-left: 3px solid var(--work-focus-border);
background: var(--work-focus-surface);
box-shadow: var(--shadow-md);
}
.companion-app .right-panel .acard-title { color: var(--text); font-size: var(--fs-ui-lg); line-height: 1.4; }
.companion-app .right-panel .acard-status {
display: inline-flex;
width: fit-content;
margin-top: 8px;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--bg-active);
color: var(--text);
font-weight: 600;
}
.companion-app .right-panel .acard-body { margin-top: 16px; }
.companion-app .right-panel .acard-summary-row { grid-template-columns: minmax(64px, auto) minmax(0, 1fr); gap: 12px; margin: 12px 0; }
.companion-app .right-panel .acard-summary-row > span,
.companion-app .right-panel .acard-summary-label { color: var(--text-dim); font-weight: 600; }
.companion-app .right-panel .acard-summary-row p,
.companion-app .right-panel .acard-findings li { color: var(--text); font-size: var(--fs-ui-base); }
.companion-topbar .topbar-name { font-size: var(--fs-ui-caption); white-space: nowrap; }
.companion-topbar .brand-mark {
background: var(--workbench-chrome-text);
color: var(--workbench-chrome);
background: var(--primary-sidebar);
color: var(--primary-sidebar-foreground);
box-shadow: none;
border-radius: var(--radius-sm) !important;
}
@@ -97,52 +37,39 @@
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--workbench-chrome-dim);
color: var(--text-sidebar-dim);
font-size: var(--fs-ui-caption);
cursor: pointer;
white-space: nowrap;
}
.shell-nav-button:hover,
.shell-return:hover { background: var(--workbench-chrome-hover); color: var(--workbench-chrome-text); }
.shell-nav-button[aria-pressed='true'] { background: var(--workbench-chrome-active); color: var(--workbench-chrome-text); }
.shell-return:hover { background: var(--bg-hover); color: var(--text-sidebar); }
.shell-nav-button[aria-pressed='true'] { background: var(--bg-active); color: var(--text-sidebar); }
.shell-nav-button svg,
.shell-return svg { flex: none; }
.shell-back-icon { transform: rotate(180deg); }
.shell-pending {
padding: 0 4px;
border-radius: var(--radius-xs);
background: var(--workbench-chrome-active);
color: var(--workbench-chrome-text);
background: var(--bg-active);
color: var(--text-sidebar);
font-size: var(--fs-ui-xs);
font-variant-numeric: tabular-nums;
}
.companion-app .tsb-brand { gap: 8px; padding: 16px 14px 12px; border-bottom: 1px solid var(--border-soft); }
.companion-app .tsb-projrow { min-height: 40px; height: auto; border-color: transparent; background: none; padding: 6px 10px; }
.companion-app .tsb-brand { gap: 4px; padding: 16px 12px 8px; }
.companion-app .tsb-projrow { min-height: 36px; height: auto; border-color: transparent; background: none; padding: 4px 8px; }
.companion-app .tsb-projrow:hover { background: var(--bg-hover); }
.companion-app .tsb-projrow-name { flex: 1; font-size: var(--fs-ui-base); font-weight: 650; }
.companion-app .tsb-projrow-name { flex: 1; }
.companion-app .tsb-projrow-stage { max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); border-radius: var(--radius-sm); }
.companion-app .tsb-iconbtn { width: 32px; height: 32px; flex: none; }
.companion-app .tsb-actions { padding: 12px 14px; gap: 10px; }
.companion-app .tsb-newtask {
min-height: 40px;
height: auto;
background: var(--workbench-chrome-text);
color: var(--workbench-chrome);
box-shadow: none;
transition: none;
}
.companion-app .tsb-newtask:hover { background: var(--workbench-chrome-text); }
.companion-app .tsb-actions { padding: 8px 12px 12px; gap: 12px; }
.companion-app .tsb-newtask { min-height: 40px; height: auto; box-shadow: none; transition: none; }
.companion-app .tsb-newtask-main { justify-content: center; padding: 8px 12px; }
.companion-app .tsb-newtask-caret { width: 36px; flex: none; border-left-color: var(--workbench-chrome-inverse-border); }
.companion-app .tsb-newtask-caret:hover { background: var(--workbench-chrome-inverse-hover); }
.companion-app .tsb-search { min-height: 38px; height: auto; padding: 8px 12px; background: var(--workbench-chrome-hover); border-color: var(--workbench-chrome-hover); }
.companion-app .tsb-search:focus-within { background: var(--workbench-chrome-active); border-color: var(--workbench-chrome-dim); }
.companion-app .tsb-orgrow { padding: 4px 16px 8px; }
.companion-app .tsb-seclabel { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 600; }
.companion-app .tsb-scroll { padding: 0 8px 12px; }
.companion-app .tsb-task { min-height: 42px; height: auto; gap: 6px; padding: 0 10px; transition: none; }
.companion-app .tsb-task.active { background: var(--bg-active); color: var(--text); }
.companion-app .tsb-newtask-caret { width: 36px; flex: none; }
.companion-app .tsb-search { min-height: 36px; height: auto; padding: 8px 12px; }
.companion-app .tsb-orgrow { padding-bottom: 4px; }
.companion-app .tsb-task { min-height: 40px; height: auto; gap: 4px; transition: none; }
.companion-app .tsb-task-open {
display: flex;
align-items: center;
@@ -158,7 +85,6 @@
cursor: pointer;
}
.companion-app .tsb-task-open > svg { flex: none; }
.companion-app .tsb-task-t { font-size: var(--fs-ui-base); }
.companion-app .tsb-task-tm { color: var(--text-dim); }
.companion-app .tsb-task:focus-within .tsb-row-acts { display: inline-flex; }
.companion-app .tsb-task:focus-within .tsb-task-tm { display: none; }
@@ -177,32 +103,11 @@
cursor: pointer;
}
.companion-app .tsb-row-menu:hover { background: var(--bg-hover); color: var(--text); }
.companion-app .tsb-bottom { padding: 10px 12px; gap: 4px; background: var(--workbench-chrome-hover); }
.companion-app .tsb-tool { min-height: 38px; padding: 7px 10px; }
.companion-app .tsb-bottom { padding: 8px 12px; gap: 4px; }
.companion-app .tsb-tool { min-height: 36px; }
.companion-app .tsb-avatar { background: var(--bg-active); color: var(--text); box-shadow: none; border-radius: var(--radius-full); }
.companion-app .tsb-user-sub { color: var(--text-dim); }
.companion-app .tsb-footer { padding: 10px 12px; }
.companion-app .tsb-user { min-height: 46px; height: auto; }
.companion-app .right-panel .inbox-scope {
gap: 4px;
margin: 14px 20px 0;
padding: 4px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--bg);
}
.companion-app .right-panel .inbox-scope button {
flex: 1;
min-width: 0;
border: none;
border-radius: var(--radius-sm);
padding: 5px 10px;
opacity: 1;
color: var(--text-dim);
font-size: var(--fs-ui-sm);
white-space: nowrap;
}
.companion-app .right-panel .inbox-scope button.active { background: var(--bg-raised); box-shadow: var(--shadow-md); color: var(--text); }
.companion-app .tsb-user { min-height: 44px; height: auto; }
.companion-app .tsb-menu {
min-width: min(216px, calc(100vw - 16px));
max-width: min(360px, calc(100vw - 16px));
@@ -211,7 +116,7 @@
}
.companion-app .tsb-mi { overflow-wrap: anywhere; }
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px 12px; }
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px; }
.companion-app.compact .shell-navigation { width: 100%; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.companion-app.compact .shell-nav-button { min-width: 0; min-height: 40px; flex-wrap: wrap; gap: 0 4px; padding: 4px; }
.companion-app.compact .shell-nav-button > svg { display: none; }
-282
View File
@@ -1,282 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import {
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView,
} from '../api'
import { useStore } from '../store'
import { Collapsible, Icon } from '@ui'
import { hasSystemAction } from './RolePermissionsPanel'
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
]
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
account.passwordSet ? '已设置密码' : '待设置密码'
function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) {
return <div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`${prefix}-${field.key}`}>{field.label}(可空)</label>
<input id={`${prefix}-${field.key}`} className="lg-input" value={profile[field.key]}
onChange={event => onChange({ ...profile, [field.key]: event.target.value })} />
</div>)}</div>
}
export function IdentityChip({ account }: { account: IdentityFlags }) {
const kind = accountIdentityKind(account)
if (kind === 'super') return <span className="ad-chip ad-chip-super">超级管理员</span>
if (kind === 'admin') return <span className="ad-chip ad-chip-admin">管理员</span>
return <span className="ad-chip">普通用户</span>
}
export function CreateAdminOption({ me, checked, disabled, onChange }: { me: IdentityFlags | null; checked: boolean; disabled: boolean; onChange: (value: boolean) => void }) {
if (!canAppointAdmins(me)) return <p className="ad-hint">仅超级管理员可创建或任命管理员;此处创建普通账号</p>
return <label className="ad-check"><input type="checkbox" checked={checked} disabled={disabled}
onChange={event => onChange(event.target.checked)} />创建为管理员账号</label>
}
export function AdminAppointmentButton({ me, account, busy, onChange }: { me: IdentityFlags | null; account: IdentityFlags; busy: boolean; onChange: (isAdmin: boolean) => void }) {
const change = accountActionPolicy(me, account).adminChange
if (!change) return null
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
}
export function AccountUsersPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
const canViewRoles = allowed('system.roles.view')
const canAssignRoles = canViewRoles && allowed('system.roles.assign')
const canViewGrants = allowed('system.permissions.view')
const canManageGrants = canViewGrants && allowed('system.permissions.manage')
const [accounts, setAccounts] = useState<ManagedAccountRecord[] | null>(null)
const [selected, setSelected] = useState<ManagedAccountRecord | null>(null)
const [roleView, setRoleView] = useState<UserRoleView | null>(null)
const [roles, setRoles] = useState<RoleRecord[]>([])
const [roleQuery, setRoleQuery] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [roleLoading, setRoleLoading] = useState(false)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [grants, setGrants] = useState<string[]>([])
const [authorityEpoch, setAuthorityEpoch] = useState(0)
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
const [newOwner, setNewOwner] = useState('')
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
const [newIsAdmin, setNewIsAdmin] = useState(false)
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [field, setField] = useState('default')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [cursor, setCursor] = useState('')
const [trail, setTrail] = useState<string[]>([])
const [nextCursor, setNextCursor] = useState('')
const [selectedRoleId, setSelectedRoleId] = useState('')
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const sequence = useRef(0)
const roleSequence = useRef(0)
const search = async (pageCursor = cursor, afterWrite = false) => {
const request = ++sequence.current
try {
const page = await api.searchAdminUsers({ query: query.trim(), mode, status,
fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 })
if (request !== sequence.current) return
setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '')
} catch (error) {
if (request === sequence.current) setErr(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error))
}
}
const resetPaging = () => { setCursor(''); setTrail([]) }
useEffect(() => {
const timer = window.setTimeout(() => { void search() }, 180)
return () => { window.clearTimeout(timer); sequence.current++ }
}, [query, mode, field, status, cursor])
const open = async (account: ManagedAccountRecord) => {
const request = ++sequence.current
roleSequence.current++
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(false)
setSelected(null); setRoleView(null); setErr(''); setNotice('')
const detailResult = await Promise.allSettled([api.adminGetUser(account.userId)])
if (request !== sequence.current) return
if (detailResult[0].status === 'rejected') { setErr(String(detailResult[0].reason)); return }
const detail = detailResult[0].value
setSelected(detail); setDraft(detail.profile)
const [roleResult, catalogResult, grantsResult] = await Promise.allSettled([
canViewRoles ? api.getUserRole(account.userId) : Promise.resolve(null),
canViewGrants ? api.permissionCatalog() : Promise.resolve(null),
canViewGrants ? api.getSystemGrants(account.userId) : Promise.resolve(null),
])
if (request !== sequence.current) return
if (roleResult.status === 'fulfilled' && roleResult.value) {
const currentRole = roleResult.value as UserRoleView
setRoleView(currentRole); setSelectedRoleId(currentRole.role?.roleId ?? '')
} else { setRoleView(null); setSelectedRoleId('') }
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
else setCatalog([])
if (grantsResult.status === 'fulfilled' && grantsResult.value) {
setGrants(grantsResult.value.actions ?? []); setAuthorityEpoch(grantsResult.value.authorityEpoch)
} else { setGrants([]); setAuthorityEpoch(0) }
}
useEffect(() => {
if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
const request = ++roleSequence.current
setRoleLoading(true); setNextRoleCursor('')
const timer = window.setTimeout(() => {
void api.listRoles({ query: roleQuery.trim(), mode: 'fuzzy', status: 'enabled', cursor: roleCursor, limit: 50 })
.then(page => {
if (request !== roleSequence.current) return
setRoles(page.roles ?? []); setNextRoleCursor(page.nextCursor ?? ''); setRoleLoading(false)
})
.catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } })
}, 180)
return () => { window.clearTimeout(timer) }
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
// 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。
const refreshFirst = async () => {
resetPaging(); setNextCursor('')
await search('', true)
}
const create = async () => {
if (!newOwner.trim()) return
setBusy(true); setErr(''); setNotice('')
try {
const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin)
setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false)
await open(account)
setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`)
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
finally { setBusy(false) }
}
const saveProfile = async () => {
if (!selected) return
const patch: Partial<AccountProfile> = {}
for (const item of fields) if (draft[item.key] !== selected.profile[item.key]) patch[item.key] = draft[item.key]
if (!Object.keys(patch).length) { setNotice('资料没有变化'); return }
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminPatchProfile(selected.userId, selected.profileVersion, patch)
setSelected(updated); setDraft(updated.profile); await refreshFirst(); setNotice('用户资料已保存')
} catch (error) { setErr(String(error).includes('409') ? '资料版本冲突,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const assignRole = async () => {
if (!selected || !selectedRoleId) return
setBusy(true); setErr(''); setNotice('')
try {
await api.assignUserRole(selected.userId, selectedRoleId, selected.stateVersion, crypto.randomUUID())
await open(selected); await refreshFirst(); setNotice('用户角色已替换')
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const saveGrants = async () => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
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)) }
finally { setBusy(false) }
}
const toggleState = async () => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminSetUserEnabled(selected.userId, selected.stateVersion, !selected.enabled)
setSelected(updated); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话、Key 与进行中的恢复提案已撤销')
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const setAdmin = async (isAdmin: boolean) => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminSetUserAdmin(selected.userId, selected.stateVersion, isAdmin)
setSelected(updated); await refreshFirst(); setNotice(isAdmin ? '已任命为管理员' : '已撤销管理员')
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const policy = selected ? accountActionPolicy(me, selected) : null
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
return <div>
{allowed('system.users.create') && <div className="ad-create">
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
<CreateAdminOption me={me} checked={newIsAdmin} disabled={busy} onChange={setNewIsAdmin} />
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
</Collapsible>
</div>}
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{selected && policy ? <div className="rp-editor">
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
</Collapsible>}
<div className="ad-row">
{canAssignRoles && policy.maintain && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
{allowed('system.users.disable') && policy.toggleEnabled && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
<AdminAppointmentButton me={me} account={selected} busy={busy} onChange={setAdmin} />
</div>
{canAssignRoles && policy.maintain && <div className="ad-row">
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
</div>}
{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>
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
</Collapsible>}
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
</div> : <>
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索用户" placeholder="搜索用户资料" value={query} onChange={event => { setQuery(event.target.value); resetPaging() }} />
<select className="lg-input" aria-label="用户匹配方式" value={mode} onChange={event => { setMode(event.target.value as typeof mode); resetPaging() }}><option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option></select>
<select className="lg-input" aria-label="用户搜索字段" value={field} onChange={event => { setField(event.target.value); resetPaging() }}>
<option value="default">账号/姓名/ID</option><option value="owner">登录名</option><option value="name">姓名</option><option value="userId">userId</option>
<option value="phone">电话</option><option value="email">邮箱</option><option value="staffNo">工号</option><option value="department">部门</option><option value="notes">备注</option>
</select>
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); resetPaging() }}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
</div>
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
<td>{account.profile.staffNo || '—'}</td><td><IdentityChip account={account} /></td><td>{account.enabled ? '有效' : '停用'}</td>
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
{accounts !== null && accounts.length === 0 && <tr><td colSpan={7} className="ad-dim">没有匹配用户</td></tr>}
{accounts === null && <tr><td colSpan={7} className="ad-dim">加载中…</td></tr>}</tbody></table>
<div className="ad-row"><button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页</button>
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页</button></div>
</>}
</div>
}
+6 -6
View File
@@ -4,18 +4,18 @@ import { useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon } from '@ui'
import { humanChangePasswordError } from './loginErrors'
import { humanChangePasswordError } from './LoginPage'
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal ad-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
<div className="modal admin-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
<div className="modal-header">
<b>{title}</b>
<span className="spacer" />
<button className="ad-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
<button className="admin-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
</div>
<div className="ad-body">{children}</div>
<div className="admin-body">{children}</div>
</div>
</div>
)
@@ -51,8 +51,8 @@ export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
return (
<Shell title="修改密码" onClose={onClose}>
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
<form className="ad-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
{ok ? <div className="admin-notice">密码已更新,其他设备的会话已注销。</div> : (
<form className="admin-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
<label className="lg-label" htmlFor="changepw-username">用户名</label>
<input id="changepw-username" className="lg-input" value={me?.owner ?? ''} readOnly autoComplete="username" />
<label className="lg-label" htmlFor="changepw-current">当前密码</label>
+13 -39
View File
@@ -7,11 +7,9 @@
import { useState } from 'react'
import { store, useStore } from '../store'
import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
import { ExecEnvPanel } from './ExecEnvPanel'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { AccountUsersPanel } from './AccountUsersPanel'
import { RolePermissionsPanel, roleDraftLeaveGuard, hasSystemAction } from './RolePermissionsPanel'
import { RdmRolesPanel } from './adminRdmSections'
import type { AdminZoneSection } from '../api'
interface AdminSectionDef {
@@ -19,8 +17,6 @@ interface AdminSectionDef {
label: string
icon: IconName
desc: string
requiredAction?: string
rootOnly?: boolean
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
render: () => React.ReactNode
}
@@ -30,30 +26,13 @@ function ProfilesPanelWrapper() {
return <ProfilesSection profiles={profiles} />
}
/** 账号目录(按系统权限开放)+ 登录凭据管理(根管理员专属)。 */
function UsersSection() {
const isAdmin = useStore(s => !!s.me?.isAdmin)
return <>
<AccountUsersPanel />
{isAdmin && <>
<div className="az-section-head" style={{ marginTop: 18 }}>
<h3>登录凭据</h3>
<p>建号并发放初始密码、生成一次性登录密码(仅根管理员)</p>
</div>
<UsersPanel />
</>}
</>
}
const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员可发放登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
{ key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => <RolePermissionsPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => <ProfilesPanelWrapper /> },
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', rootOnly: true, render: () => <SystemSettingsPanel /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', rootOnly: true, render: () => <PluginsSection /> },
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', rootOnly: true, render: () => <ExecEnvPanel /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', rootOnly: true, render: () => <PluginWorkspacesPanel /> },
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
]
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
@@ -81,15 +60,14 @@ function DeclaredSection({ section }: { section: AdminZoneSection }) {
export function AdminZone() {
const me = useStore(s => s.me)
const declared = useStore(s => s.adminSections)
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
const [sectionKey, setSectionKey] = useState('users')
const isAdmin = !!me?.isAdmin
const systemActions = me?.systemActions ?? []
const canOpenSystem = isAdmin || systemActions.length > 0
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
if (!canOpenSystem && declared.length === 0) return null
if (!isAdmin && declared.length === 0) return null
const sections: AdminSectionDef[] = [
...SECTIONS.filter(section => section.rootOnly ? isAdmin : isAdmin || hasSystemAction(systemActions, section.requiredAction ?? '')),
...(isAdmin ? SECTIONS.filter(s => s.key !== 'rdm-roles' || rdmRolesAvailable) : []),
...declared.map(s => ({
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
@@ -102,15 +80,11 @@ export function AdminZone() {
return (
<div className="az" role="dialog" aria-label="系统管理">
<div className="az-head">
<button className="btn ghost" onClick={() => {
// DR9:带未保存权限草稿离开须显式决定;守卫返回 false 表示用户取消。
if (roleDraftLeaveGuard.current && !roleDraftLeaveGuard.current()) return
store.setUI({ adminZoneOpen: false })
}}>
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
<Icon name="chevron-down" size={14} /> 返回工作台
</button>
<span className="az-title">系统管理</span>
<span className="az-sub">{me?.isSuperAdmin ? '超级管理员' : isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me?.owner ?? ''}</span>
</div>
-269
View File
@@ -1,269 +0,0 @@
// 文档驾驶舱 I3:阅读器(连续正文 + 版本摘要/差异折叠)与右侧依据/影响面板。
// 消费 I1/I3 命令面 rdm.cockpit.node(+versions)/node.history(Dev 仓 docs/superpowers
// specs 2026-10-16 view-spec §5–§6)。不新增第四栏:右栏是选中节点的同一依据面,
// 全景与阅读器共享 selectedNodeRef;面板刷新原位进行,不抢占滚动位置(DV-R05/§5)。
// 折叠只用 @ui Collapsible;颜色/字号只消费 @ui/tokens.css。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Collapsible, EmptyState } from '@ui'
import {
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
PANEL_TABS, type CockpitContentBlock, type CockpitHistorySummary,
type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode,
type CockpitVersionSummary, type Density, type PanelTabId,
} from '../documentCockpit'
import type { CitationView } from '../regCitations'
import { CitedBody } from './RegCitation'
export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string }
interface NodeDetailShape {
meta: CockpitMeta
node: CockpitTreeNode
body?: CockpitContentBlock[]
/** U7:章节正文 [cit-…] 标记的引用视图(契约 §3)。 */
citations?: CitationView[] | null
children: CockpitTreeNode[]
sources: { items: any[] }
history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string }
impacts: { items: any[] }
conflicts: { items: any[] }
versions?: CockpitVersionSummary[]
}
function useNodeDetail(pid: string, sel?: CockpitNodeRef) {
const [detail, setDetail] = useState<NodeDetailShape | null>(null)
const [err, setErr] = useState('')
const gen = useRef(0)
useEffect(() => {
const g = ++gen.current
setDetail(null); setErr('')
if (!sel) return
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id })
.then(d => {
if (g !== gen.current) return
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
}
setDetail(d)
})
.catch(e => { if (g === gen.current) setErr(e.message) })
return () => { gen.current++ }
}, [pid, sel?.kind, sel?.id])
return { detail, err }
}
/** 版本块:当前版本 + 前一版本摘要;完整差异(行级标记)折叠。受限只给元数据。 */
export function VersionBlock({ versions, density }: { versions?: CockpitVersionSummary[]; density: Density }) {
if (!versions || versions.length === 0) return null
const { current, previous, redacted } = versionSummary(versions)
if (redacted || !current || current.content === undefined) {
return <div className="empty-note">版本受限(仅元数据可见)</div>
}
const prev = previous && previous.content !== undefined ? previous : undefined
return (
<Collapsible
id={`cockpit-versions-${current.version}`}
trigger={<span className="cockpit-versions-toggle">版本 {current.version}{previous ? `(前一版 ${previous.version})` : '(首版)'}与完整差异</span>}
>
<div className={`cockpit-versions cockpit-density-${density}`}>
{prev ? (
<>
<div className="cockpit-versions-head">当前 {current.version} · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
<pre className="cockpit-diff">
{diffLines(current.content || '', prev.content || '').map((l, i) => (
<div key={i} className={`cockpit-diff-${l.flag === '=' ? 'same' : l.flag === '+' ? 'add' : 'del'}`}>{l.flag} {l.text}</div>
))}
</pre>
<div className="cockpit-versions-head">前一版 {prev.version} · {prev.actor || '未知'} · {new Date(prev.at).toLocaleString('zh-CN')}</div>
</>
) : (
<div className="cockpit-versions-head">首版 · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
)}
</div>
</Collapsible>
)
}
/** 阅读器中栏:选中章节=该节连续正文+版本块;选中文档=全章节连续装配(每节详情按需拉取)。 */
export function CockpitReader({ pid, selection, sections, density, onBack }: {
pid: string
selection: ReaderSelection
sections: CockpitTreeNode[]
density: Density
onBack: () => void
}) {
const { detail, err } = useNodeDetail(pid, selection.ref)
const isSection = selection.ref.kind === 'section'
const [sectionDetails, setSectionDetails] = useState<Record<string, NodeDetailShape | undefined>>({})
const gen = useRef(0)
// 文档阅读器:按树顺序装配全部章节详情(上限 20;失败章节给受限占位不拖垮整篇)。
useEffect(() => {
if (isSection || selection.ref.kind !== 'document') return
const g = ++gen.current
const list = sections.slice(0, 20)
setSectionDetails({})
for (const sec of list) {
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: 'section', id: sec.ref.id })
.then(d => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: d })) })
.catch(() => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: undefined })) })
}
return () => { gen.current++ }
}, [pid, isSection, selection.ref.id, sections])
const anchorRef = useRef<HTMLDivElement | null>(null)
useEffect(() => { anchorRef.current?.scrollIntoView({ block: 'start' }) }, [selection.ref.id])
if (err) return <EmptyState icon="alert">阅读器加载失败:{err}</EmptyState>
return (
<div className={`cockpit-reader cockpit-density-${density}`}>
<div className="cockpit-nodehead" ref={anchorRef}>
<button type="button" className="cockpit-back" onClick={onBack}>← 返回全景</button>
<span className="cockpit-nodetitle">{detail?.node?.title || (isSection ? '章节' : '文档')}</span>
</div>
{isSection && detail && (
<>
{renderBody(detail.body, detail.meta, detail.citations, `sec-${selection.ref.id}`)}
<VersionBlock versions={detail.versions} density={density} />
</>
)}
{!isSection && (
<>
{sections.length === 0 && <div className="empty-note">该文档暂无章节</div>}
{assembleReader(sections, sectionDetails).map(sec => (
<section key={sec.ref.id} id={`sec-${sec.ref.id}`} className="cockpit-reader-section">
<div className="cockpit-reader-sechead">
<span className="cockpit-reader-sectitle">{sec.title}</span>
{sec.blocks[0]?.kind === 'restricted'
? <span className="cockpit-badge">内容受限({sec.blocks[0].reason || '权限'})</span>
: sec.contentState === 'empty' && <span className="cockpit-badge">空</span>}
</div>
{sec.blocks[0]?.kind === 'text' && (
<CitedBody texts={sec.blocks.filter(b => b.kind === 'text').map(b => b.text || '')}
citations={sectionDetails[sec.ref.id]?.citations} scope={`doc-${sec.ref.id}`} />
)}
{sec.blocks.map((b, i) => b.kind !== 'text' && (
<p key={i} className="empty-note">内容受限——标题与状态可见,正文不展示</p>
))}
<VersionBlock versions={sec.versions} density={density} />
</section>
))}
</>
)}
</div>
)
}
function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta, citations: CitationView[] | null | undefined, scope: string) {
if (!body || body.length === 0) return <div className="empty-note">暂无正文</div>
if (body[0].kind === 'restricted') {
return <div className="empty-note">内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
}
return (
<>
<CitedBody texts={body.map(b => b.text || '')} citations={citations} scope={scope} />
{meta.truncated && <div className="cockpit-truncated">详情已截断:{meta.truncationReasons?.join(';')}</div>}
</>
)
}
/** 右栏:依据/历史/影响/冲突四标签(同一选中节点;刷新原位,不抢占滚动)。 */
export function CockpitPanel({ pid, selection, tab, onTab, density }: {
pid: string
selection: ReaderSelection
tab: PanelTabId
onTab: (t: PanelTabId) => void
density: Density
}) {
const { detail, err } = useNodeDetail(pid, selection.ref)
const [historyItems, setHistoryItems] = useState<CockpitHistorySummary[]>([])
const [historyMore, setHistoryMore] = useState(false)
const [historyCursor, setHistoryCursor] = useState<string | undefined>(undefined)
useEffect(() => {
setHistoryItems([]); setHistoryMore(false); setHistoryCursor(undefined)
}, [selection.ref.kind, selection.ref.id])
useEffect(() => {
if (!detail?.history) return
// 严格合并:只接受 seq 更新的条目,乱序/重复丢弃;不重置已渲染内容。
setHistoryItems(prev => mergeHistoryStrict(prev, detail.history.items).items)
setHistoryMore(detail.history.hasMore)
setHistoryCursor(detail.history.nextCursor)
}, [detail])
const loadMoreHistory = useCallback(() => {
if (!historyCursor) return
api.rdmCommand<any>('rdm.cockpit.node.history', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id, cursor: historyCursor, limit: 20 })
.then(page => {
setHistoryItems(prev => mergeHistoryStrict(prev, page.items).items)
setHistoryMore(page.hasMore)
setHistoryCursor(page.nextCursor)
})
.catch(() => { /* 保留已渲染历史;可再次点击重试 */ })
}, [pid, selection.ref.kind, selection.ref.id, historyCursor])
const node = detail?.node
return (
<div className={`cockpit-panel cockpit-density-${density}`}>
<div className="cockpit-panel-tabs" role="tablist" aria-label="依据与影响">
{PANEL_TABS.map(t => (
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id}
className={`cockpit-panel-tab${tab === t.id ? ' cockpit-panel-tab-active' : ''}`}
onClick={() => onTab(t.id)}>
{t.label}
</button>
))}
</div>
{err && <div className="empty-note">依据面板加载失败:{err}</div>}
{!err && !detail && <div className="empty-note">加载依据…</div>}
{detail && tab === 'sources' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">来源 {node?.state.sourceCount ?? detail.sources.items.length} 条</div>
{detail.sources.items.length === 0 && <div className="empty-note">无来源——章节正文未挂依据切片</div>}
{detail.sources.items.map((s: any) => (
<div key={s.ref.id} className="cockpit-panel-row">
<span className="cockpit-panel-kind">{s.ref.kind}</span>
<span className="cockpit-panel-label">{s.label}</span>
{s.confidence && <span className="cockpit-badge">置信度 {s.confidence}</span>}
{s.redactedReason
? <span className="cockpit-badge">受限({s.redactedReason})</span>
: s.verification && s.verification !== 'not_applicable' && <span className="cockpit-badge">{s.verification}</span>}
</div>
))}
</div>
)}
{detail && tab === 'history' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">已提交事件(最新在前)</div>
{historyItems.length === 0 && <div className="empty-note">暂无历史</div>}
{historyItems.map(h => <div key={h.seq} className="cockpit-panel-row">{historyLine(h)}</div>)}
{historyMore && <button type="button" className="cockpit-tree-more" onClick={loadMoreHistory}>加载更早历史</button>}
</div>
)}
{detail && tab === 'impacts' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">影响 {node?.state.impactCount ?? detail.impacts.items.length} 项(一跳)</div>
{detail.impacts.items.length === 0 && <div className="empty-note">无一跳影响对象</div>}
{detail.impacts.items.map((r: any, i: number) => (
<div key={`${r.ref.kind}-${r.ref.id}-${i}`} className="cockpit-panel-row">
<span className="cockpit-panel-kind">{r.direction === 'incoming' ? '←' : '→'}</span>
<span className="cockpit-panel-label">{r.ref.kind}:{r.ref.id}</span>
<span className="cockpit-badge">{r.edgeType}</span>
<span className="cockpit-badge">{r.edgeState}</span>
</div>
))}
</div>
)}
{detail && tab === 'conflicts' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">冲突 {node?.state.conflictCount ?? 0} 项</div>
{detail.conflicts.items.length === 0 && <div className="empty-note">无开放冲突</div>}
{detail.conflicts.items.map(c => (
<div key={c.id} className="cockpit-panel-row cockpit-panel-conflict">
<span className="cockpit-badge">{c.state === 'open' ? '开放' : c.state}</span>
<div className="cockpit-conflict-side">甲方:{c.left.visibility === 'full' ? c.left.claim : `受限(${c.left.redactedReason || '权限'})`}</div>
<div className="cockpit-conflict-side">乙方:{c.right.visibility === 'full' ? c.right.claim : `受限(${c.right.redactedReason || '权限'})`}</div>
<div className="cockpit-changetime">{new Date(c.detectedAt).toLocaleString('zh-CN')}</div>
</div>
))}
</div>
)}
</div>
)
}
+31 -76
View File
@@ -5,16 +5,15 @@ import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpe
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../api'
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline } from '../live'
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
import { docBridge } from './Workbench'
import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { ConversationWork } from './ConversationWork'
import { MessageTimestamp } from './MessageTimestamp'
import { loadRunEvents } from '../runEvents'
import '../companion-conversation.css'
function hasFiles(dt: DataTransfer | null): boolean {
@@ -26,7 +25,6 @@ function hasFiles(dt: DataTransfer | null): boolean {
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
switch (part.type) {
case 'text':
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
@@ -48,12 +46,12 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
case 'plan':
return null // 计划在右栏 Plan 标签呈现
case 'card': {
// 右栏工作可见时保留定位入口;详细正文只在右栏。关闭右栏时中栏可展开全文。
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
if (part.card?.cardId) {
const ref = part.card.cardId
const card = findCard(ref)
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={workPaneOwnsCards || card?.work ? undefined : card}
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={card}
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
}
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
@@ -199,8 +197,6 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
const childStamp = useStore(s => msg.runId ? s.runs.filter(r => r.parentRunId === msg.runId)
.map(r => `${r.id}:${r.status}:${r.endedAt ?? ''}`).join('|') : '')
const parts = msg.parts ?? []
useLiveClock(!!live)
const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl'
@@ -215,26 +211,6 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
useEffect(() => {
if (!touchedProcess.has(memKey)) setOpen(showProcess)
}, [memKey, showProcess])
const [replayed, setReplayed] = useState<Part[] | null>(null)
const [processError, setProcessError] = useState(false)
useEffect(() => { setReplayed(null); setProcessError(false) }, [childStamp])
useEffect(() => {
if (!open) { setReplayed(null); setProcessError(false) }
}, [open])
useEffect(() => {
if (!open || live || !msg.runId || !convId || replayed || processError) return
const controller = new AbortController()
let active = true
void (async () => {
try {
const events = await loadRunEvents(convId, msg.runId!, controller.signal)
if (active) setReplayed(runEventsToTimeline(events, msg.runId))
} catch {
if (active) setProcessError(true)
}
})()
return () => { active = false; controller.abort() }
}, [open, live, msg.runId, convId, replayed, processError])
const extras = live ? [] : citedArtifactCards(split.final)
const cards = live ? [] : dedupeDeliverables(split.final)
const lastText = (() => {
@@ -243,11 +219,10 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
})()
const streamingText = !!(live && live.phase === 'responding' && lastText >= 0
&& !live.items.some(it => it.kind === 'tool' && it.state !== 'done'))
const processParts = replayed === null ? split.timeline : mergeProcessTimeline(split.timeline, replayed, split.final)
const toolCount = processParts.filter(p => p.type === 'tool_call').length
const toolCount = split.timeline.filter(p => p.type === 'tool_call').length
const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}`
const timeline = (
<ProcessTimeline parts={processParts} live={!!live} idKey={msg.id}
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
)
const toggleProcess = (next: boolean) => {
@@ -260,21 +235,16 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
<div className="msg-role">
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
<span>助手</span>
<MessageTimestamp value={msg.createdAt || run?.createdAt} />
{live && <LiveBar live={live} />}
</div>
{(split.timeline.length > 0 || !!msg.runId) && (
{split.timeline.some(part => part.type !== 'thinking') && (
<div className="process-collapse">
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span className={live ? 'mk-sweep' : ''}>{headline}</span>
</>}>
<div className="body">
{replayed === null && !live && !processError && <div className="process-load-note">正在读取完整记录…</div>}
{processError && <div className="process-load-note">完整记录暂不可用,以下为已保存的摘要</div>}
{processParts.length > 0 ? timeline : replayed && <div className="process-load-note">这轮没有可回看的过程记录</div>}
</div>
<div className="body">{timeline}</div>
</Collapsible>
</div>
)}
@@ -454,19 +424,18 @@ function OutboxPanel() {
if (outbox.length === 0) return null
return (
<Collapsible id="composer-outbox" defaultOpen
trigger={<span>待发送 {outbox.length}</span>}
trigger={<span>排队 {outbox.length}</span>}
triggerClassName="outbox-trigger">
<ul className="outbox-list">
{outbox.map(item => (
<li key={item.id} className="outbox-item">
{item.deliveryError && <span className="outbox-text" role="status">{item.deliveryError}</span>}
{editing === item.id ? (
<input className="search-input outbox-edit" value={draft} onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) }
}} />
) : (
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}{item.taskRef ? ` · 基于「${item.taskRef.title}」` : ''}</span>
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}</span>
)}
{editing === item.id ? (
<button className="btn ghost" onClick={() => { store.updateOutbox(item.id, draft); setEditing(null) }}>保存</button>
@@ -474,7 +443,6 @@ function OutboxPanel() {
<button className="btn ghost" onClick={() => { setEditing(item.id); setDraft(item.text) }}>修改</button>
)}
<button className="btn ghost" onClick={() => store.sendOutboxNow(item.id)}>发送</button>
{item.taskRef && <button className="btn ghost" onClick={() => store.clearOutboxReference(item.id)}>移除引用</button>}
<button className="icon-btn" title="移出队列" onClick={() => store.removeOutbox(item.id)}><Icon name="x" size={12} /></button>
</li>
))}
@@ -489,7 +457,7 @@ export function NoticeMessage({ msg }: { msg: Message }) {
return (
<div className="msg notice" data-msg-id={msg.id}>
<Collapsible id={`notice:${msg.id}`} triggerClassName="notice-summary"
trigger={<><span className="notice-label">通知 · {preview}</span><MessageTimestamp value={msg.createdAt} /></>}>
trigger={<span className="notice-label">通知 · {preview}</span>}>
<div className="notice-body">{text}</div>
</Collapsible>
</div>
@@ -518,8 +486,6 @@ function ConversationSession() {
const cards = useStore(s => s.cards)
const currentProjectId = useStore(s => s.currentProjectId)
const cardActionStates = useStore(s => s.cardActionStates)
const workReference = useStore(s => s.workReference)
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
const [text, setText] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
@@ -548,9 +514,8 @@ function ConversationSession() {
})
const busy = isShellBusy(phase)
const showStop = phase === 'running'
// Background work stays in its own card and does not insert another chat turn.
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
const unreadResults = cards.filter(card => card.work?.result && card.work.resultVersion > card.work.readVersion)
useEffect(() => {
if (!stickRef.current) return
@@ -584,18 +549,12 @@ function ConversationSession() {
const t = text
const ids = attachmentIds
const names = attachmentNames
const reference = workReference ?? undefined
if (reference && ids.length > 0) {
store.toast('这条消息已引用工作结果,请先移除引用或附件再发送')
return
}
setText(''); setAttachmentIds([]); setAttachmentNames([])
store.clearWorkReference()
if (busy) {
store.enqueueOutbox(t, ids, names, reference)
store.enqueueOutbox(t, ids, names)
return
}
await store.sendMessage(t, ids, undefined, reference)
await store.sendMessage(t, ids)
}
if (!currentConvId) {
@@ -658,6 +617,13 @@ function ConversationSession() {
const stick = dist < 80
stickRef.current = stick
setShowJump(prev => (prev === !stick ? prev : !stick))
const ratio = scrollRatio(src)
if (ratio === null) return
const dst = document.querySelector('[data-scroll-sync="inbox"]')
if (!dst) return
const top = scrollTopForRatio(dst, ratio)
if (top === null) return
dst.scrollTop = top
}}>
{foldRunErrors(messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent')).map(m => {
if (m.role === 'user') {
@@ -667,20 +633,16 @@ function ConversationSession() {
<div className={`msg user${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
<div className="msg-role">
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
<MessageTimestamp value={m.createdAt} />
</div>
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
{anchored.map(c => workPaneOwnsCards
? <button type="button" key={c.id} className="conversation-action-anchor" onClick={() => store.openInbox(c.id)}>
<span>{c.title}</span><span>在工作栏处理 ↗</span>
</button>
: <InlineConfirmCard key={c.id} card={c} callbacks={{
onAction: (card, actionId) => store.cardAction(card, actionId),
actionState: card => cardActionStates?.[card.id],
onLocate: card => store.openInbox(card.id),
}} />
)}
{anchored.map(c => (
<InlineConfirmCard key={c.id} card={c} callbacks={{
onAction: (card, actionId) => store.cardAction(card, actionId),
actionState: card => cardActionStates?.[card.id],
onLocate: card => store.openInbox(card.id),
}} />
))}
</div>
)
}
@@ -712,23 +674,16 @@ function ConversationSession() {
<div className="input-bar">
<OutboxPanel />
{unreadResults.length > 0 && <button type="button" className="work-result-notice" onClick={() => store.openInbox(unreadResults[0].id)}>
<Icon name="check" size={14} /><span>{unreadResults.length} 项工作有新结果</span><span>查看工作 ↗</span>
</button>}
{workReference && <div className="composer-work-reference" role="group" aria-label="本条消息引用的工作结果">
<span>基于「{workReference.title}」的第 {workReference.resultVersion} 版结果</span>
<button type="button" className="icon-btn" aria-label="移除工作结果引用" onClick={() => store.clearWorkReference()}><Icon name="x" size={12} /></button>
</div>}
{!connected && <div className="companion-reconnect" role="status">正在重新连接,最新进展待确认</div>}
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
后台工作完成后更新原卡,用户按需打开。 */}
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
{(busy || bgRunning > 0) && (
<div className="input-status" data-testid="input-status">
<span className="live-dot" />
{busy
? <span className="mk-sweep">助手正在处理{bgRunning > 0 ? ` · ${bgRunning} 项后台工作` : ''}</span>
: <span>{bgRunning} 项后台工作</span>}
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,结果会更新到工作卡'}</span>
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,完成后会在这里汇报'}</span>
</div>
)}
{attachmentNames.length > 0 && (
+7 -19
View File
@@ -1,9 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { ActivityCardView, Collapsible, activitySummary, groupActivityCards, pendingCount, type ActivityCard } from '@ui'
import { ActivityCardView, Collapsible, activitySummary, pendingCount, type ActivityCard } from '@ui'
import { store, useStore } from '../store'
import { docBridge } from './Workbench'
import type { WorkCardAction } from '../contracts'
import { WorkExecutionRecord } from './WorkExecutionRecord'
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
export function ConversationWork() {
@@ -13,11 +11,8 @@ export function ConversationWork() {
const messages = useStore(s => s.messages)
const actionStates = useStore(s => s.cardActionStates)
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
const rightTab = useStore(s => s.ui.rightTab)
const record = useStore(s => s.workRecord)
const detailHere = rightCollapsed || rightTab !== 'work'
const [summaryOpen, setSummaryOpen] = useState(detailHere)
useEffect(() => setSummaryOpen(detailHere), [detailHere])
const [summaryOpen, setSummaryOpen] = useState(rightCollapsed)
useEffect(() => setSummaryOpen(rightCollapsed), [rightCollapsed])
const { current, required, focused, history } = useMemo(() => {
const current = cards.filter(card => card.status !== 'dismissed'
&& card.conversationId === conversationId && card.projectId === projectId)
@@ -29,29 +24,22 @@ export function ConversationWork() {
if (card.kind === 'confirm' && card.anchorMessageId
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
const summary = activitySummary(card)
const taskNotice = card.task?.limitations?.length || card.task?.phase.id === 'correction'
|| card.task?.findings?.some(f => f.status === 'active' && (f.kind === 'needs_user' || f.corrects))
if (taskNotice || card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
if (card.status === 'action_required' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
else remaining.push(card)
}
// Preserve the host's newest-first order for completed results and history.
const results = groupActivityCards(remaining).find(group => group.key === 'results')?.cards ?? []
const focused = remaining.find(card => card.pinned && card.status === 'active')
?? remaining.find(card => card.status === 'active')
?? results[0]
return { current, required, focused, history: remaining.filter(card => card !== focused).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) }
?? remaining.find(card => card.status === 'done') ?? remaining[0]
return { current, required, focused, history: remaining.filter(card => card !== focused) }
}, [cards, messages, conversationId, projectId])
if (!detailHere) return null
if (record) return <div className="conversation-work" data-conversation-work><WorkExecutionRecord /></div>
if (current.length === 0) return null
const pending = pendingCount(current)
const active = current.filter(card => card.status === 'active').length
const results = current.filter(card => card.status === 'done' || card.status === 'failed').length
const summary = [pending ? `${pending} 项待处理` : '', active ? `${active} 项工作` : '', results ? `${results} 项结果` : ''].filter(Boolean).join(' · ')
const callbacks = {
onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => { void store.cardAction(card, actionId, payload) },
onOpenRecord: (card: ActivityCard) => store.openWorkRecord(card),
onReference: (card: ActivityCard) => store.referenceWork(card),
onAction: (card: ActivityCard, actionId: string) => { void store.cardAction(card, actionId) },
actionState: (card: ActivityCard) => actionStates?.[card.id],
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
-372
View File
@@ -1,372 +0,0 @@
// 文档驾驶舱 I2(rollout I2):顶部统计带 + 左侧结构树 + 中央结构全景。
// 消费 I1 只读命令面 rdm.cockpit.*(Dev 仓 docs/superpowers/specs 2026-10-16
// view-spec §1–§4;四项取舍冻结见 rollout §7——首屏只显示章节概览与统计,
// 段落/正文摘要在选中与阅读器内按权限展示,本增量不含阅读器(I3))。
// 异步所有者:generation + contextKey(项目+筛选);不匹配的旧响应直接丢弃。
// 令牌驱动(DESIGN.md):颜色/字号只消费 @ui/tokens.css;折叠用 Collapsible。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
import {
contextKey, loadPrefs, nodeSummaryLine, sameSnapshot, savePrefs,
selectionSurvives, statDisplay, STAT_FILTERS, toggleFilter, treeBadges,
type CockpitMeta, type CockpitNodeRef, type CockpitOverview,
type CockpitTreeNode, type CockpitTreePage,
type Density, type PanelTabId, type ViewMode,
} from '../documentCockpit'
import { CockpitPanel, CockpitReader, VersionBlock } from './CockpitReader'
interface ProjectView { projectId: string; name: string; canAudit?: boolean }
function CockpitErrorState({ raw }: { raw: string }) {
const c = classifyRdmError(raw)
return <EmptyState icon="alert">{c.message}</EmptyState>
}
interface Selection { ref: CockpitNodeRef; parentDocId?: string }
export function DocumentCockpit() {
const identity = useStore(() => store.getIdentityEpoch())
const [projects, setProjects] = useState<ProjectView[] | null>(null)
const [pid, setPid] = useState('')
const [filter, setFilter] = useState('')
const [overview, setOverview] = useState<CockpitOverview | null>(null)
const [treePages, setTreePages] = useState<Record<string, { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }>>({})
const [selected, setSelected] = useState<Selection | null>(null)
const [error, setError] = useState('')
const [loadErr, setLoadErr] = useState('')
const [viewMode, setViewMode] = useState<ViewMode>('panorama')
const [density, setDensity] = useState<Density>('compact')
const [panelTab, setPanelTab] = useState<PanelTabId>('sources')
const gen = useRef(0)
const reload = useCallback((projectId: string, f: string) => {
if (!projectId) return
const g = ++gen.current
const ctx = contextKey(projectId, f)
setLoadErr('')
api.rdmCommand<CockpitOverview>('rdm.cockpit.overview', { projectId })
.then(ov => {
if (g !== gen.current || ctx !== contextKey(pid, filter)) return // 旧响应:项目/筛选已切换,丢弃
setOverview(ov)
setTreePages({}) // 树页随新快照整体重建
setSelected(sel => {
const kept = selectionSurvives(sel?.ref || null, (ov.documents || []).map(d => d.ref.id), sel?.parentDocId)
return kept ? { ref: kept, parentDocId: sel?.parentDocId } : null
})
})
.catch(e => { if (g === gen.current && ctx === contextKey(pid, filter)) setLoadErr(e.message) })
}, [pid, filter])
// 项目清单(rdm.project.list:成员可见项目,canAudit 与否不影响文档读取入口)。
useEffect(() => {
let c = false
setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('')
api.rdmCommand<{ projects?: ProjectView[] } | ProjectView[]>('rdm.project.list')
.then(list => {
if (c) return
// rdm.project.list 返回 { projects: [...] };容忍裸数组形态。
const rows = Array.isArray(list) ? list : (list?.projects ?? [])
setProjects(rows)
// 设计 §6.2:进入驾驶舱默认选中第一个可见项目,驱动概览首屏。
if (rows.length > 0) setPid(prev => prev || rows[0].projectId)
})
.catch(e => { if (!c) setError(e.message) })
return () => { c = true }
}, [identity])
// 项目/筛选变化 → 重新取概览(统计与根文档来自同一快照)。
useEffect(() => {
if (!pid) return
reload(pid, filter)
}, [pid, filter, reload])
// 个人视图偏好(设计 §7.3):按 登录世代+项目 隔离,仅本地;恢复最近视图。
useEffect(() => {
if (!pid) return
const prefs = loadPrefs(`epoch-${identity}`, pid)
setViewMode(prefs.lastView)
setDensity(prefs.density)
setPanelTab(prefs.rightPanelTab)
}, [identity, pid])
const applyPrefs = useCallback((patch: { lastView?: ViewMode; density?: Density; rightPanelTab?: PanelTabId }) => {
if (!pid) return
setViewMode(v => patch.lastView ?? v)
setDensity(d => patch.density ?? d)
setPanelTab(t => patch.rightPanelTab ?? t)
const prefs = loadPrefs(`epoch-${identity}`, pid)
savePrefs(`epoch-${identity}`, pid, { ...prefs, ...patch })
}, [identity, pid])
// 展开文档:按需加载章节子页(键集分页;旧项目/旧筛选的响应丢弃;
// 加载失败只影响该分支——行内保留重试,不拖垮其它面板)。
const loadChildren = useCallback((docId: string, cursor?: string) => {
if (!pid) return
const g = gen.current
const ctx = contextKey(pid, filter)
api.rdmCommand<CockpitTreePage>('rdm.cockpit.tree', { projectId: pid, parent: docId, cursor, limit: 50, filter })
.then(page => {
if (g !== gen.current || ctx !== contextKey(pid, filter)) return
setTreePages(prev => {
const old = cursor ? prev[docId] : { items: [] as CockpitTreeNode[], hasMore: false }
const items = cursor ? [...(old?.items || []), ...page.items] : page.items
return { ...prev, [docId]: { items, nextCursor: page.nextCursor, hasMore: page.hasMore, meta: page.meta } }
})
})
.catch(() => { /* 行内重试入口承担 */ })
}, [pid, filter])
if (error) return <CockpitErrorState raw={error} />
if (!projects) return <div className="empty-note">加载中…</div>
if (projects.length === 0) return <EmptyState icon="folder">暂无可见研发项目——项目成员身份由项目负责人授予</EmptyState>
const docIds = (overview?.documents || []).map(d => d.ref.id)
const splitNotice = !!overview && Object.values(treePages).some(p => !sameSnapshot(overview.meta, p.meta))
const selectDoc = (id: string) => setSelected({ ref: { kind: 'document', id } })
const selectSection = (docId: string, sec: CockpitTreeNode) => setSelected({ ref: sec.ref, parentDocId: docId })
return (
<div className="cockpit">
<div className="cockpit-header">
<select className="ui-input cockpit-project" value={pid} onChange={e => { setPid(e.target.value); setSelected(null); setFilter('') }} aria-label="选择项目">
{projects.map(p => <option key={p.projectId} value={p.projectId}>{p.name}</option>)}
</select>
{filter && <button type="button" className="cockpit-filter-chip" onClick={() => setFilter('')}>
筛选:{STAT_FILTERS.find(s => s.filter === filter)?.label || filter} ✕
</button>}
<span className="cockpit-snap">{overview ? `快照 ${overview.meta.snapshotRevision}` : '加载中…'}</span>
<div className="cockpit-viewtoggle" role="group" aria-label="视图切换">
<button type="button" className={`cockpit-viewbtn${viewMode === 'panorama' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'panorama' })}>全景</button>
<button type="button" className={`cockpit-viewbtn${viewMode === 'reader' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'reader' })}>阅读器</button>
<button type="button" className="cockpit-viewbtn" onClick={() => applyPrefs({ density: density === 'compact' ? 'full' : 'compact' })} title="切换密度">{density === 'compact' ? '紧凑' : '宽松'}</button>
</div>
<button type="button" className="cockpit-refresh" onClick={() => reload(pid, filter)} disabled={!pid}>刷新</button>
</div>
{splitNotice && (
<div className="cockpit-banner" role="status">统计与树快照不一致——请刷新以对齐最新快照</div>
)}
{loadErr && <div className="cockpit-banner cockpit-banner-err" role="alert">概览读取失败:{loadErr}(保留上一快照,可重试)</div>}
{overview && (
<div className="cockpit-stats" role="group" aria-label="项目统计">
<div className="cockpit-stat">
<span className="cockpit-stat-num">{statDisplay(overview.stats, 'completion').value}</span>
<span className="cockpit-stat-label">完成度{statDisplay(overview.stats, 'completion').hint ? `(${statDisplay(overview.stats, 'completion').hint})` : ''}</span>
</div>
<div className="cockpit-stat"><span className="cockpit-stat-num">{statDisplay(overview.stats, 'documents').value}</span><span className="cockpit-stat-label">文档</span></div>
{STAT_FILTERS.map(s => {
const d = statDisplay(overview.stats, s.key)
const active = filter === s.filter
return (
<button key={s.key} type="button" className={`cockpit-stat${active ? ' cockpit-stat-active' : ''}`}
onClick={() => setFilter(f => toggleFilter(f, s.filter))}
title={d.hint ? `${s.label}:${d.hint}` : `筛选:${s.label}`}>
<span className="cockpit-stat-num">{d.value}</span>
<span className="cockpit-stat-label">{s.label}{d.hint ? `(${d.hint})` : ''}</span>
</button>
)
})}
<div className="cockpit-stat cockpit-stat-unavail"><span className="cockpit-stat-num">—</span><span className="cockpit-stat-label">待确认(未纳入投影)</span></div>
<div className="cockpit-stat"><span className="cockpit-stat-num">{overview.stats.updatedAt ? new Date(overview.stats.updatedAt).toLocaleString('zh-CN') : '—'}</span><span className="cockpit-stat-label">最近更新</span></div>
</div>
)}
<div className={`cockpit-columns${selected ? ' cockpit-columns-3' : ''}`}>
<div className="cockpit-tree" role="tree" aria-label="文档结构树">
<SectionLabel>结构树</SectionLabel>
{!overview && <div className="empty-note">加载结构…</div>}
{overview && docIds.length === 0 && <div className="empty-note">暂无受控文档</div>}
{overview && overview.meta.truncated && (
<div className="cockpit-truncated">文档清单已截断({overview.meta.truncationReasons?.join(';') || '超出上限'})</div>
)}
{overview && overview.documents.map(d => (
<TreeRow key={d.ref.id} node={d} depth={0}
expanded={treePages[d.ref.id] !== undefined}
childPage={treePages[d.ref.id]}
selectedId={selected?.ref.id}
onSelectNode={n => (n.ref.kind === 'document' ? selectDoc(n.ref.id) : selectSection(d.ref.id, n))}
onExpand={() => { if (treePages[d.ref.id] === undefined) loadChildren(d.ref.id) }}
onLoadMore={() => { const p = treePages[d.ref.id]; if (p?.nextCursor) loadChildren(d.ref.id, p.nextCursor) }}
onRetryChild={() => { const p = treePages[d.ref.id]; loadChildren(d.ref.id, p?.nextCursor) }}
/>
))}
</div>
<div className="cockpit-center">
{!overview && <div className="empty-note">加载概览…</div>}
{overview && !selected && (
<PanoramaHome overview={overview} onSelect={selectDoc} />
)}
{overview && selected && viewMode === 'panorama' && (
<PanoramaNode pid={pid} selection={selected} overview={overview}
snapRev={overview.meta.snapshotRevision}
childPage={selected.ref.kind === 'document' ? treePages[selected.ref.id] : undefined}
density={density}
onClear={() => setSelected(null)}
onOpenReader={() => applyPrefs({ lastView: 'reader' })} />
)}
{overview && selected && viewMode === 'reader' && (
<CockpitReader pid={pid} selection={selected}
sections={selected.ref.kind === 'document' ? (treePages[selected.ref.id]?.items || []) : []}
density={density} onBack={() => applyPrefs({ lastView: 'panorama' })} />
)}
</div>
{overview && selected && (
<div className="cockpit-right">
<CockpitPanel pid={pid} selection={selected} tab={panelTab} onTab={t => applyPrefs({ rightPanelTab: t })} density={density} />
</div>
)}
</div>
</div>
)
}
function TreeRow({ node, depth, expanded, childPage, selectedId, onSelectNode, onExpand, onLoadMore, onRetryChild }: {
node: CockpitTreeNode; depth: number
expanded: boolean
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
selectedId?: string
onSelectNode: (n: CockpitTreeNode) => void
onExpand: () => void
onLoadMore: () => void
onRetryChild: () => void
}) {
const badges = treeBadges(node)
const selected = selectedId === node.ref.id
return (
<div className="cockpit-tree-branch">
<div className={`cockpit-tree-row${selected ? ' cockpit-tree-row-active' : ''}`} style={{ paddingLeft: depth * 14 }} role="treeitem" aria-selected={selected}>
{node.ref.kind === 'document' && (
<button type="button" className="cockpit-tree-caret" aria-label={expanded ? '折叠' : '展开'}
aria-expanded={expanded} onClick={e => { e.stopPropagation(); if (!expanded) onExpand() }}>
{expanded ? '▾' : '▸'}
</button>
)}
<button type="button" className="cockpit-tree-title" onClick={() => onSelectNode(node)}>{node.title}</button>
{badges.map(b => <span key={b} className="cockpit-badge">{b}</span>)}
{node.lastChangedAt && <span className="cockpit-tree-time">{new Date(node.lastChangedAt).toLocaleDateString('zh-CN')}</span>}
</div>
{expanded && !childPage && (
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onRetryChild}>章节加载失败,重试</button>
)}
{expanded && childPage && (
<div>
{childPage.items.length === 0 && <div className="cockpit-tree-empty" style={{ paddingLeft: (depth + 1) * 14 }}>无章节</div>}
{childPage.items.map(sec => (
<TreeRow key={sec.ref.id} node={sec} depth={depth + 1} expanded={false} childPage={undefined}
selectedId={selectedId} onSelectNode={onSelectNode}
onExpand={onExpand} onLoadMore={onLoadMore} onRetryChild={onRetryChild} />
))}
{childPage.hasMore && childPage.items.length > 0 && (
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onLoadMore}>加载更多章节</button>
)}
</div>
)}
</div>
)
}
function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSelect: (id: string) => void }) {
return (
<div>
<SectionLabel>{overview.project.title} · 文档概览</SectionLabel>
{overview.documents.length === 0 && <EmptyState icon="folder">暂无受控文档——文档由模板实例化或导入后进入驾驶舱</EmptyState>}
<div className="cockpit-doclist">
{overview.documents.map(d => (
<button key={d.ref.id} type="button" className="cockpit-docrow" onClick={() => onSelect(d.ref.id)}>
<span className="cockpit-docrow-title">{d.title}</span>
<span className="cockpit-docrow-meta">{nodeSummaryLine(d)}</span>
</button>
))}
</div>
<SectionLabel>最近变化</SectionLabel>
{overview.recentChanges.length === 0 && <div className="empty-note">暂无可见变化</div>}
<div className="cockpit-changes">
{overview.recentChanges.map((c, i) => (
<div key={`${c.node.id}-${i}`} className="cockpit-changerow">
<span className="cockpit-changesum">{c.summary} · {c.title}{c.actor ? ` · ${c.actor}` : ''}</span>
<span className="cockpit-changetime">{new Date(c.changedAt).toLocaleString('zh-CN')}</span>
</div>
))}
</div>
</div>
)
}
function PanoramaNode({ pid, selection, overview, childPage, density, snapRev, onClear, onOpenReader }: {
pid: string; selection: Selection; overview: CockpitOverview
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
density: Density
snapRev: string
onClear: () => void
onOpenReader: () => void
}) {
const [detail, setDetail] = useState<any>(null)
const [err, setErr] = useState('')
const gen = useRef(0)
useEffect(() => {
const g = ++gen.current
setDetail(null); setErr('')
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id })
.then(d => {
if (g !== gen.current) return
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
}
setDetail(d)
})
.catch(e => { if (g === gen.current) setErr(e.message) })
return () => { gen.current++ }
}, [pid, selection.ref.kind, selection.ref.id, snapRev])
const node = (detail?.node || null) as CockpitTreeNode | null
const title = node?.title || selection.ref.id
return (
<div>
<div className="cockpit-nodehead">
<button type="button" className="cockpit-back" onClick={onClear}>← 返回概览</button>
<span className="cockpit-nodetitle">{title}</span>
{node && <span className="cockpit-nodesum">{nodeSummaryLine(node)}</span>}
<button type="button" className="cockpit-filter-chip" onClick={onOpenReader}>在阅读器中打开</button>
</div>
{err && <CockpitErrorState raw={err} />}
{detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && (
<div className="empty-note">内容受限({detail.body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
)}
{selection.ref.kind === 'section' && detail?.versions && detail.versions.length > 0 && (
<VersionBlock versions={detail.versions} density={density} />
)}
{detail && detail.meta && !sameSnapshot(detail.meta, overview.meta) && (
<div className="cockpit-truncated">节点详情与统计快照不同({detail.meta.snapshotRevision} vs {overview.meta.snapshotRevision})——刷新对齐</div>
)}
{node && node.ref.kind === 'document' && (
<>
<SectionLabel>章节状态</SectionLabel>
{!childPage && <div className="empty-note">章节按需加载——在左侧树展开</div>}
{childPage && childPage.items.length === 0 && <div className="empty-note">无章节</div>}
{childPage && childPage.items.length > 0 && (
<table className="cockpit-childtable">
<thead><tr><th>章节</th><th>内容</th><th>核实</th><th>来源</th><th>冲突</th><th>最近更新</th></tr></thead>
<tbody>
{childPage.items.map(sec => (
<tr key={sec.ref.id}>
<td>{sec.title}</td>
<td>{{ complete: '完整', partial: '部分', empty: '空', inaccessible: '受限', unavailable: '不可读' }[sec.state.content] || sec.state.content}</td>
<td>{sec.state.verification === 'not_applicable' ? '—' : sec.state.verification}</td>
<td>{sec.state.sourceCount}</td>
<td>{sec.state.conflictCount}</td>
<td>{sec.lastChangedAt ? new Date(sec.lastChangedAt).toLocaleDateString('zh-CN') : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
{node && node.ref.kind === 'section' && (
<div className="empty-note">点「在阅读器中打开」查看连续正文与版本差异;右侧为该节点的依据与影响面板</div>
)}
{detail?.meta?.truncated && (
<div className="cockpit-truncated">详情已截断:{detail.meta.truncationReasons?.join(';')}</div>
)}
</div>
)
}
-125
View File
@@ -1,125 +0,0 @@
// 统一渲染 Task 11:整文审签与规则绑定面板。纯展示:入口、载荷与校验来自 documentRenderPresentation,
// 异步操作状态由工作区唯一持有并经 props 注入;入口只在服务端允许(能力 ∧ 冻结职责/项目设置权限)时出现。
import { useState } from 'react'
import { Btn, Chip, SectionLabel } from '@ui'
import {
BIND_SOURCE_LABELS, validateReviewText, ruleLabel,
type BindSource, type BindableRule, type DecisionEntries, type DecisionKind, type DocSubmissionView, type RuleRef,
} from '../documentRenderPresentation'
import { OperationStatus, type OperationState } from './renderFeedback'
export const ACTION_OP_LABELS: Record<string, string> = {
approve: '整文审核通过', reject: '整文驳回', final: '整文终批', bind: '绑定规则', unbind: '解绑规则',
}
const DECISION_LABELS: Record<DecisionKind, string> = { approve: '审核通过', reject: '驳回', final: '终批' }
const SUBMISSION_STATE: Record<string, { label: string; tone: 'ok' | 'info' | 'danger' | 'plain' }> = {
submitted: { label: '审签中', tone: 'info' },
rejected: { label: '已驳回', tone: 'danger' },
final: { label: '已终批', tone: 'ok' },
}
export function ReviewDecisionPanel({ sub, entries, busy, status, onRetry, onDecide }: {
sub: DocSubmissionView
entries: DecisionEntries
busy: boolean
status: OperationState
onRetry: () => void
onDecide: (kind: DecisionKind, text: string) => void
}) {
const [text, setText] = useState('')
const [hint, setHint] = useState('')
const reviewerIds = Object.keys(sub.reviews)
const approvedCount = reviewerIds.filter(id => sub.reviews[id] === true).length
const stateView = SUBMISSION_STATE[sub.state] ?? { label: '状态未知', tone: 'plain' as const }
const anyEntry = entries.approve || entries.reject || entries.final
const decide = (kind: DecisionKind) => {
// 驳回必须给出原因;审核通过与终批的说明可选(服务端同样可选)。
if (kind === 'reject') {
const bad = validateReviewText(text)
if (bad) { setHint(bad); return }
}
setHint('')
onDecide(kind, text)
}
return (
<>
<div className="drw-row drw-row-between">
<SectionLabel>整文审签</SectionLabel>
<span className="drw-row">
<Chip tone={stateView.tone}>{stateView.label}</Chip>
<span className="drw-muted">第 {sub.round} 轮 · 审核 {approvedCount}/{reviewerIds.length}</span>
</span>
</div>
{anyEntry && (
<>
<textarea className="ui-input drw-textarea" aria-label="审签说明" value={text}
onChange={e => setText(e.target.value)} placeholder="说明可留空;驳回时必须填写原因" />
{hint && <div className="drw-inline-err">{hint}</div>}
<div className="drw-row">
{entries.approve && <Btn variant="ghost" disabled={busy} onClick={() => decide('approve')}>{DECISION_LABELS.approve}</Btn>}
{entries.reject && <Btn variant="danger" disabled={busy} onClick={() => decide('reject')}>{DECISION_LABELS.reject}</Btn>}
{entries.final && <Btn variant="primary" disabled={busy} onClick={() => decide('final')}>{DECISION_LABELS.final}</Btn>}
</div>
</>
)}
<OperationStatus state={status} onRetry={onRetry} labels={ACTION_OP_LABELS} />
</>
)
}
const SCOPES: BindSource[] = ['document', 'document-type', 'project-default']
const ruleKey = (r: RuleRef) => `${r.profileId}@${r.version}`
export function ProfileBindingPanel({ rules, bindings, typeKey, busy, status, onRetry, onBind, onUnbind }: {
rules: BindableRule[]
bindings: Partial<Record<BindSource, RuleRef>>
typeKey: string | undefined
busy: boolean
status: OperationState
onRetry: () => void
onBind: (source: BindSource, rule: RuleRef) => void
onUnbind: (source: BindSource) => void
}) {
const [choice, setChoice] = useState('')
const selected = rules.find(r => ruleKey(r) === choice)
return (
<>
<SectionLabel>规则绑定</SectionLabel>
{rules.length === 0 ? (
<div className="empty-note">没有可绑定的已发布规则</div>
) : (
<div className="drw-bind-rules" role="radiogroup" aria-label="可绑定规则">
{rules.map(r => (
<label key={ruleKey(r)} className={`drw-bind-rule${choice === ruleKey(r) ? ' sel' : ''}`}>
<input type="radio" name="drw-bind-rule" value={ruleKey(r)} checked={choice === ruleKey(r)} onChange={() => setChoice(ruleKey(r))} />
<span className="drw-mono">{ruleLabel(r)}</span>
</label>
))}
</div>
)}
{SCOPES.map(source => {
const bound = bindings[source]
const needsType = source === 'document-type'
const bindDisabled = busy || !selected || (needsType && !typeKey)
return (
<div key={source} className="drw-bind-row">
<div className="drw-row drw-row-between">
<span className="drw-profile-source">{BIND_SOURCE_LABELS[source]}</span>
<span className="drw-mono">{bound ? ruleLabel(bound) : '未绑定'}</span>
</div>
<div className="drw-row">
<Btn variant="ghost" disabled={bindDisabled} onClick={() => selected && onBind(source, selected)}>绑定所选规则</Btn>
{bound && <Btn variant="ghost" disabled={busy} onClick={() => onUnbind(source)}>解绑</Btn>}
</div>
{needsType && !typeKey && <div className="drw-muted">文档类型未就绪,暂不能绑定文档类型规则</div>}
</div>
)
})}
<OperationStatus state={status} onRetry={onRetry} labels={ACTION_OP_LABELS} />
</>
)
}
File diff suppressed because it is too large Load Diff
-98
View File
@@ -1,98 +0,0 @@
import { useEffect, useState } from 'react'
import { api, type ExecEnvView } from '../api'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup } from '@ui'
export function ExecEnvPanel() {
const [view, setView] = useState<ExecEnvView | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [conv, setConv] = useState('')
const [reason, setReason] = useState('')
const [wheel, setWheel] = useState<File | null>(null)
const refresh = async () => {
try { setView(await api.adminExecEnv()) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const run = async (work: () => Promise<string>) => {
setErr(''); setNotice(''); setBusy(true)
try { setNotice(await work()); await refresh() } catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
if (!view) return <EmptyState icon="package">加载 Python 环境…</EmptyState>
if (!view.configured) return <EmptyState icon="package">这台服务没有配置分层 Python 环境根</EmptyState>
return (
<div>
<SettingsGroup title="当前环境">
<SettingRow label="环境根" desc={view.root || '未设置'}><span /></SettingRow>
<SettingRow label="解释器" desc={view.python || '尚未安装托管解释器'}><span /></SettingRow>
<SettingRow label="基础层" desc={view.base || '尚未建立'}><span /></SettingRow>
<SettingRow label="层目录" desc={(view.layers ?? []).join('、') || '无'}>
<Btn variant="ghost" icon="undo" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvRebase()
return `基础层已切换到 ${out.base}。已有会话仍用原来的绑定。`
})}>重建基础层</Btn>
</SettingRow>
</SettingsGroup>
<SettingsGroup title="申请">
{(view.requests ?? []).length === 0 && <EmptyState icon="inbox">还没有扩展包申请</EmptyState>}
{(view.requests ?? []).map(req => (
<SettingRow key={req.id} label={req.spec || req.id} desc={`${req.status} · ${req.conversationId} · ${req.detail}`}>
{req.status === 'pending_approval' && (
<span className="ad-row">
<Btn variant="primary" icon="check" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, true, false)
return out.detail || out.status
})}>批准</Btn>
<Btn variant="ghost" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, true, true)
return out.detail || '已用隔离层'
})}>隔离层</Btn>
<Btn variant="ghost" icon="x" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, false, false)
return out.detail || '已拒绝'
})}>拒绝</Btn>
</span>
)}
</SettingRow>
))}
</SettingsGroup>
<SettingsGroup title="会话绑定">
{(view.bindings ?? []).map(b => (
<SettingRow key={b.conversationId} label={b.conversationId} desc={b.isolated ? `隔离 ${b.isolated}` : (b.extensions ?? []).join('、') || '只用基础层'}>
<span />
</SettingRow>
))}
<SettingRow label="回收" desc="删掉 7 天内没有会话引用、也不是当前基础层的目录">
<Btn variant="ghost" icon="trash" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvGC()
const n = out.removed?.length ?? 0
return n === 0 ? '没有可回收的层' : `已回收 ${n} 层`
})}>回收未引用层</Btn>
</SettingRow>
</SettingsGroup>
<SettingsGroup title="上传 wheel">
<SettingRow label="会话" desc="这个 wheel 只挂到指定会话,并进入审批">
<input className="lg-input" value={conv} onChange={e => setConv(e.target.value)} placeholder="会话 ID" />
</SettingRow>
<SettingRow label="理由" desc="写给审批记录">
<input className="lg-input" value={reason} onChange={e => setReason(e.target.value)} />
</SettingRow>
<SettingRow label="文件" desc="只接受预编译 wheel,不执行里面的脚本">
<input type="file" accept=".whl" onChange={e => setWheel(e.target.files?.[0] ?? null)} />
</SettingRow>
<div className="ad-row">
<Btn variant="primary" icon="download" disabled={busy || !wheel || !conv.trim()} onClick={() => run(async () => {
const out = await api.adminExecEnvWheel(wheel as File, conv.trim(), reason.trim())
return `${out.file}:${out.outcome.status}`
})}>提交审批</Btn>
</div>
</SettingsGroup>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
+98 -67
View File
@@ -1,13 +1,10 @@
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
// LoginPage —— 用户名+密码唯一入口;
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
// grant_setup 与 one_time_password 免当前密码,current_required 必须校验当前密码
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
import { useEffect, useState } from 'react'
import { api, type PasswordChangeMode } from '../api'
// 登录与改密(管理员发放制,R2):
// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口;
// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。
import { useState } from 'react'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark } from '@ui'
import { ForcedPasswordModeSection, isForcedPasswordMode, requiresCurrentPassword, submitForcedPassword } from './passwordChangeMode'
import { humanChangePasswordError, humanLoginError } from './loginErrors'
function Err({ msg }: { msg: string }) {
if (!msg) return null
@@ -31,12 +28,12 @@ function BrandPanel() {
)
}
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
const [tab, setTab] = useState<'password' | 'apikey'>('password')
const [owner, setOwner] = useState('')
const [password, setPassword] = useState('')
const [key, setKey] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
@@ -44,9 +41,10 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
setErr(''); setBusy(true)
try {
const me = await api.login(owner.trim(), password)
// 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明;
// 受限会话先进设密门,完成前不放行。
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
auth.beginSession()
if (me.mustChangePassword) {
// 初始/重置密码:先完成强制改密再放行。
onMustChange(me.owner)
return
}
@@ -56,61 +54,81 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
} finally { setBusy(false) }
}
const submitKey = async () => {
setErr(''); setBusy(true)
auth.setKey(key)
try {
const r = await auth.fetch('/api/health')
if (r.ok) {
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
// 不放行进死路改密门,引导改走账号密码登录。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) {
auth.clear()
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
return
}
onAuthed(); return
}
throw new Error(String(r.status))
} catch {
auth.clear()
setErr('密钥无效或已撤销,请重试')
} finally { setBusy(false) }
}
return (
<div className="lg-wrap">
<BrandPanel />
<div className="lg-formside">
<form className="lg-card" onSubmit={e => { e.preventDefault(); submitPassword() }}>
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">登录</h2>
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员重置临时密码</p>
<label className="lg-label">用户名</label>
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
<label className="lg-label">密码</label>
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
<p className="lg-sub">账号由管理员发放;首次登录需修改初始密码</p>
<div className="lg-tabs" role="tablist">
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
<button type="button" role="tab" aria-selected={tab === 'apikey'} className={`lg-tab ${tab === 'apikey' ? 'on' : ''}`} onClick={() => setTab('apikey')}>API 密钥</button>
</div>
{tab === 'password' ? (
<>
<label className="lg-label">用户名</label>
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
<label className="lg-label">密码</label>
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
</>
) : (
<>
<label className="lg-label">API 访问密钥</label>
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="ask_…" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '验证中…' : '进入'}</button>
<p className="lg-hint">密钥由服务端创建:<code>agent-system apikey create --owner &lt;name&gt;</code>。程序化客户端也可直接带 Authorization 头。</p>
</>
)}
</form>
</div>
</div>
)
}
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
const [mode, setMode] = useState<PasswordChangeMode | null>(null)
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
useEffect(() => {
let active = true
api.me().then(me => {
if (!active) return
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
!isForcedPasswordMode(me.passwordChangeMode)) {
setErr('当前设密凭据不再有效,请重新登录。')
return
}
setMode(me.passwordChangeMode)
}).catch(() => { if (active) setErr('无法确认当前设密方式,请重新登录。') })
return () => { active = false }
}, [owner])
const submit = async () => {
setErr('')
if (!mode) { setErr('设密方式尚未由服务端确认'); return }
if (requiresCurrentPassword(mode) && !current) { setErr('请填写当前密码'); return }
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await submitForcedPassword(mode, current, next)
setDone(true)
await api.changePassword(current, next)
onDone()
} catch (e) {
setErr(humanChangePasswordError(String(e)))
} finally { setBusy(false) }
@@ -120,29 +138,42 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
<div className="lg-wrap">
<BrandPanel />
<div className="lg-formside">
{done ? (
<form className="lg-card" onSubmit={e => { e.preventDefault(); onDone() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">密码已更新</h2>
<p className="lg-sub">请使用新密码重新登录账号 <b>{owner}</b></p>
<button className="lg-submit" type="submit">返回登录</button>
</form>
) : (
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">设置新密码</h2>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
<ForcedPasswordModeSection mode={mode} current={current} onCurrentChange={setCurrent} />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !mode || (requiresCurrentPassword(mode) && !current) || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
</form>
)}
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">修改密码</h2>
<p className="lg-sub">账号 <b>{owner}</b> 使用初始/重置密码首次登录,须设置新密码后继续</p>
<label className="lg-label">当前密码(初始/重置密码)</label>
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus autoComplete="current-password" />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '设置新密码并进入'}</button>
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>返回登录</button>}
</form>
</div>
</div>
)
}
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
-8
View File
@@ -1,8 +0,0 @@
/** Stable server time, with the full local date available to keyboard and assistive readers. */
export function MessageTimestamp({ value }: { value?: string }) {
const date = value ? new Date(value) : null
if (!date || !Number.isFinite(date.getTime())) return null
const full = date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
const label = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
return <time className="message-timestamp" dateTime={date.toISOString()} title={full} aria-label={full}>{label}</time>
}
-63
View File
@@ -1,63 +0,0 @@
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
import { useEffect, useRef, useState } from 'react'
import { api, type AccountProfile, type AccountRecord } from '../api'
import { useStore } from '../store'
import { Collapsible, Icon, SettingsGroup } from '@ui'
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
]
export function MyAccountSettings() {
const me = useStore(s => s.me)
const [account, setAccount] = useState<AccountRecord | null>(null)
const [draft, setDraft] = useState<AccountProfile | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const sequence = useRef(0)
useEffect(() => {
if (!me?.auth) return
const seq = ++sequence.current
api.meProfile().then(profile => {
if (seq !== sequence.current) return
setAccount(profile); setDraft(profile.profile)
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
return () => { sequence.current++ }
}, [me?.owner, me?.auth])
if (!me?.auth) return null
const save = async () => {
if (!account || !draft) return
const patch: Partial<AccountProfile> = {}
for (const field of fields) if (draft[field.key] !== account.profile[field.key]) patch[field.key] = draft[field.key]
if (Object.keys(patch).length === 0) { setNotice('资料没有变化'); return }
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.patchMeProfile(account.profileVersion, patch)
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存')
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
finally { setBusy(false) }
}
return <SettingsGroup title="我的账号">
{account && draft ? <>
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空。</p>
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
<input id={`my-profile-${field.key}`} className="lg-input" value={draft[field.key]}
onChange={event => setDraft({ ...draft, [field.key]: event.target.value })} />
</div>)}
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
</Collapsible>
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密),原密码随生成即失效。</p>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
</SettingsGroup>
}
+4 -7
View File
@@ -24,7 +24,6 @@ export function PreviewPane() {
})
: undefined
const art = active ? artifacts.find(a => a.id === active.id) : undefined
const contentUrl=art?api.artifactContentUrl(art.id)+(active?.sourceContext?.expectedHash?`?expectedHash=${encodeURIComponent(active.sourceContext.expectedHash)}`:''):''
const textKind = !!art && isTextKind(art)
const [content, setContent] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
@@ -36,12 +35,12 @@ export function PreviewPane() {
setContent(null)
if (!art || !isTextKind(art)) return
let cancelled = false
fetch(contentUrl)
.then(r => {if(!r.ok)throw new Error('source unavailable');return r.text()})
fetch(api.artifactContentUrl(art.id))
.then(r => r.text())
.then(text => { if (!cancelled) setContent(text) })
.catch(() => { if (!cancelled) setContent('(读取失败)') })
return () => { cancelled = true }
}, [art?.id,contentUrl])
}, [art?.id])
if (!active) return null
if (active.id.startsWith('surface:')) {
@@ -97,7 +96,6 @@ export function PreviewPane() {
await api.saveArtifactContent(art.id, content ?? '')
setDirty(false)
store.toast('已保存')
const conv=store.getState().currentConvId;if(conv)void store.refreshCards(conv)
}}>保存</Btn>}
</>
)}
@@ -110,7 +108,6 @@ export function PreviewPane() {
</>
)}
</div>
{active.sourceContext&&<div className="acard-status-note" role="note"><p>{active.sourceContext.notice}</p>{active.sourceContext.quote&&<blockquote>{active.sourceContext.quote}</blockquote>}</div>}
<div className="preview-pane-body">
{editing && textKind ? (
<textarea className="search-input preview-pane-editor" value={content ?? ''}
@@ -121,7 +118,7 @@ export function PreviewPane() {
id: art.id,
name: art.name,
mime: art.mime,
contentUrl,
contentUrl: api.artifactContentUrl(art.id),
downloadUrl: api.artifactDownloadUrl(art.id),
}} />
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
+3 -12
View File
@@ -1,6 +1,6 @@
// 执行记录按工作动作分组;展开状态由调用方控制。
import type { ReactNode } from 'react'
import { Collapsible, ToolBlock } from '@ui'
import { ToolBlock } from '@ui'
import type { Part } from '../contracts'
import { formatDuration, humanSize } from '../messageparts'
import { timelineEntries, toolGroupLabel, type ToolGroupItem } from '../toolkind'
@@ -15,12 +15,8 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
return (
<div className="tl">
{rows.map((row, i) => {
if (row.kind === 'thinking') {
return row.text && <div className="tl-thinking" key={`${idKey}:th:${i}`}>
<span className="tl-thinking-label">思考过程</span>
<div className="tl-thinking-body">{row.text}</div>
</div>
}
// 模型内部思考不是用户工作内容,也不属于可展开的执行记录。
if (row.kind === 'thinking') return null
if (row.kind === 'text') {
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
}
@@ -42,11 +38,6 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
<span className="tl-tools-name">{it.headline}</span>
{it.status === 'error' && <span className="status-error">失败</span>}
{it.summary && <span className="tl-tools-sum">{it.summary}</span>}
{(it.arguments || it.output) && <Collapsible id={`tool-record:${idKey}:${it.id}:${j}`}
triggerClassName="tl-tool-detail" trigger={<span>查看记录 ▸</span>}>
{it.arguments && <pre className="tool-preview">{it.arguments}</pre>}
{it.output && <pre className="tool-preview">{it.output}</pre>}
</Collapsible>}
</li>
))}
</ul>
+12 -32
View File
@@ -8,8 +8,6 @@ import type { SettingDesc } from '../contracts'
import { SettingsShell, Btn, EmptyState } from '@ui'
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections'
import { rdmProjectReadError } from '../rdmManagement'
import { projectActionError, projectWorkError } from '../projectmeta'
export function ProjectSettingsModal() {
const projectId = useStore(s => s.ui.projectSettingsId)
@@ -18,31 +16,21 @@ export function ProjectSettingsModal() {
const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable)
const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable)
const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable)
const owner = useStore(s => s.me?.owner)
const isAdmin = useStore(s => s.me?.isAdmin ?? false)
// P1-B 裁定:宿主覆盖设置(含模型与密钥)仅项目负责人/系统管理员可查看与修改;
// RDM 页签(成员/文档职责/项目权限)不在此列——数据面由 RDM 权限自行收敛,
// 授权的职责管理者仍可进入。
const projectPrivileged = !!project && (project.owner === owner || isAdmin)
const [tab, setTab] = useState('project')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [draft, setDraft] = useState<Record<string, any>>({})
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!projectId) return
let active = true
setLoaded(false); setSaved(false); setError(''); setDraft({}); setSchema([])
setLoaded(false)
api.getSettings(projectId).then(s => {
if (!active) return
setSchema(s.schema)
setDraft(flatten(s.project))
setLoaded(true)
}).catch(() => { if (active) setError('项目设置未能加载,请关闭后重新打开或联系项目负责人') })
return () => { active = false }
}, [projectId, owner])
}).catch(() => undefined)
}, [projectId])
const groups = useMemo(() => {
const g: Record<string, SettingDesc[]> = {}
@@ -51,19 +39,15 @@ export function ProjectSettingsModal() {
}, [schema])
if (!projectId) return null
const rdmError = rdmProjectReadError(project)
const writeError = projectWorkError(project)
const rdmTab = tab === 'members' || tab === 'duties' || tab === 'matrix'
const save = async () => {
if (!projectId) return
if (writeError) { store.toast(writeError); return }
try {
await api.putProjectSettings(projectId, unflatten(draft))
setSaved(true)
setTimeout(() => setSaved(false), 1500)
} catch (e) {
store.toast(`项目设置保存失败:${projectActionError(e)}`)
store.toast(`项目设置保存失败:${e}`)
}
}
@@ -77,25 +61,21 @@ export function ProjectSettingsModal() {
{ key: 'project', label: '本项目', icon: 'folder' },
// U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。
...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []),
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责', icon: 'shield' as const }] : []),
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责 · 整文审签', icon: 'shield' as const }] : []),
...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
]}
footer={saved ? <span style={{ color: 'var(--ok-text)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
>
<div>
{rdmTab && rdmError ? <EmptyState icon="folder">{rdmError}</EmptyState> : project && <>
{tab === 'members' && <RdmMembersSection key={project.id} project={project} />}
{tab === 'duties' && <RdmDutiesSection key={project.id} project={project} />}
{tab === 'matrix' && <RdmPermMatrixSection key={project.id} project={project} />}
</>}
{tab === 'members' && <RdmMembersSection projectId={projectId} />}
{tab === 'duties' && <RdmDutiesSection projectId={projectId} />}
{tab === 'matrix' && <RdmPermMatrixSection projectId={projectId} />}
{tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? (
<EmptyState icon="settings">{error || '加载中…'}</EmptyState>
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !projectPrivileged ? (
<EmptyState icon="settings">项目覆盖设置(含模型与密钥)仅项目负责人或系统管理员可查看与修改。</EmptyState>
<EmptyState icon="settings">加载中…</EmptyState>
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? (
<>
<div className="empty-note" style={{ marginBottom: 10 }}>
{writeError ?? '此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。'}
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
</div>
{Object.entries(groups).map(([g, descs]) => (
<div className="settings-group" key={g}>
@@ -111,7 +91,7 @@ export function ProjectSettingsModal() {
))}
</div>
))}
<Btn variant="primary" icon="check" disabled={!!writeError} onClick={save}>保存项目设置</Btn>
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
</>
) : null}
</div>
-84
View File
@@ -1,84 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { startLatestFetch, TreeNodeRow } from './RdmAuditWorkspace'
function deferred<T>() {
let resolve!: (v: T) => void
let reject!: (e: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
const flush = () => new Promise(r => setTimeout(r, 0))
describe('startLatestFetch(历史请求单一所有者)', () => {
it('较新的请求已发起时丢弃过期响应', async () => {
const ref = { current: 0 }
const onData = vi.fn()
const onError = vi.fn()
const older = deferred<string>()
const newer = deferred<string>()
startLatestFetch(ref, () => older.promise, onData, onError)
startLatestFetch(ref, () => newer.promise, onData, onError)
newer.resolve('newer')
await flush()
older.resolve('older')
await flush()
expect(onData.mock.calls).toEqual([['newer']])
expect(onError).not.toHaveBeenCalled()
})
it('过期请求的失败不写回错误', async () => {
const ref = { current: 0 }
const onError = vi.fn()
const older = deferred<string>()
startLatestFetch(ref, () => older.promise, vi.fn(), onError)
startLatestFetch(ref, () => new Promise<string>(() => {}), vi.fn(), onError)
older.reject(new Error('stale'))
await flush()
expect(onError).not.toHaveBeenCalled()
})
it('清理(卸载或参数变化)后在途响应被丢弃', async () => {
const ref = { current: 0 }
const onData = vi.fn()
const pending = deferred<string>()
const cleanup = startLatestFetch(ref, () => pending.promise, onData, vi.fn())
cleanup()
pending.resolve('late')
await flush()
expect(onData).not.toHaveBeenCalled()
})
it('只推进自己的 ref,不影响其他 seq 所有者', () => {
const historyRef = { current: 0 }
const eventsRef = { current: 7 }
startLatestFetch(historyRef, () => new Promise<string>(() => {}), vi.fn(), vi.fn())
expect(historyRef.current).toBe(1)
expect(eventsRef.current).toBe(7)
})
})
describe('TreeNodeRow 无障碍属性', () => {
const noop = () => {}
const render = (item: { kind: string; id: string; title: string; hasChildren?: boolean }, children = {}) =>
renderToStaticMarkup(<TreeNodeRow item={item} children={children} onSelect={noop} onLoadMore={noop} onOpenDoc={noop} />)
it('可展开行:折叠时 aria-expanded=false,箭头 aria-hidden', () => {
const html = render({ kind: 'spine', id: 's1', title: '骨架' })
expect(html).toContain('aria-expanded="false"')
expect(html).toMatch(/<span class="rdm-audit-tree-caret" aria-hidden="true">▸<\/span>/)
})
it('可展开行:已加载下级时 aria-expanded=true', () => {
const html = render({ kind: 'spine', id: 's1', title: '骨架' }, { s1: { items: [], hasMore: false, truncated: false } })
expect(html).toContain('aria-expanded="true"')
expect(html).toMatch(/<span class="rdm-audit-tree-caret" aria-hidden="true">▾<\/span>/)
})
it('不可展开行省略 aria-expanded 与箭头', () => {
const html = render({ kind: 'doc', id: 'd1', title: '文档' })
expect(html).not.toContain('aria-expanded')
expect(html).not.toContain('rdm-audit-tree-caret')
})
})
+138 -528
View File
@@ -5,37 +5,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
import {
describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable,
relationOtherEndpoint,
type AuditEventViewInput,
} from '../rdmAuditPresentation'
import {
collectRecursiveTree, isTreeItemInspectable, mergeTreePage,
type AuditTreeItem, type AuditTreePage,
} from '../rdmAuditTree'
import { store, useStore } from '../store'
import { exportNeedsDraft } from '../workspaces'
import {
CITATION_CHIP_TONE, citationStateLabel, citationStyle, legStateLabel, REG_TREE_KIND_LABELS,
regsTreeParams, sealStateLabel,
type RegBatchDetail, type RegCitationDetail,
} from '../regCitations'
import { DocumentRenderWorkspace, saveBase64File } from './DocumentRenderWorkspace'
import { CitationCard, CitationListItem, CitationStateChips } from './RegCitation'
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
interface InspectProject { projectId: string; name: string; canAudit: boolean }
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
interface CoverageItem { face: string; reason: string }
interface ListShell<T = any> { items: T[]; businessAudit?: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
interface ListShell<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
type TreeItem = AuditTreeItem
interface TreeItem { kind: string; id: string; title: string; state?: string }
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
interface EventDTO extends AuditEventViewInput {}
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; parentId?: string; childCount?: number; sliceCount?: number; mounts?: string[]; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
// ---- 工作区主组件 ----
@@ -48,23 +30,6 @@ const TABS = [
] as const
type TabId = (typeof TABS)[number]['id']
const OPERATION_FILTERS = [
['', '全部操作'],
['doc.instantiate', '创建文档实例'],
['section.add', '新增文档章节'],
['slice.ingest', '新增项目资料'],
['edge.candidate', '提出追溯候选'],
['edge.confirm', '确认追溯关系'],
['change.requested', '提出变更申请'],
['change.applied', '应用变更'],
['open-item.add', '登记项目问题'],
['open-item.close', '关闭项目问题'],
['rdm.work.observe', '拆解工作来源'],
['rdm.work.risk', '发现项目风险'],
['rdm.work.risk.answer', '回答风险澄清'],
['rdm.structure.decide', '决定结构变更'],
] as const
/** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断;
* 「无可审计项目」由调用处独立空态承担,不走本组件。 */
function RdmErrorState({ raw }: { raw: string }) {
@@ -80,30 +45,22 @@ function RdmErrorState({ raw }: { raw: string }) {
}
export function RdmAuditWorkspace() {
const identity = useStore(() => store.getIdentityEpoch())
const [caps, setCaps] = useState<Capabilities | null>(null)
const [capsIdentity, setCapsIdentity] = useState(-1)
const [pid, setPid] = useState('')
const [tab, setTab] = useState<TabId>('summary')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
setCaps(null); setPid(''); setError('')
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
.then(c => {
if (cancelled || identity !== store.getIdentityEpoch()) return
setCaps(c); setCapsIdentity(identity)
if (c.projects.length > 0) setPid(c.projects[0].projectId)
})
.catch(e => { if (!cancelled && identity === store.getIdentityEpoch()) setError(e.message) })
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
.catch(e => { if (!cancelled) setError(e.message) })
return () => { cancelled = true }
}, [identity])
}, [])
const project = caps?.projects.find(p => p.projectId === pid)
const exportOn = useStore(s => s.rdmExportAvailable)
if (error) return <RdmErrorState raw={error} />
if (!caps || capsIdentity !== identity) return <div className="empty-note">加载中…</div>
if (!caps) return <div className="empty-note">加载中…</div>
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
return (
@@ -125,8 +82,8 @@ export function RdmAuditWorkspace() {
))}
</div>
<div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
{tab === 'materials' && <MaterialsTab key={pid} pid={pid} />}
{tab === 'summary' && <SummaryTab pid={pid} />}
{tab === 'materials' && <MaterialsTab pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />}
</div>
@@ -136,7 +93,7 @@ export function RdmAuditWorkspace() {
// ---- 概览 ----
function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) {
function SummaryTab({ pid }: { pid: string }) {
const [data, setData] = useState<SummaryData | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
@@ -176,118 +133,6 @@ function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) {
{data.coverage && data.coverage.length > 0 && (
<CoverageList cov={data.coverage} />
)}
{exportOn && <ExportSection pid={pid} />}
</div>
)
}
// ---- 概览导出(三条命令都启用才挂上;不参与七命令就绪门) ----
interface ExportCreated {
exportId: string
fileName: string
watermark: number
sha256: string
draft?: boolean
}
interface ExportStatus {
expiresAt: string
sha256: string
watermark: number
}
interface ExportDownloaded {
fileName: string
mime: string
dataB64: string
sha256: string
}
function ExportSection({ pid }: { pid: string }) {
const identity = useStore(() => store.getIdentityEpoch())
const generation = useRef(0)
const [kind, setKind] = useState<'cgraph-docx' | 'cgraph-matrix'>('cgraph-docx')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [needDraft, setNeedDraft] = useState(false)
const [artifact, setArtifact] = useState<(ExportCreated & { expiresAt: string }) | null>(null)
useEffect(() => {
generation.current++
setArtifact(null)
setErr('')
setNeedDraft(false)
setBusy(false)
return () => { generation.current++ }
}, [pid, identity])
const generate = async (draft: boolean) => {
const request = ++generation.current
const current = () => request === generation.current && identity === store.getIdentityEpoch()
setBusy(true)
setErr('')
setNeedDraft(false)
try {
const created = await api.rdmCommand<ExportCreated>('rdm.inspect.export.create', { projectId: pid, kind, draft })
if (!current()) return
const st = await api.rdmCommand<ExportStatus>('rdm.inspect.export.status', { projectId: pid, exportId: created.exportId })
if (!current()) return
setArtifact({ ...created, expiresAt: st.expiresAt })
} catch (e) {
if (!current()) return
const msg = e instanceof Error ? e.message : String(e)
if (!draft && exportNeedsDraft(msg)) setNeedDraft(true)
else setErr(msg)
} finally {
if (current()) setBusy(false)
}
}
const download = async () => {
if (!artifact) return
const request = ++generation.current
const current = () => request === generation.current && identity === store.getIdentityEpoch()
setBusy(true)
setErr('')
try {
const dl = await api.rdmCommand<ExportDownloaded>('rdm.inspect.export.download', { projectId: pid, exportId: artifact.exportId })
if (!current()) return
saveBase64File(dl.fileName, dl.mime, dl.dataB64)
} catch (e) {
if (current()) setErr(e instanceof Error ? e.message : String(e))
} finally {
if (current()) setBusy(false)
}
}
return (
<div className="rdm-audit-export">
<SectionLabel>导出</SectionLabel>
<div className="rdm-audit-export-row">
<label className="rdm-audit-export-kind">
类型
<select className="ui-input" aria-label="导出类型" value={kind} onChange={e => setKind(e.target.value as 'cgraph-docx' | 'cgraph-matrix')}>
<option value="cgraph-docx">合规追溯报告</option>
<option value="cgraph-matrix">追溯矩阵</option>
</select>
</label>
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(false) }}>生成</button>
</div>
{needDraft && (
<div className="rdm-audit-export-draft">
<p>正式报告不完整。可以生成带缺口标注的草稿。</p>
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(true) }}>生成草稿</button>
</div>
)}
{err && <div className="rdm-audit-export-err">{err}</div>}
{artifact && (
<div className="rdm-audit-export-result">
<div>水位 {artifact.watermark}</div>
<div>sha256 {artifact.sha256}</div>
<div>有效期 {artifact.expiresAt ? new Date(artifact.expiresAt).toLocaleString('zh-CN') : '—'}</div>
{artifact.draft && <div>草稿</div>}
<button type="button" className="btn v-ghost" disabled={busy} onClick={() => { void download() }}>下载</button>
</div>
)}
</div>
)
}
@@ -295,332 +140,115 @@ function ExportSection({ pid }: { pid: string }) {
// ---- 资料与文档 ----
function MaterialsTab({ pid }: { pid: string }) {
const [kind, setKind] = useState<'notes' | 'docs' | 'regs'>('docs')
// 打开文档时树列表保持挂载(只隐藏),返回后展开状态不丢。
const [openDoc, setOpenDoc] = useState<TreeItem | null>(null)
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
return (
<div className="rdm-audit-materials">
{openDoc && (
<DocumentRenderWorkspace projectId={pid} docId={openDoc.id} docTitle={openDoc.title} onBack={() => setOpenDoc(null)} />
)}
<div className="rdm-audit-materials-side" hidden={!!openDoc}>
<div className="rdm-audit-materials-side">
<div className="tabs">
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
<button className={`tab${kind === 'regs' ? ' active' : ''}`} onClick={() => setKind('regs')}>法规核验</button>
</div>
{kind === 'regs' ? <RegsTreeList pid={pid} /> : <TreeList pid={pid} kind={kind} onOpenDoc={setOpenDoc} />}
<TreeList key={`${pid}:${kind}`} pid={pid} kind={kind} />
</div>
</div>
)
}
// ---- 法规核验树(U7:rdm.inspect.tree kind=regs;根层证据批,parent=<batchId> 列引用) ----
function RegTreeNode({ item, selected, onSelect }: { item: TreeItem; selected: boolean; onSelect: () => void }) {
return (
<button className={`rdm-audit-tree-node${selected ? ' sel' : ''}`} onClick={onSelect}>
<span className="rdm-audit-tree-kind">{REG_TREE_KIND_LABELS[item.kind] || item.kind}</span>
<span className="rdm-audit-tree-title">{item.title || item.id}</span>
{item.state && <Chip tone={CITATION_CHIP_TONE[citationStyle({ state: item.state })]}>{citationStateLabel(item.state)}</Chip>}
</button>
)
}
function RegsTreeList({ pid }: { pid: string }) {
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
const [err, setErr] = useState('')
const [children, setChildren] = useState<Record<string, ListShell<TreeItem>>>({})
const [selected, setSelected] = useState<TreeItem | null>(null)
const [detail, setDetail] = useState<RegBatchDetail | RegCitationDetail | null>(null)
const [detailErr, setDetailErr] = useState('')
// genRef:项目切换代际(树与子页);selRef:选中代际(快速切换时丢弃旧详情响应)。
const genRef = useRef(0)
const selRef = useRef(0)
useEffect(() => {
const gen = ++genRef.current
selRef.current++
setShell(null); setErr(''); setSelected(null); setDetail(null); setDetailErr(''); setChildren({})
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid))
.then(s => { if (gen === genRef.current) setShell({ ...s, items: s.items || [] }) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid])
const loadMore = () => {
if (!shell?.nextCursor) return
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid, { cursor: shell.nextCursor }))
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...(s.items || [])] } : s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const loadChildren = (batchId: string, cursor?: string) => {
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid, { parent: batchId, cursor }))
.then(s => {
if (gen !== genRef.current) return
setChildren(prev => {
const items = cursor ? [...(prev[batchId]?.items || []), ...(s.items || [])] : (s.items || [])
return { ...prev, [batchId]: { ...s, items } }
})
})
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const toggleBatch = (item: TreeItem) => {
if (children[item.id]) {
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
return
}
loadChildren(item.id)
}
const selectNode = (item: TreeItem) => {
setSelected(item); setDetail(null); setDetailErr('')
const gen = genRef.current
const sel = ++selRef.current
const current = () => gen === genRef.current && sel === selRef.current
api.rdmCommand<RegBatchDetail | RegCitationDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
.then(d => { if (current()) setDetail(d) })
.catch(e => { if (current()) setDetailErr(e.message) })
}
if (err) return <RdmErrorState raw={err} />
if (!shell) return <div className="empty-note">加载中…</div>
if (shell.items.length === 0) return <EmptyState icon="folder">暂无法规核验证据批</EmptyState>
return (
<div className="rdm-audit-tree">
<div className="rdm-audit-tree-list">
{shell.items.map(item => {
const sub = children[item.id]
return (
<div key={item.id}>
<div className="regcite-tree-row">
<button type="button" className="regcite-tree-caret" aria-label={sub ? '收起引用' : '展开引用'}
aria-expanded={!!sub} onClick={() => toggleBatch(item)}>{sub ? '▾' : '▸'}</button>
<RegTreeNode item={item} selected={selected?.id === item.id} onSelect={() => selectNode(item)} />
</div>
{sub && (
<div className="rdm-audit-tree-children">
{sub.items.length === 0 && <div className="empty-note">无引用</div>}
{sub.items.map(c => (
<RegTreeNode key={c.id} item={c} selected={selected?.id === c.id} onSelect={() => selectNode(c)} />
))}
{sub.hasMore && (
<button className="btn v-ghost rdm-audit-more" onClick={() => loadChildren(item.id, sub.nextCursor)}>加载更多引用</button>
)}
{sub.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
)}
</div>
)
})}
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
{selected && (
<div className="rdm-audit-detail">
<SectionLabel>核验详情</SectionLabel>
{detailErr ? <RdmErrorState raw={detailErr} />
: !detail ? <div className="empty-note">加载中…</div>
: selected.kind === 'reg-batch'
? <RegBatchView item={selected} detail={detail as RegBatchDetail} />
: <RegCitationView item={selected} detail={detail as RegCitationDetail} />}
</div>
)}
</div>
)
}
function RegBatchView({ item, detail }: { item: TreeItem; detail: RegBatchDetail }) {
const reasons = detail.stateReasons || []
const citations = detail.citations || []
const resolutions = detail.resolutions || []
return (
<div className="rdm-audit-detail-body">
<div><strong>{detail.title || item.title || item.id}</strong></div>
<div className="regcite-mono">reg-batch:{item.id}</div>
<div>状态:<CitationStateChips c={{ state: detail.state }} /></div>
{reasons.length > 0 && (
<div>
<SectionLabel>状态原因</SectionLabel>
<ul className="regcite-reasons">{reasons.map((r, i) => <li key={i}>{r}</li>)}</ul>
</div>
)}
{detail.legStates && (
<div>
<SectionLabel>来源腿</SectionLabel>
<dl className="regcite-fields">
<dt>知识库</dt><dd>{legStateLabel(detail.legStates.kb, detail.legStates.kbReason)}</dd>
<dt>网络</dt><dd>{legStateLabel(detail.legStates.web, detail.legStates.webReason)}</dd>
</dl>
</div>
)}
{detail.seal && (
<div>
<SectionLabel>封存</SectionLabel>
<dl className="regcite-fields">
<dt>状态</dt><dd>{sealStateLabel(detail.seal.state)}</dd>
<dt>密钥</dt><dd className="regcite-mono">{detail.seal.keyId || '—'}</dd>
<dt>清单哈希</dt><dd className="regcite-mono">{detail.seal.manifestSha256 || '—'}</dd>
</dl>
</div>
)}
<div>
<SectionLabel>引用({citations.length})</SectionLabel>
{citations.length === 0 && <div className="empty-note">该批没有引用</div>}
{citations.map(c => <CitationListItem key={c.id} id={c.id} citation={c} scope={`audit-${item.id}`} />)}
</div>
{resolutions.length > 0 && (
<div>
<SectionLabel>人工裁决({resolutions.length})</SectionLabel>
{resolutions.map((r, i) => (
<div key={i} className="rdm-audit-version">
{r.decision} · {r.actor} · {new Date(r.at).toLocaleString('zh-CN')}{r.note ? ` · ${r.note}` : ''}
</div>
))}
</div>
)}
</div>
)
}
function RegCitationView({ item, detail }: { item: TreeItem; detail: RegCitationDetail }) {
return (
<div className="rdm-audit-detail-body">
<div><strong>{detail.title || item.title || item.id}</strong></div>
<CitationCard id={item.id} citation={detail.citation} scope="audit" />
</div>
)
}
function TreeList({ pid, kind, onOpenDoc }: { pid: string; kind: 'notes' | 'docs'; onOpenDoc: (doc: TreeItem) => void }) {
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
const [err, setErr] = useState('')
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
const [selected, setSelected] = useState<TreeItem | null>(null)
const [detail, setDetail] = useState<ObjectDetail | null>(null)
const [detailErr, setDetailErr] = useState('')
const [children, setChildren] = useState<Record<string, AuditTreePage>>({})
const [detailError, setDetailError] = useState('')
const genRef = useRef(0)
const detailGenRef = useRef(0)
useEffect(() => {
useEffect(() => () => { genRef.current++ }, [])
const selectNode = useCallback((item: TreeItem) => {
const gen = ++genRef.current
detailGenRef.current++
setShell(null); setErr(''); setSelected(null); setDetail(null); setDetailErr(''); setChildren({})
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
.then(s => { if (gen === genRef.current) setShell(s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, kind])
const loadMore = () => {
if (!shell?.nextCursor) return
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: shell.nextCursor })
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...s.items] } : s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const toggleNode = (item: TreeItem) => {
if (children[item.id]) {
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
return
}
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, parent: item.id, cursor: '' })
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s })) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const loadMoreChildren = (item: TreeItem) => {
const current = children[item.id]
if (!current?.nextCursor) return
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', {
projectId: pid, kind, parent: item.id, cursor: current.nextCursor,
})
.then(s => {
if (gen !== genRef.current) return
setChildren(prev => ({ ...prev, [item.id]: mergeTreePage(prev[item.id], s) }))
})
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const selectNode = (item: TreeItem) => {
if (isTreeItemExpandable(item)) toggleNode(item)
const detailGen = ++detailGenRef.current
if (!isTreeItemInspectable(item)) {
setSelected(null); setDetail(null); setDetailErr('')
return
}
setSelected(item); setDetail(null); setDetailErr('')
setSelected(item); setDetail(null); setDetailError('')
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
.then(d => { if (detailGen === detailGenRef.current) setDetail(d) })
.catch(e => { if (detailGen === detailGenRef.current) setDetailErr(e.message) })
}
if (err) return <RdmErrorState raw={err} />
if (!shell) return <div className="empty-note">加载中…</div>
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
.then(d => { if (gen === genRef.current) setDetail(d) })
.catch(e => { if (gen === genRef.current) setDetailError(e.message) })
}, [pid])
return (
<div className="rdm-audit-tree">
<div className="rdm-audit-tree-list">
{shell.items.map(item => <TreeNodeRow key={item.id} item={item} selectedId={selected?.id} children={children} onSelect={selectNode} onLoadMore={loadMoreChildren} onOpenDoc={onOpenDoc} />)}
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
<TreeLevel pid={pid} kind={kind} parent="" selected={selected} onSelect={selectNode} />
{selected && (
<div className="rdm-audit-detail">
<SectionLabel>对象详情</SectionLabel>
{detailErr ? <RdmErrorState raw={detailErr} /> : !detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
{detailError ? <>
<RdmErrorState raw={detailError} />
<button className="btn v-ghost" onClick={() => selectNode(selected)}>重试详情</button>
</> : detail ? <ObjectDetailView detail={detail} /> : <div className="empty-note">加载中…</div>}
</div>
)}
</div>
)
}
export function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore, onOpenDoc }: {
item: TreeItem
selectedId?: string
children: Record<string, AuditTreePage>
onSelect: (item: TreeItem) => void
onLoadMore: (item: TreeItem) => void
onOpenDoc: (doc: TreeItem) => void
}) {
const expandable = isTreeItemExpandable(item)
const nestedPage = children[item.id]
const nested = nestedPage?.items
const readable = describeTreeItem(item)
interface TreeLevelProps { pid: string; kind: 'notes' | 'docs'; parent: string; selected: TreeItem | null; onSelect: (item: TreeItem) => void }
function TreeLevel({ pid, kind, parent, selected, onSelect }: TreeLevelProps) {
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const genRef = useRef(0)
const cursorRef = useRef('')
const load = useCallback((cursor = '') => {
const gen = ++genRef.current
cursorRef.current = cursor
setLoading(true); setError('')
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, parent, cursor })
.then(next => {
if (gen !== genRef.current) return
setShell(prev => cursor && prev ? { ...next, items: [...prev.items, ...next.items] } : next)
})
.catch(e => { if (gen === genRef.current) setError(e.message) })
.finally(() => { if (gen === genRef.current) setLoading(false) })
}, [pid, kind, parent])
useEffect(() => {
load()
return () => { genRef.current++ }
}, [load])
return (
<div>
<div className="rdm-audit-tree-row">
<button className={`rdm-audit-tree-node${selectedId === item.id ? ' sel' : ''}`} onClick={() => onSelect(item)}
aria-expanded={expandable ? Boolean(nested) : undefined}>
{expandable && <span className="rdm-audit-tree-caret" aria-hidden="true">{nested ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{readable.kind}</span>
<span className="rdm-audit-tree-title">{readable.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : item.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(item.state)}</Chip>}
</button>
{item.kind === 'doc' && (
<button type="button" className="btn v-ghost rdm-audit-tree-open" onClick={() => onOpenDoc(item)}
aria-label={`阅读与交付:${readable.title}`}>阅读与交付</button>
)}
</div>
{nested && (
<div className="rdm-audit-tree-children">
{nested.length === 0 && <div className="empty-note">无下级节点</div>}
{nested.map(child => <TreeNodeRow key={child.id} item={child} selectedId={selectedId} children={children} onSelect={onSelect} onLoadMore={onLoadMore} onOpenDoc={onOpenDoc} />)}
{nestedPage.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => onLoadMore(item)}>加载更多下级节点</button>}
{nestedPage.truncated && <div className="rdm-audit-truncated">该节点的下级内容已截断</div>}
</div>
)}
<div className="rdm-audit-tree-list">
{shell?.items.map(item => <TreeNode key={`${item.kind}:${item.id}`} item={item}
pid={pid} kind={kind} selected={selected} onSelect={onSelect} />)}
{!shell && loading && <div className="empty-note">加载中…</div>}
{shell?.items.length === 0 && <div className="empty-note">{parent ? kind === 'notes' ? '无子骨架或挂载切片' : '无章节' : kind === 'notes' ? '暂无骨架节点或切片' : '暂无文档'}</div>}
{error && <>
<RdmErrorState raw={error} />
<button className="btn v-ghost" disabled={loading} onClick={() => load(cursorRef.current)}>重试列表</button>
</>}
{shell?.hasMore && !error && <button className="btn v-ghost rdm-audit-more" disabled={loading}
onClick={() => load(shell.nextCursor)}>加载更多</button>}
{shell?.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
)
}
function TreeNode({ item, pid, kind, selected, onSelect }: Omit<TreeLevelProps, 'parent'> & { item: TreeItem }) {
const [open, setOpen] = useState(false)
const branch = item.kind === 'doc' || item.kind === 'spine'
const className = `rdm-audit-tree-node${selected?.kind === item.kind && selected.id === item.id ? ' sel' : ''}`
const label = <>
{branch && <span className="rdm-audit-tree-caret">{open ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{item.kind}</span>
<span className="rdm-audit-tree-title">{item.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
</>
if (!branch) return <button className={className} onClick={() => onSelect(item)}>{label}</button>
return <Collapsible open={open} trigger={label} triggerClassName={className}
bodyClassName="rdm-audit-tree-children" onToggle={next => {
setOpen(next)
if (item.kind === 'spine') onSelect(item)
}}>
<TreeLevel pid={pid} kind={kind} parent={item.id} selected={selected} onSelect={onSelect} />
</Collapsible>
}
// ---- 关联追溯 ----
function TraceTab({ pid }: { pid: string }) {
@@ -639,17 +267,21 @@ function TraceTab({ pid }: { pid: string }) {
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, sel])
const [treeItems, setTreeItems] = useState<TreeItem[]>([])
const [pickErr, setPickErr] = useState('')
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
// (parent=<docId>),按文档聚合后合并展示。
const [sections, setSections] = useState<TreeItem[]>([])
useEffect(() => {
if (sel) return
let c = false
;(async () => {
setTreeItems([]); setPickErr('')
const items = await collectRecursiveTree((parent, cursor) =>
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent, cursor }))
if (!c) setTreeItems(items.filter(item => item.kind !== 'doc'))
})().catch(e => { if (!c) setPickErr(e instanceof Error ? e.message : String(e)) })
const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
const secs: TreeItem[] = []
for (const d of top.items.filter(i => i.kind === 'doc')) {
const sub = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id })
secs.push(...sub.items.filter(i => i.kind === 'section'))
}
if (!c) setSections(secs)
})().catch(() => {})
return () => { c = true }
}, [pid, sel])
@@ -657,14 +289,13 @@ function TraceTab({ pid }: { pid: string }) {
return (
<div>
<SectionLabel>选择对象查看来源与影响</SectionLabel>
{pickErr ? <RdmErrorState raw={pickErr} /> : treeItems.length === 0 ? <EmptyState icon="folder">暂无可追溯对象</EmptyState> : (
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
<div className="rdm-audit-pick">
{treeItems.map(item => {
const readable = describeTreeItem(item)
return <button key={item.id} className="btn v-ghost" onClick={() => setSel({ kind: item.kind, id: item.id, title: readable.title })}>
{readable.kind} · {readable.title}
{sections.map(s => (
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
{s.title}
</button>
})}
))}
</div>
)}
</div>
@@ -679,7 +310,7 @@ function TraceTab({ pid }: { pid: string }) {
<div className="rdm-audit-trace-head">
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
<strong>{sel.title}</strong>
<Chip tone="plain">{humanizeKind(sel.kind)} · {sel.id}</Chip>
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
</div>
{err && <RdmErrorState raw={err} />}
{!relations && !err && <div className="empty-note">加载中…</div>}
@@ -688,11 +319,11 @@ function TraceTab({ pid }: { pid: string }) {
<div className="rdm-audit-trace-cols">
<div className="rdm-audit-trace-col">
<SectionLabel>来源依据({sources.length})</SectionLabel>
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} selected={sel} />)}
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
<div className="rdm-audit-trace-col">
<SectionLabel>影响对象({impacts.length})</SectionLabel>
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} selected={sel} />)}
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
</div>
)}
@@ -701,35 +332,20 @@ function TraceTab({ pid }: { pid: string }) {
)
}
function RelationRow({ r, selected }: { r: RelationEntry; selected: { kind: string; id: string } }) {
const other = relationOtherEndpoint(r, selected)
const readable = humanizeRelation(r)
function RelationRow({ r }: { r: RelationEntry }) {
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
return (
<div className="rdm-audit-rel">
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{readable.layer}</Chip>
<span className="rdm-audit-rel-type">{readable.type}</span>
<span className="rdm-audit-rel-endpoint">{humanizeKind(other?.kind)}“{other?.title || other?.id || '未知对象'}”{other?.ver ? `(版本 ${other.ver})` : ''}</span>
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' || r.state === 'stale' ? 'warn' : 'plain'}>{readable.state}</Chip>
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
</div>
)
}
// ---- 操作记录(事件时间线) ----
// 单一所有者:仅最近一次发起的请求可写回;返回的清理函数作废在途请求。
export function startLatestFetch<T>(
genRef: { current: number },
request: () => Promise<T>,
onData: (data: T) => void,
onError: (message: string) => void,
): () => void {
const gen = ++genRef.current
request()
.then(d => { if (gen === genRef.current) onData(d) })
.catch(e => { if (gen === genRef.current) onError(e instanceof Error ? e.message : String(e)) })
return () => { if (gen === genRef.current) genRef.current++ }
}
function TimelineTab({ pid }: { pid: string }) {
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
const [cursor, setCursor] = useState('')
@@ -741,7 +357,6 @@ function TimelineTab({ pid }: { pid: string }) {
const [historySeq, setHistorySeq] = useState(0)
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
const genRef = useRef(0)
const historyGenRef = useRef(0)
const load = useCallback((cur: string, replace: boolean) => {
const gen = ++genRef.current
@@ -752,8 +367,7 @@ function TimelineTab({ pid }: { pid: string }) {
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
.then(s => {
if (gen !== genRef.current) return
const page = replace ? [...(s.businessAudit || []), ...s.items] : s.items
setItems(prev => replace ? page : [...prev, ...page])
setItems(prev => replace ? s.items : [...prev, ...s.items])
setCursor(s.nextCursor || '')
setHasMore(s.hasMore)
})
@@ -764,9 +378,12 @@ function TimelineTab({ pid }: { pid: string }) {
useEffect(() => {
if (!historyMode) { setHistoryData(null); return }
const gen = genRef.current
setHistoryData(null)
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
return startLatestFetch(historyGenRef, () => api.rdmCommand<HistoryData>('rdm.inspect.history', params), setHistoryData, setErr)
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
.then(d => { if (gen === genRef.current) setHistoryData(d) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, historyMode, historySeq])
return (
@@ -774,9 +391,8 @@ function TimelineTab({ pid }: { pid: string }) {
<div className="rdm-audit-timeline-filters">
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
onChange={e => setFilterActor(e.target.value)} />
<select className="ui-input" aria-label="按操作筛选" value={filterOp} onChange={e => setFilterOp(e.target.value)}>
{OPERATION_FILTERS.map(([value, label]) => <option key={value || 'all'} value={value}>{label}</option>)}
</select>
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
onChange={e => setFilterOp(e.target.value)} />
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
{historyMode ? '← 返回当前' : '历史模式'}
</button>
@@ -827,38 +443,27 @@ function TimelineTab({ pid }: { pid: string }) {
}
function OpGroupView({ group }: { group: OpGroup }) {
const first = group.events[0] ? describeAuditEvent(group.events[0]) : null
const [open, setOpen] = useState(false)
return (
<div className="rdm-audit-opgroup">
<Collapsible id={`rdm-audit-operation-${group.operationId}`} trigger={(
<span className="rdm-audit-opgroup-head">
<span className="rdm-audit-opgroup-actor">{first?.subject || group.actor}</span>
<span className="rdm-audit-opgroup-action">{first?.action || '项目操作'}</span>
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
<Chip tone="plain">包含 {group.events.length} 项记录</Chip>
</span>
)}>
{group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
</Collapsible>
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
<Chip tone="plain">{group.events.length} 个事件</Chip>
</button>
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
</div>
)
}
function EventRow({ ev }: { ev: EventDTO }) {
const view = describeAuditEvent(ev)
return (
<div className="rdm-audit-event">
<div className="rdm-audit-event-head">
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleString('zh-CN')}</span>
<strong className="rdm-audit-event-action">{view.action}</strong>
<Chip tone={ev.disclosure === 'ok' && view.outcome === '已生效' ? 'ok' : 'warn'}>{view.outcome}</Chip>
</div>
<div className="rdm-audit-event-summary">{view.subject}处理了{view.object}</div>
<dl className="rdm-audit-event-fields">
<div><dt>原因</dt><dd>{view.reason}</dd></div>
<div><dt>影响</dt><dd>{view.impact}</dd></div>
<div><dt>依据</dt><dd>{view.source}</dd></div>
</dl>
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
<span className="rdm-audit-event-op">{ev.op}</span>
<span className="rdm-audit-event-actor">{ev.actor}</span>
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
</div>
)
}
@@ -869,8 +474,13 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
return (
<div className="rdm-audit-detail-body">
<div><strong>{detail.title || detail.id}</strong></div>
<div>{humanizeKind(detail.kind)} · 对象编号 {detail.id}{detail.ver ? ` · 版本 ${detail.ver}` : ''}</div>
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : detail.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(detail.state)}</Chip></div>}
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
{detail.kind === 'spine' && <>
<div>父骨架:{detail.parentId || '根节点'}</div>
<div>子骨架 {detail.childCount ?? 0} · 挂载切片 {detail.sliceCount ?? 0}</div>
</>}
{!!detail.mounts?.length && <div>挂载骨架:{detail.mounts.join('、')}</div>}
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
{detail.versions && detail.versions.length > 0 && (
<div>
@@ -890,7 +500,7 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
{detail.relations && detail.relations.length > 0 && (
<div>
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
{detail.relations.map((r, i) => <RelationRow key={i} r={r} selected={{ kind: detail.kind, id: detail.id }} />)}
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
)}
{detail.recentEvents && detail.recentEvents.length > 0 && (
-183
View File
@@ -1,183 +0,0 @@
// U7 法规结构化引用的共享呈现:编号标记、引用卡片、引用清单(审计工作区与驾驶舱阅读器共用)。
// 正文与原文片段一律作为文本节点渲染,不经 HTML。非 verified 状态用未核验样式,invalid 用无效样式;
// 折叠只用 @ui Collapsible;颜色/字号/圆角只消费 @ui/tokens.css(见 reg-citations.css)。
import { Fragment, useState, type ReactNode } from 'react'
import { Chip, Collapsible } from '@ui'
import {
CITATION_CHIP_TONE, citationListOrder, citationMarkText, citationStateLabel, citationStyle,
disclosureLabel, excerptView, parseCitedBlocks, safeWebHref, staleLabel,
type CitationView,
} from '../regCitations'
import '../reg-citations.css'
/** 状态徽标:核验状态 + 过期「待复核」。 */
export function CitationStateChips({ c }: { c: { state?: string; stale?: boolean; staleReason?: string } }) {
const stale = staleLabel(c)
return (
<span className="regcite-chips">
<Chip tone={CITATION_CHIP_TONE[citationStyle({ state: c.state })]}>{citationStateLabel(c.state)}</Chip>
{stale && <Chip tone="warn">{stale}</Chip>}
</span>
)
}
function citationHeading(c: CitationView): string {
const parts = [c.docNo, c.clauseNo].filter(Boolean)
return parts.length > 0 ? parts.join(' ') : (c.title || c.id)
}
function formatTime(at: string | undefined): string {
if (!at) return '—'
const d = new Date(at)
return Number.isNaN(d.getTime()) ? at : d.toLocaleString('zh-CN')
}
function Excerpt({ id, label, text, redaction }: { id: string; label: string; text?: string; redaction?: string }) {
if (!text) return <div className="regcite-redaction">{label}未展示:{redaction}</div>
return (
<Collapsible id={id} trigger={<span className="regcite-toggle">{label}</span>}>
<blockquote className="regcite-excerpt">{text}</blockquote>
</Collapsible>
)
}
/** 引用卡片:元数据 + 可展开原文片段(受披露约束);缺失或 invalid 的 ID 只给无效说明。 */
export function CitationCard({ id, citation, scope }: { id: string; citation?: CitationView; scope: string }) {
const style = citationStyle(citation)
if (!citation || style === 'invalid') {
return (
<div className="regcite-card regcite-card-invalid">
<div className="regcite-card-head">
<Chip tone="danger">无效引用</Chip>
<span className="regcite-mono">{id}</span>
</div>
<div className="regcite-note">
{citation ? '引用 ID 无法解析为证据批' : '本节引用清单中没有该引用'}——不得作为法律依据
</div>
</div>
)
}
const web = citation.web
const href = safeWebHref(web?.finalUrl || web?.url)
const fields: [string, ReactNode][] = []
const add = (label: string, v: ReactNode) => { if (v !== undefined && v !== null && v !== '') fields.push([label, v]) }
add('文号', citation.docNo)
add('标题', citation.title)
add('版本', citation.editionLabel)
add('效力状态', citation.legalStatus)
add('条款', citation.clauseNo)
add('章节路径', citation.headingPath?.length ? citation.headingPath.join(' › ') : undefined)
add('语料修订', citation.corpusRevision && <span className="regcite-mono">{citation.corpusRevision}</span>)
add('原文披露', citation.disclosure && disclosureLabel(citation.disclosure))
if (web) {
add('网络来源', href
? <a className="regcite-link" href={href} target="_blank" rel="noopener noreferrer">{web.finalUrl || web.url}</a>
: web.finalUrl || web.url)
add('官方来源', web.official ? '是' : '否')
add('抓取时间', formatTime(web.fetchedAt))
}
add('证据批', citation.batchId && <span className="regcite-mono">{citation.batchId}</span>)
add('引用 ID', <span className="regcite-mono">{citation.id}</span>)
const ex = excerptView(citation)
return (
<div className={`regcite-card regcite-card-${style}`}>
<div className="regcite-card-head">
<span className="regcite-card-title">{citationHeading(citation)}</span>
<CitationStateChips c={citation} />
</div>
{style !== 'valid' && <div className="regcite-note">未核验引用——不得作为有效法律依据</div>}
{citation.claim && <div className="regcite-claim">主张:{citation.claim}</div>}
<dl className="regcite-fields">
{fields.map(([label, v]) => (
<Fragment key={label}><dt>{label}</dt><dd>{v}</dd></Fragment>
))}
</dl>
<Excerpt id={`regcite-excerpt-${scope}-${citation.id}`} label="原文片段"
text={'excerpt' in ex ? ex.excerpt : undefined}
redaction={'redaction' in ex ? ex.redaction : undefined} />
{web?.excerpt && (
<Excerpt id={`regcite-web-${scope}-${citation.id}`} label="网页摘录" text={web.excerpt} />
)}
</div>
)
}
/** 引用清单一行:摘要触发 + 折叠卡片。 */
export function CitationListItem({ id, n, citation, scope }: { id: string; n?: number; citation?: CitationView; scope: string }) {
const style = citationStyle(citation)
return (
<div className={`regcite-item regcite-item-${style}`}>
<Collapsible id={`regcite-item-${scope}-${id}`} trigger={
<span className="regcite-item-head">
{n !== undefined && <span className="regcite-item-num">[{n}]</span>}
<span className="regcite-item-title">{citation && style !== 'invalid' ? citationHeading(citation) : id}</span>
{citation && style !== 'invalid' && citation.title && citation.title !== citationHeading(citation) && (
<span className="regcite-item-sub">{citation.title}</span>
)}
{citation && style !== 'invalid'
? <CitationStateChips c={citation} />
: <Chip tone="danger">无效引用</Chip>}
</span>
}>
<CitationCard id={id} citation={citation} scope={scope} />
</Collapsible>
</div>
)
}
/** 带引用的正文:[cit-…] 渲染为编号标记,点击在段落下展开引用卡片;正文下附引用清单。 */
export function CitedBody({ texts, citations, scope, paraClassName = 'cockpit-reader-para' }: {
texts: string[]
citations?: CitationView[] | null
scope: string
paraClassName?: string
}) {
const [open, setOpen] = useState<string | null>(null)
const { blocks, order } = parseCitedBlocks(texts)
const index = new Map((citations || []).map(c => [c.id, c]))
const list = citationListOrder(order, citations)
return (
<>
{blocks.map((segs, bi) => {
const ids = [...new Set(segs.flatMap(s => (s.kind === 'cite' ? [s.id] : [])))]
if (ids.length === 0) {
return <p key={bi} className={paraClassName}>{segs.map(s => (s.kind === 'text' ? s.text : '')).join('')}</p>
}
return (
<div key={bi} className="regcite-block">
<p className={paraClassName}>
{segs.map((s, si) => {
if (s.kind === 'text') return <Fragment key={si}>{s.text}</Fragment>
const c = index.get(s.id)
const key = `${bi}:${s.id}`
const isOpen = open === key
return (
<button key={si} type="button"
className={`regcite-mark regcite-${citationStyle(c)}${isOpen ? ' regcite-mark-open' : ''}`}
aria-expanded={isOpen}
title={`引用 ${s.n}:${c ? citationStateLabel(c.state) : '无效引用'}`}
onClick={() => setOpen(isOpen ? null : key)}>
[{citationMarkText(s.n, c)}]
</button>
)
})}
</p>
{ids.map(id => (
<Collapsible key={id} open={open === `${bi}:${id}`}>
<CitationCard id={id} citation={index.get(id)} scope={`${scope}-inline`} />
</Collapsible>
))}
</div>
)
})}
{list.length > 0 && (
<div className="regcite-list">
<div className="regcite-list-head">引用({list.length})</div>
{list.map((id, i) => (
<CitationListItem key={id} id={id} n={i + 1} citation={index.get(id)} scope={scope} />
))}
</div>
)}
</>
)
}
-386
View File
@@ -1,386 +0,0 @@
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,
} from '../rolePermissions'
import { Collapsible, Icon } from '@ui'
import { useStore } from '../store'
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
export function filterRoles(
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
): RoleRecord[] {
const normalized = query.trim().toLocaleLowerCase()
return roles.filter(role => {
if (status === 'enabled' && !role.enabled) return false
if (status === 'disabled' && role.enabled) return false
if (!normalized) return true
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
return mode === 'exact' ? fields.some(value => value === normalized) : fields.some(value => value.includes(normalized))
})
}
export function groupPermissionPage(catalog: PermissionDescriptor[], group: string, page: number, size: number): PermissionDescriptor[] {
const grouped = catalog.filter(item => !group || item.group === group)
return grouped.slice(page * size, page * size + size)
}
export function shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean {
return activeSequence === responseSequence
}
export function hasSystemAction(actions: string[] | undefined, action: string): boolean {
return actions?.includes(action) ?? false
}
function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<string, RolePermissionValue> {
const existing = new Map(role.permissions.map(item => [item.actionId, item]))
return Object.fromEntries(catalog.filter(item => !item.protected).map(item => {
const value = existing.get(item.actionId)
return [item.actionId, { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }]
}))
}
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 }
}
export function RolePermissionsPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
const canManageRoles = allowed('system.roles.manage')
const canViewPermissions = allowed('system.permissions.view')
const canManagePermissions = canViewPermissions && allowed('system.permissions.manage')
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [selectedId, setSelectedId] = useState('')
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
const [confirmDisable, setConfirmDisable] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [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('')
const [busy, setBusy] = useState(false)
const [roleLoading, setRoleLoading] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const requestSequence = useRef(0)
const selectedIdRef = useRef('')
const savePermsRef = useRef<HTMLButtonElement>(null)
selectedIdRef.current = selectedId
const selected = roles?.find(role => role.roleId === selectedId) ?? null
const draft = selected ? drafts[selected.roleId] ?? null : null
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
const 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 load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
filters = { query, mode, status, action: actionFilter }) => {
const sequence = ++requestSequence.current
setRoleLoading(true)
setErr('')
const [roleResult, catalogResult] = await Promise.allSettled([
api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status,
action: filters.action || undefined, cursor: pageCursor, limit: 50 }),
canViewPermissions && (refreshCatalog || catalog.length === 0) ? api.permissionCatalog() : Promise.resolve(catalog),
])
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
setRoleLoading(false)
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
if (roleResult.status === 'rejected') {
setErr(String(roleResult.reason))
return
}
const rolePage = roleResult.value
setRoles(rolePage.roles ?? [])
setNextRoleCursor(rolePage.nextCursor ?? '')
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
else setSelectedId('')
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
}
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
const nextSequence = sequence ?? ++requestSequence.current
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
setSelectedId(role.roleId)
setName(role.name)
setDescription(role.description)
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
setDrafts(prev => prev[role.roleId] ? prev
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
setNotice('')
setErr('')
}
// DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
useEffect(() => {
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
roleDraftLeaveGuard.current = () => window.confirm(message)
return () => { roleDraftLeaveGuard.current = null }
}, [dirty, selected?.roleId, selected?.name])
useEffect(() => {
if (!dirty) return
const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [dirty])
useEffect(() => {
setNextRoleCursor(''); setRoleLoading(true)
const timer = window.setTimeout(() => { void load(roleCursor) }, 180)
return () => { window.clearTimeout(timer); requestSequence.current++ }
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
const create = async () => {
if (!newName.trim()) return
requestSequence.current++; setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
const role = await api.createRole(newName.trim(), newDescription.trim())
setNewName(''); setNewDescription('')
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
selectRole(role, catalog, ++requestSequence.current)
setNotice(`已创建角色 ${role.name}`)
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
setNotice(`已创建角色 ${role.name}`)
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
}
const saveMetadata = async () => {
if (!selected) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
await load(); setNotice('角色资料已保存')
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
}
finally { setBusy(false) }
}
const setEnabled = async () => {
if (!selected) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
await load(); setNotice(selected.enabled ? '角色已停用,现有绑定不再授予权限' : '角色已启用')
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
} finally { setBusy(false) }
}
const rebasePermissionConflict = async (roleId: string, currentDraft: RolePermissionDraft, operationSequence: number) => {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === roleId)
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
}
const savePermissions = async () => {
if (!selected || !draft) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
const invalid = validateRolePermissionDraft(draft)
if (invalid) { setErr(`${invalid.actionId} 的默认权限不能超过角色上限`); return }
const full = Object.entries(draft.values).map(([actionId, value]) => ({ actionId, ...value }))
setBusy(true); setErr(''); setNotice('')
try {
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('角色权限已保存')
} catch (error) {
if (String(error).includes('409')) {
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
}
finally {
setBusy(false)
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
requestAnimationFrame(() => {
if (savePermsRef.current && !savePermsRef.current.disabled && document.activeElement === document.body) savePermsRef.current.focus()
})
}
}
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 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>
</table>
</div>
return <div className="rp-panel">
{canManageRoles && <div className="ad-create">
<div className="ad-row">
<input className="lg-input" aria-label="新角色名称" placeholder="角色名称" value={newName} onChange={event => setNewName(event.target.value)} />
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={newDescription} onChange={event => setNewDescription(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || roleLoading || !newName.trim()} onClick={create}>创建角色</button>
</div>
</div>}
<Collapsible id="role-permission-filters" trigger="搜索与过滤">
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索角色" placeholder="角色名称、描述或 ID" value={query} disabled={busy} onChange={event => { setQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<select className="lg-input" aria-label="角色匹配方式" value={mode} disabled={busy} onChange={event => { setMode(event.target.value as typeof mode); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
</select>
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
</select>
<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>)}
</select>}
</div>
</Collapsible>
<div className="ad-row rp-view-toggle">
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
<button className="ad-mini" disabled={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>}
{view === 'matrix' ? matrix() : <div className="rp-single">
<aside className="rp-role-list">
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
</button>)}
</aside>
<section className="rp-editor">
{selected && draft ? <>
<div className="ad-row">
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
{canManageRoles && (selected.enabled
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
</div>
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
</div>}
{canViewPermissions ? <><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>
<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>}
</Collapsible>
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
</> : <p className="ad-hint">请选择角色</p>}
</section>
</div>}
</div>
}
+3 -6
View File
@@ -9,9 +9,8 @@ import type { ThemeChoice } from '../theme'
import type { SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
import { BorrowPanels } from './BorrowPanels'
import { MyAccountSettings } from './MyAccountSettings'
type Section = 'settings' | 'account'
type Section = 'settings'
// 分组显示名(项目设置面板共用)。
export const groupNames: Record<string, string> = {
@@ -20,7 +19,7 @@ export const groupNames: Record<string, string> = {
}
export function SettingsModal() {
const [section, setSection] = useState<Section>('settings')
const [section] = useState<Section>('settings')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
@@ -55,11 +54,10 @@ export function SettingsModal() {
return (
<SettingsShell
title="设置" active={section} onSelect={key => setSection(key as Section)}
title="设置" active={section} onSelect={() => undefined}
onClose={() => store.openSettings(false)}
sections={[
{ key: 'settings', label: '常规', icon: 'settings' },
{ key: 'account', label: '账号', icon: 'user' },
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
actions={section === 'settings' && schema.length > 0 ? (
@@ -110,7 +108,6 @@ export function SettingsModal() {
)
})}
{section === 'settings' && <BorrowPanels />}
{section === 'account' && <MyAccountSettings />}
</SettingsShell>
)
}
+10 -16
View File
@@ -7,7 +7,7 @@ import { api } from '../api'
import { Icon } from '@ui'
import type { Conversation } from '../contracts'
import { relativeTime, filterTasks } from '../tasklist'
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
import { projectStage } from '../projectmeta'
import { buildWorkspaceEntries } from '../workspaces'
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
@@ -57,7 +57,6 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const serverWorkspaces = useStore(s => s.serverWorkspaces)
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
@@ -69,19 +68,16 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const [newProjectName, setNewProjectName] = useState('')
const createConversation = async (projectId?: string) => {
try {
await store.createTask(projectId)
onConversationOpen?.()
} catch { /* store owns the actionable failure toast */ }
await store.createTask(projectId)
onConversationOpen?.()
}
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable, rdmCockpit: rdmCockpitAvailable })
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const currentProject = projects.find(p => p.id === currentProjectId)
const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings)
const workError = currentProject ? projectWorkError(currentProject) : null
const stage = projectStage(currentProject?.settings)
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
@@ -166,10 +162,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
onChange={e => setNewProjectName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && newProjectName.trim()) {
try {
await store.createProject(newProjectName.trim())
setCreatingProject(false); setNewProjectName('')
} catch { /* keep the input available for a corrected name */ }
await store.createProject(newProjectName.trim())
setCreatingProject(false); setNewProjectName('')
}
if (e.key === 'Escape') setCreatingProject(false)
}}
@@ -185,7 +179,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
)}
{rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
<div className="tsb-empty">从「新建对话」开始,描述你想完成的工作。</div>
)}
{rest.map(t => taskRow(t))}
</div>
@@ -230,7 +224,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
{projects.map(p => {
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
const pstage = projectStage(p.settings)
return (
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
{p.name}
@@ -280,7 +274,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
{(me?.isAdmin || (me?.systemActions?.length ?? 0) > 0 || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
+2 -2
View File
@@ -36,7 +36,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur
<Icon name="message" size={15} /><span>对话</span>
</button>
</>}
{(compact || ui.rightCollapsed) && <><button className="shell-nav-button" aria-label="打开工作"
<button className="shell-nav-button" aria-label="打开工作"
aria-pressed={compact ? activePane === 'work' : !ui.rightCollapsed && ui.rightTab === 'work'}
title={pending > 0 ? `${pending} 项待处理` : '工作'} onClick={onWork}>
<Icon name="inbox" size={15} /><span>工作</span>
@@ -45,7 +45,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur
<button className="shell-nav-button" aria-label="打开文件"
aria-pressed={compact ? activePane === 'files' : !ui.rightCollapsed && ui.rightTab === 'files'} onClick={onFiles}>
<Icon name="folder" size={15} /><span>文件</span>
</button></>}
</button>
</nav>
</header>
)
-50
View File
@@ -1,50 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { SafeMarkdown } from '@ui'
import type { StreamEvent } from '../contracts'
import { loadRunEvents } from '../runEvents'
import { store, useStore } from '../store'
import { runEventsToTimeline } from '../live'
import { mergeProcessTimeline } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { MessageTimestamp } from './MessageTimestamp'
/** The work pane and inline fallback share one selection. Only this detail host
* reads that run's authorized durable events; opening a record never adds chat. */
export function WorkExecutionRecord() {
const selected = useStore(s => s.workRecord)
const conversationId = useStore(s => s.currentConvId)
const messages = useStore(s => s.messages)
const run = useStore(s => s.runs.find(r => r.id === selected?.runId))
const [result, setResult] = useState<{ runId: string; events: StreamEvent[] } | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!selected || selected.conversationId !== conversationId) return
const controller = new AbortController()
let active = true
setResult(null); setFailed(false)
void (async () => {
try {
const events = await loadRunEvents(selected.conversationId, selected.runId, controller.signal)
if (active) setResult({ runId: selected.runId, events })
} catch { if (active) setFailed(true) }
})()
return () => { active = false; controller.abort() }
}, [selected?.runId, selected?.conversationId, conversationId, run?.status, run?.endedAt])
const parts = useMemo(() => {
if (!selected || result?.runId !== selected.runId) return []
const saved = messages.filter(message => message.runId === selected.runId && message.role === 'assistant')
.flatMap(message => message.parts).map(part => ({ ...part, process: true }))
return mergeProcessTimeline(saved, runEventsToTimeline(result.events, selected.runId, true), [])
}, [messages, result, selected?.runId])
if (!selected || selected.conversationId !== conversationId) return null
return <section className="work-execution-record" aria-label={`${selected.title}的执行记录`}>
<div className="work-record-heading"><strong>{selected.title} · 执行记录</strong>
<button type="button" className="btn ghost" onClick={() => store.closeWorkRecord()}>返回工作</button></div>
<MessageTimestamp value={run?.createdAt} />
{failed ? <p role="status">完整记录暂不可用,请稍后重新打开</p>
: !result ? <p role="status">正在读取已保存的记录…</p>
: parts.length === 0 ? <p>这项工作没有已保存的过程输出</p>
: <ProcessTimeline parts={parts} live={false} idKey={`work-record:${selected.runId}`}
renderPart={part => <SafeMarkdown className="part-text" markdown={part.text ?? ''} breaks />} />}
</section>
}
+4 -12
View File
@@ -2,19 +2,17 @@
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
import { store, useStore } from '../store'
import type { Run, WorkCardAction } from '../contracts'
import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon, SurfaceView } from '@ui'
import { previewTabsKey } from '../previewtabs'
import { surfaceKey } from '../surfaceKey'
import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane'
import { WorkExecutionRecord } from './WorkExecutionRecord'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
export const docBridge: DocBridge = {
onOpenTaskSource: (cardId, source) => store.openTaskSource(cardId, source),
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
onOpenDoc: id => {
const art = store.getState().artifacts.find(a => a.id === id)
@@ -23,9 +21,7 @@ export const docBridge: DocBridge = {
}
const inboxCallbacks = {
onAction: (c: ActivityCard, id: string, payload?: WorkCardAction) => store.cardAction(c, id, payload),
onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c),
onReference: (c: ActivityCard) => store.referenceWork(c),
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
actionState: (c: ActivityCard) => store.getState().cardActionStates[c.id],
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
@@ -68,7 +64,6 @@ export function Workbench() {
const filter = useStore(s => s.ui.inboxFilter)
const visible = inboxScope === 'project' ? projectCards : cards
const pending = pendingCount(visible)
const unread = visible.filter(card => card.work && card.work.resultVersion > card.work.readVersion).length
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
const switchTab = (tab: 'work' | 'files') =>
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
@@ -76,7 +71,7 @@ export function Workbench() {
<>
<div className="tabs">
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}{unread > 0 ? ` · ${unread} 项新结果` : ''}</button>
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
title="文件搜索、预览、编辑与下载">文件</button>
<span className="spacer" style={{ flex: 1 }} />
@@ -113,12 +108,10 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
projectCards: ActivityCard[]
cards: ActivityCard[]
}) {
const record = useStore(s => s.workRecord)
const showCurrentTask = !hasActiveProgressCard(cards)
return (
<>
<div className="tab-body" data-work-scroll>
{record ? <WorkExecutionRecord /> : <>
<div className="tab-body" data-scroll-sync="inbox">
{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>
@@ -133,7 +126,6 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
<WorkspaceSurfaces />
</>}
</div>
</>
)
+1 -7
View File
@@ -8,13 +8,12 @@ import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { DocumentCockpit } from './DocumentCockpit'
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
export function workspaceEntries(): WorkspaceEntry[] {
const s = store.getState()
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable, rdmCockpit: s.rdmCockpitAvailable })
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable })
}
export function Workspaces() {
@@ -37,11 +36,6 @@ export function Workspaces() {
<RdmAuditWorkspace />
</OverlayHost>
}
if (active === 'rdm-cockpit') {
return <OverlayHost title="文档驾驶舱" icon="📋" shortcut={entry?.shortcut} sayHint="打开文档驾驶舱" onClose={close}>
<DocumentCockpit />
</OverlayHost>
}
if (active.startsWith('plugin:')) {
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
+310 -396
View File
@@ -1,132 +1,167 @@
// RDM 项目设置组件:成员 / 文档职责。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 门控集中在 src/rdmManagement.ts;分区可见性由 ProjectSettingsModal 按 store 的
// rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 admin-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect, useState } from 'react'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { Btn, Collapsible, Icon, Picker } from '@ui'
import { Btn, EmptyState, Icon } from '@ui'
import {
memberAddPayload, memberRemovePayload, memberSetRolePayload,
permCheckPayload, describeSource,
type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type RdmMemberPage, type RdmProfRole,
type SearchFieldsChoice,
MATCH_MODE_LABELS, SEARCH_FIELDS, accountCandidatePager, accountSearchKey, appendMemberPage, memberListParams,
personLabel, roleOptionsPayload,
ROLE_ACTIONS, parseReviewers,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload,
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload,
docFinalPayload, docStatusPayload, permCheckPayload, describeSource,
rolesListPlaceholder, describeRoleListError,
} from '../rdmManagement'
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ----
interface MemberRow {
owner: string; name?: string; role: string; addedBy: string; addedAt: string
profRole?: { roleId: string; name: string } | null
interface RoleView {
roleId: string
name: string
disabled: boolean
version: number
actions: Record<string, [boolean, boolean]> | null
accounts: string[] | null
}
export function RdmMembersSection({
project,
initialCanManage,
initialRows,
}: {
project: Project
initialCanManage?: boolean
initialRows?: MemberRow[]
}) {
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 [memberNext, setMemberNext] = useState('')
const [memberTotal, setMemberTotal] = useState<number | null>(null)
const [directoryOk, setDirectoryOk] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [memberQuery, setMemberQuery] = useState('')
const [memberRoleId, setMemberRoleId] = useState('')
// null=未加载或 rdm.role.options 失败:两处专业角色筛选均不渲染(不给误导选项)。
const [roleOptions, setRoleOptions] = useState<RdmProfRole[] | null>(null)
export function RdmRolesPanel() {
const me = useStore(s => s.me)
const [roles, setRoles] = useState<RoleView[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [picked, setPicked] = useState<string[]>([])
const [pickedItems, setPickedItems] = useState<PersonPickItem[]>([])
const [candRoleId, setCandRoleId] = useState('')
const [candMatch, setCandMatch] = useState<MemberMatchMode>('fuzzy')
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
const [role, setRole] = useState<'member' | 'owner'>('member')
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
const rowsRef = useRef<MemberRow[] | null>(null)
rowsRef.current = rows
const knownPicks = useRef(new Map<string, PersonPickItem>())
// 成员表请求代次:切项目/切筛选推进代次,迟到响应丢弃。
const memberGen = useRef(0)
const [newName, setNewName] = useState('')
// 动作编辑器
const [editRole, setEditRole] = useState('')
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
const [editDef, setEditDef] = useState(false)
const [editCap, setEditCap] = useState(false)
// 绑定
const [assignOwner, setAssignOwner] = useState('')
const sel = roles?.find(role => role.roleId === editRole)
const editCurrent = sel?.actions?.[editAction]
useEffect(() => {
setCanManage(false)
setPicked([]); setPickedItems([]); knownPicks.current.clear()
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
setRoleOptions(null)
}, [project])
useEffect(() => {
if (!canManage) {
setRoleOptions(null)
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])
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
[candMatch, candFields, candRoleId])
/** 人员选择器取数适配:rdm.account.list 服务端按 query/mode/searchFields/roleId 先过滤后分页。 */
const fetchPage = useMemo(() => {
const page = accountCandidatePager(params => cmd<RdmAccountPage>('rdm.account.list', params), projectId,
{ ...candOptions, joinedOwners: () => (rowsRef.current ?? []).map(m => m.owner) })
return async (query: string, cursor: string, limit: number) => {
const out = await page(query, cursor, limit)
for (const it of out.items) knownPicks.current.set(it.id, it)
return out
}
}, [project, candOptions])
/** cursor 空=重载首页(clear 时先清空表格);非空=追加下一页。 */
const loadMembers = useCallback(async (cursor: string, clear: boolean) => {
const gen = cursor ? memberGen.current : ++memberGen.current
if (cursor) setLoadingMore(true)
else {
setLoadingMore(false)
if (clear) { setErr(''); setRows(null); setMemberNext(''); setMemberTotal(null) }
}
const refresh = async () => {
try {
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)
setDirectoryOk(out.accountDirectoryAvailable !== false)
} catch (e) {
if (gen === memberGen.current) setErr(String(e))
} finally {
if (cursor && gen === memberGen.current) setLoadingMore(false)
}
}, [project, memberQuery, memberRoleId])
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
setErr(''); setRoles(out.roles ?? [])
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
}
useEffect(() => { refresh() }, [])
useEffect(() => {
const timer = setTimeout(() => { void loadMembers('', true) }, memberQuery.trim() ? 250 : 0)
return () => { clearTimeout(timer); ++memberGen.current }
}, [loadMembers])
setEditDef(editCurrent?.[0] ?? false)
setEditCap(editCurrent?.[1] ?? false)
}, [roles, editRole, editAction])
const refresh = () => loadMembers('', false)
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refresh() }
catch (e) { setErr(describeRoleListError(String(e))) } finally { setBusy(false) }
}
if (!me?.isAdmin) return <EmptyState icon="shield">仅平台管理员可管理专业角色</EmptyState>
// 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见
// rolesListPlaceholder 注释)。
const placeholder = rolesListPlaceholder(roles, err)
return (
<div>
<div className="admin-create">
<div className="admin-row">
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
onChange={e => setNewName(e.target.value)} />
<button className="lg-submit admin-create-btn" disabled={busy || !newName.trim()}
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
setNewName('')
})}>创建角色</button>
</div>
<p className="admin-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
</div>
<table className="admin-table">
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
<tbody>
{(roles ?? []).map(r => (
<tr key={r.roleId}>
<td><span className="admin-mono">{r.name}</span><br /><span className="admin-dim">{r.roleId}</span></td>
<td>{r.disabled ? <span className="admin-chip admin-chip-warn">已停用</span> : <span className="admin-chip">生效</span>}</td>
<td className="admin-dim">v{r.version}</td>
<td>{Object.keys(r.actions ?? {}).length} 项</td>
<td className="admin-mono">{(r.accounts ?? []).join('、') || '—'}</td>
<td><button className="admin-mini" disabled={busy}
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
{r.disabled ? '启用' : '停用'}</button></td>
</tr>
))}
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="admin-dim">暂无专业角色</td></tr>}
{placeholder && <tr><td colSpan={6} className="admin-dim">{placeholder}</td></tr>}
</tbody>
</table>
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
<div className="admin-row">
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
<option value="">选择角色…</option>
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<select className="lg-input" value={editAction} onChange={e => setEditAction(e.target.value)}>
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<label className="admin-check"><input type="checkbox" checked={editDef}
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
<label className="admin-check"><input type="checkbox" checked={editCap}
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
<button className="lg-submit admin-create-btn" disabled={busy || !editRole}
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
</div>
{sel && editCurrent && <p className="admin-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
<div className="admin-row">
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
onChange={e => setAssignOwner(e.target.value)} />
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
<option value="">选择角色…</option>
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<button className="lg-submit admin-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
</div>
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:成员 ----
interface MemberRow { owner: string; role: string; addedBy: string; addedAt: string }
export function RdmMembersSection({ projectId }: { projectId: string }) {
const [rows, setRows] = useState<MemberRow[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [member, setMember] = useState('')
const [role, setRole] = useState<'member' | 'owner'>('member')
const refresh = async () => {
try {
const out = await api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
setRows(out.members ?? [])
} catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [projectId])
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
@@ -134,341 +169,220 @@ export function RdmMembersSection({
catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const onPick = (ids: string[]) => {
setPicked(ids)
setPickedItems(ids.flatMap(id => {
const it = knownPicks.current.get(id)
return it ? [it] : []
}))
}
const pickedId = picked[0] ?? ''
const pickedItem = pickedItems.find(it => it.id === pickedId)
const pickedValid = !!pickedItem && !pickedItem.disabled && !(rows ?? []).some(m => m.owner === pickedItem.owner)
const pickerDisabled = busy || !!writeError
const filtering = memberQuery.trim() !== '' || memberRoleId !== ''
const roleFilter = (value: string, onChange: (v: string) => void, label: string) =>
roleOptions && roleOptions.length > 0 ? (
<select className="lg-input" aria-label={label} value={value} onChange={e => onChange(e.target.value)}>
<option value="">全部专业角色</option>
{roleOptions.map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
) : null
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
{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>
<div className="admin-create">
<div className="admin-row">
<input className="lg-input lg-mono" placeholder="成员账号(owner)" value={member}
onChange={e => setMember(e.target.value)} />
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">member(成员)</option>
<option value="owner">owner(负责人)</option>
</select>
<button className="lg-submit admin-create-btn" disabled={busy || !member.trim()}
onClick={() => run(`已添加 ${member.trim()}`, async () => {
await api.rdmCommand('rdm.project.member.add', memberAddPayload(projectId, member.trim(), role))
setMember('')
})}>添加成员</button>
</div>
) : (
<p className="ad-hint">仅项目创建者可管理成员;你可以查看成员列表。</p>
)}
<div className="ad-row">
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
onChange={e => setMemberQuery(e.target.value)} />
{roleFilter(memberRoleId, setMemberRoleId, '成员专业角色筛选')}
{rows !== null && <span className="ad-dim">共 {memberTotal ?? rows.length} 名成员匹配</span>}
<p className="admin-hint">加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。</p>
</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>
<table className="admin-table">
<thead><tr><th>账号</th><th>项目角色</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
<tbody>
{(rows ?? []).map(m => (
<tr key={m.owner}>
<td className="ad-mono">{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 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 className="admin-mono">{m.owner}</td>
<td>{m.role === 'owner' ? <span className="admin-chip admin-chip-admin">OWNER</span> : <span className="admin-chip">MEMBER</span>}</td>
<td className="admin-dim">{m.addedBy || '—'}</td>
<td className="admin-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
<td><button className="admin-mini" disabled={busy}
onClick={() => run(`已移除 ${m.owner}`,
() => api.rdmCommand('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button></td>
</tr>
))}
{rows !== null && rows.length === 0 && <tr><td colSpan={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={5} className="admin-dim">暂无成员</td></tr>}
{rows === null && <tr><td colSpan={5} className="admin-dim">加载中…</td></tr>}
</tbody>
</table>
{memberNext && rows !== null && (
<div className="ad-row">
<button className="ad-mini" disabled={loadingMore}
onClick={() => loadMembers(memberNext, false)}>{loadingMore ? '加载中…' : '加载更多成员'}</button>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
export { RdmDutiesSection } from './duties/RdmDutiesSection'
// ---- 项目设置:文档职责 · 整文审签 ----
// ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖;
// 与实际判定同源——UI 不重算,全部读服务端解释) ----
interface MatrixAction {
action: string; label?: string; scope?: string
allowed: boolean; source: string; reason?: string
interface DocSubmissionView {
round: number
state: string
digest: string
createdBy: string
createdAt: string
duties: { author: string; reviewers: string[]; approver: string }
reviews: Record<string, boolean>
sections: Record<string, number>
}
export function RdmDutiesSection({ projectId }: { projectId: string }) {
const [docId, setDocId] = useState('')
const [sub, setSub] = useState<DocSubmissionView | null | undefined>(undefined)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [author, setAuthor] = useState('')
const [reviewers, setReviewers] = useState('')
const [approver, setApprover] = useState('')
const cmd = <T,>(id: string, params: Record<string, unknown>) => api.rdmCommand<T>(id, params)
const refreshStatus = async (id: string) => {
if (!id) return
try {
const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id))
setSub(out.submission)
} catch (e) { setErr(String(e)); setSub(undefined) }
}
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refreshStatus(docId) }
catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
<div className="admin-create">
<div className="admin-row">
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
onChange={e => setDocId(e.target.value)} />
<Btn variant="ghost" icon="eye" disabled={busy || !docId}
onClick={() => refreshStatus(docId)}>查看整文状态</Btn>
</div>
</div>
{sub === null && <EmptyState icon="inbox">该文档尚无整文提交记录</EmptyState>}
{sub && (
<div className="settings-group">
<h4>第 {sub.round} 轮 · {sub.state === 'submitted' ? <span className="admin-chip admin-chip-warn">审签在途(全文锁定)</span> : sub.state === 'final' ? <span className="admin-chip admin-chip-admin">已终批</span> : <span className="admin-chip">已驳回</span>}</h4>
<div className="setting-row"><label>三权</label><span>编制 <b className="admin-mono">{sub.duties.author}</b> · 审核 <b className="admin-mono">{(sub.duties.reviewers ?? []).join('、')}</b> · 批准 <b className="admin-mono">{sub.duties.approver}</b></span></div>
<div className="setting-row"><label>审核进度</label><span>{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}</span></div>
<div className="setting-row"><label>冻结摘要</label><span className="admin-mono admin-dim">{(sub.digest ?? '').slice(0, 16)}…</span></div>
<div className="admin-row" style={{ marginTop: 8 }}>
<button className="lg-submit admin-create-btn" disabled={busy || sub.state !== 'submitted'}
onClick={() => run('整文终批完成(各章节已基线)',
() => cmd('rdm.review.doc.final', docFinalPayload(projectId, docId)))}>终批(批准人)</button>
<button className="admin-mini" disabled={busy || sub.state !== 'submitted'}
onClick={() => run('已通过整文审核',
() => cmd('rdm.review.doc.approve', docApprovePayload(projectId, docId, '通过')))}>审核通过</button>
<button className="admin-mini" disabled={busy || sub.state !== 'submitted'}
onClick={() => run('已驳回整文(解锁回编写态)',
() => cmd('rdm.review.doc.reject', docRejectPayload(projectId, docId, '退回修改')))}>驳回</button>
</div>
</div>
)}
{sub === undefined && docId && <p className="admin-hint">输入文档 ID 后查看整文状态。</p>}
<h4 className="settings-group-title" style={{ marginTop: 18 }}>分配文档三权(整文)</h4>
<div className="admin-row">
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
onChange={e => setDocId(e.target.value)} />
<input className="lg-input lg-mono" placeholder="编制人(author)" value={author}
onChange={e => setAuthor(e.target.value)} />
<input className="lg-input lg-mono" placeholder="审核人(逗号分隔,≥1 且异于编制/批准)" value={reviewers}
onChange={e => setReviewers(e.target.value)} />
<input className="lg-input lg-mono" placeholder="批准人(approver)" value={approver}
onChange={e => setApprover(e.target.value)} />
</div>
<div className="admin-row">
<button className="lg-submit admin-create-btn"
disabled={busy || !docId || !author.trim() || !approver.trim() || parseReviewers(reviewers).length === 0}
onClick={() => run('已分配文档三权', () => cmd('rdm.review.assign.doc',
assignDocDutiesPayload(projectId, docId, author.trim(), parseReviewers(reviewers), approver.trim())))}>分配三权</button>
<button className="admin-mini" disabled={busy || !docId}
onClick={() => run('整文已提交审核(全文锁定)',
() => cmd('rdm.review.doc.submit', docSubmitPayload(projectId, docId)))}>整文提交(编制人)</button>
</div>
<p className="admin-hint">三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。</p>
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:项目权限(矩阵解释读面,与实际判定同源——UI 不重算) ----
interface MatrixAction { action: string; allowed: boolean; source: string; reason?: string }
interface MatrixOut {
projectId: string; member: string; role: string
profRole: { roleId: string; name: string } | null
actions: MatrixAction[]
}
interface MatrixRowOut {
member: string; role: string
profRole: { roleId: string; name: string } | null
actions: MatrixAction[]
}
interface MatrixBatchOut { projectId: string; rows: MatrixRowOut[] }
function actionTitle(a: MatrixAction) { return a.label || a.action }
/** 动作行:中文标签为主,内部标识进 title 技术详情(方案 §5)。 */
function ActionDetailTable({ actions, onOverride, canManage }: {
actions: MatrixAction[]
onOverride?: (action: string, mode: 'allow' | 'deny' | 'default') => void
canManage?: boolean
}) {
const project = actions.filter(a => a.scope !== 'system')
const system = actions.filter(a => a.scope === 'system')
const overrideOf = (a: MatrixAction): 'allow' | 'deny' | 'default' =>
a.source.includes('override') || a.source === 'project' ? (a.allowed ? 'allow' : 'deny') : 'default'
return (
<>
<table className="ad-table">
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th>{canManage ? <th>项目覆盖</th> : null}</tr></thead>
<tbody>
{project.map(a => (
<tr key={a.action}>
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
<td className="ad-dim">{describeSource(a.source)}</td>
<td className="ad-dim">{a.reason || '—'}</td>
{canManage ? (
<td>
<select className="lg-input" value={overrideOf(a)} style={{ minWidth: 120 }}
onChange={e => onOverride?.(a.action, e.target.value as 'allow' | 'deny' | 'default')}>
<option value="default">沿用角色默认</option>
<option value="allow">项目内允许</option>
<option value="deny">项目内禁止</option>
</select>
</td>
) : null}
</tr>
))}
</tbody>
</table>
{system.length > 0 && (
<Collapsible trigger={`系统范围动作(${system.length} 项,不在项目内配置)`}>
<table className="ad-table">
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因</th></tr></thead>
<tbody>
{system.map(a => (
<tr key={a.action}>
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
<td className="ad-dim">{describeSource(a.source)}</td>
<td className="ad-dim">{a.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</Collapsible>
)}
</>
)
}
export function RdmPermMatrixSection({ project }: { project: Project }) {
const projectId = project.id
const writeError = projectWorkError(project)
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
export function RdmPermMatrixSection({ projectId }: { projectId: string }) {
const me = useStore(s => s.me)
const [view, setView] = useState<'member' | 'matrix'>('member')
const [member, setMember] = useState('')
const [members, setMembers] = useState<MemberRow[] | null>(null)
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
const [batch, setBatch] = useState<MatrixBatchOut | null>(null)
const [expand, setExpand] = useState('')
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const loadMembers = () =>
cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
useEffect(() => {
api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
.then(out => {
setMembers(out.members ?? [])
// 默认自省:普通成员只看自己的矩阵;负责人/管理员可切换他人。
if (me?.owner) setMember(prev => prev || me.owner)
}).catch(e => setErr(String(e)))
useEffect(() => { loadMembers() }, [project, me?.owner]) // eslint-disable-line react-hooks/exhaustive-deps
})
.catch(e => setErr(String(e)))
}, [projectId, me?.owner])
const query = async (m: string) => {
if (!m) return
setErr(''); setBusy(true); setMatrix(null)
try {
setMatrix(await cmd<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
setMatrix(await api.rdmCommand<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const queryBatch = async () => {
setErr(''); setBusy(true); setBatch(null)
try {
setBatch(await cmd<MatrixBatchOut>('rdm.perm.matrix', { projectId }))
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
useEffect(() => { if (view === 'matrix') queryBatch() }, [view]) // eslint-disable-line react-hooks/exhaustive-deps
/** 三态覆盖写回:default=实际撤销(perm.unset);allow/deny=perm.set。 */
const override = async (m: string, action: string, mode: 'allow' | 'deny' | 'default') => {
if (writeError) { setErr(writeError); return }
setErr(''); setNotice(''); setBusy(true)
try {
if (mode === 'default') {
await cmd('rdm.perm.unset', { projectId, member: m, action })
setNotice(`已恢复默认(撤销 ${action} 的项目覆盖)`)
} else {
await cmd('rdm.perm.set', { projectId, member: m, action, allow: mode === 'allow' })
setNotice(mode === 'allow' ? `已在项目内允许 ${action}` : `已在项目内禁止 ${action}`)
}
await query(m)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const canManage = !writeError && !!matrix && matrix.role !== undefined
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-create">
<div className="ad-row">
<select className="lg-input" value={view} onChange={e => setView(e.target.value as 'member' | 'matrix')}>
<option value="member">按成员查看(默认自省)</option>
<option value="matrix">权限矩阵(成员 × 动作,须管理权)</option>
<div className="admin-create">
<div className="admin-row">
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
<option value="">选择成员(默认自省)…</option>
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
</select>
{view === 'member' && (
<>
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
<option value="">选择成员…</option>
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
</select>
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看</Btn>
</>
)}
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看矩阵</Btn>
</div>
<p className="ad-hint">普通成员可查看自己的有效权限;查看他人与矩阵须项目管理权。解释与实际判定同源(服务端,非前端推算);系统范围动作折叠为技术详情。</p>
<p className="admin-hint">普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。</p>
</div>
{view === 'member' && matrix && (
{matrix && (
<>
<div className="ad-row">
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目身份 <b>{matrix.role === 'owner' ? '项目负责人' : matrix.role === 'member' ? '项目成员' : matrix.role}</b>
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b></>}
<div className="admin-row">
<span>成员 <b className="admin-mono">{matrix.member}</b> · 项目关系 <b>{matrix.role}</b>
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b>({matrix.profRole.roleId})</>}
</span>
</div>
<ActionDetailTable actions={matrix.actions ?? []}
canManage={canManage}
onOverride={(action, mode) => override(matrix.member, action, mode)} />
<table className="admin-table">
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th></tr></thead>
<tbody>
{(matrix.actions ?? []).map(a => (
<tr key={a.action}>
<td className="admin-mono">{a.action}</td>
<td>{a.allowed ? <span className="admin-chip admin-chip-admin">允许</span> : <span className="admin-chip admin-chip-warn">拒绝</span>}</td>
<td className="admin-dim">{describeSource(a.source)}</td>
<td className="admin-dim">{a.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</>
)}
{view === 'member' && !matrix && !err && members !== null && <p className="ad-hint">选择成员后展示各动作的有效权限、来源与第一拒绝原因。</p>}
{view === 'matrix' && batch && (
<table className="ad-table" style={{ overflowX: 'auto', display: 'block' }}>
<thead><tr>
<th>成员</th>
{(batch.rows[0]?.actions ?? []).filter(a => a.scope !== 'system').map(a => (
<th key={a.action} title={`内部标识 ${a.action}`}>{actionTitle(a)}</th>
))}
</tr></thead>
<tbody>
{batch.rows.map(row => (
<tr key={row.member}>
<td>
<button className="ad-mini" onClick={() => setExpand(expand === row.member ? '' : row.member)}>
{row.member}
</button>
<span className="ad-dim"> · {row.role === 'owner' ? '负责人' : '成员'}</span>
</td>
{row.actions.filter(a => a.scope !== 'system').map(a => (
<td key={a.action} title={`${a.label ?? a.action}:${a.allowed ? '允许' : '拒绝'}(${describeSource(a.source)}${a.reason ? ';' + a.reason : ''})`}>
{a.allowed ? <span className="ad-chip ad-chip-admin">✓</span> : <span className="ad-chip ad-chip-warn">✗</span>}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
{view === 'matrix' && batch && expand && (
<div className="settings-group" style={{ marginTop: 12 }}>
<h4>{expand} · 单成员详情与三态覆盖</h4>
{(() => {
const row = batch.rows.find(r => r.member === expand)
if (!row) return null
return <ActionDetailTable actions={row.actions} canManage={!writeError}
onOverride={(action, mode) => override(expand, action, mode)} />
})()}
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{!matrix && !err && members !== null && <p className="admin-hint">选择成员后展示 12 项动作的有效权限与第一拒绝原因。</p>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
+57 -120
View File
@@ -6,16 +6,11 @@ import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { SettingInput, flatten, unflatten } from './SettingsModal'
import type { SettingDesc } from '../contracts'
import { docBridge } from './Workbench'
import { accountActionPolicy, canAppointAdmins } from '../accountAuthority'
import { CreateAdminOption, IdentityChip } from './AccountUsersPanel'
import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCredential } from './oneTimePasswordUi'
import { randomPassword } from '../passwords'
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码的唯一处置是
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
export function UsersPanel() {
const me = useStore(s => s.me)
@@ -24,10 +19,11 @@ export function UsersPanel() {
const [notice, setNotice] = useState('')
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [initialPassword, setInitialPassword] = useState('')
const [createAsAdmin, setCreateAsAdmin] = useState(false)
const [generatedCredential, setGeneratedCredential] = useState<GeneratedCredential | null>(null)
const [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
const [resetFor, setResetFor] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
@@ -36,22 +32,25 @@ export function UsersPanel() {
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
setBusy(true)
try {
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined, canAppointAdmins(me) && createAsAdmin)
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
setOwner(''); setName(''); setInitialPassword(''); setCreateAsAdmin(false)
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
setOwner(''); setName(''); setPw('')
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const issueOneTimePassword = async (u: AdminUserRow) => {
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
const reset = async () => {
if (!resetFor) return
setErr('')
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
const out = await api.adminIssueOneTimePassword(u.owner)
setGeneratedCredential(oneTimePasswordCredential(out))
setNotice(`已为 ${u.owner} 生成一次性登录密码。用户使用后必须立即设置新密码。`)
await api.resetAdminUserPassword(resetFor, resetPw)
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
setResetFor(null); setResetPw('')
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
@@ -59,41 +58,50 @@ export function UsersPanel() {
return (
<div>
{me?.isAdmin && (
<div className="ad-create">
<div className="ad-row">
<div className="admin-create">
<div className="admin-row">
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
</div>
<CreateAdminOption me={me} checked={createAsAdmin} disabled={busy} onChange={setCreateAsAdmin} />
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
<div className="admin-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="admin-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="admin-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit admin-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="admin-hint">创建后该用户首次登录必须修改此初始密码。</p>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{generatedCredential && <GeneratedCredentialPanel credential={generatedCredential} onClose={() => setGeneratedCredential(null)} />}
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点击该用户行的「生成一次性登录密码」:用户登录后必须立即设置新密码。</p>}
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => {
const policy = accountActionPolicy(me, u)
return <tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (policy.resetPassword
? <button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
: <span className="ad-dim">{policy.protectedReason || '受保护账号不可生成一次性登录密码'}</span>
)}</td>
</tr>
})}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
{resetFor ? (
<div className="admin-reset">
<span>重置 <b>{resetFor}</b> 的密码:</span>
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
<button type="button" className="admin-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
<button className="lg-submit admin-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
<button type="button" className="admin-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
</div>
) : (
<table className="admin-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="admin-user-cell"><span className="admin-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="admin-chip admin-chip-admin">ADMIN</span> : <span className="admin-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="admin-chip admin-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="admin-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="admin-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="admin-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
</div>
)
}
@@ -232,74 +240,3 @@ export function PluginWorkspacesPanel() {
</div>
)
}
// ---- 模型与服务配置(P2 系统受管项的唯一写入面)----
/**
* SystemSettingsPanel:消费 GET/PUT /api/settings/system。
* - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施);
* 非受管项属于用户/项目偏好,不在本面板出现。
* - 保存后重新 GET 以服务端结果回填(保存成功 ≠ 本地草稿即真相);掩码占位符
* 原样传回,由服务端视为未修改。
* - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。
*/
export function SystemSettingsPanel() {
const [schema, setSchema] = useState<SettingDesc[] | null>(null)
const [draft, setDraft] = useState<Record<string, any>>({})
const [loaded, setLoaded] = useState(false)
const [forbidden, setForbidden] = useState(false)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const load = async () => {
setErr(''); setForbidden(false)
try {
const out = await api.getSystemSettings()
setSchema(out.schema.filter(d => d.scope === 'system'))
setDraft(flatten(out.system))
setLoaded(true)
} catch (e) {
if (String(e).includes('403')) { setForbidden(true); setLoaded(true) }
else setErr(String(e))
}
}
useEffect(() => { load() }, [])
const groups: Record<string, SettingDesc[]> = {}
for (const d of schema ?? []) (groups[d.group] ??= []).push(d)
const save = async () => {
setErr(''); setNotice(''); setBusy(true)
try {
await api.putSystemSettings(unflatten(draft))
setNotice('已保存;按各项标注的生效时机生效(需重启的项在重启后生效)')
await load()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
if (!loaded) return <EmptyState icon="settings">加载系统设置…</EmptyState>
if (forbidden) return <EmptyState icon="shield">系统设置仅管理员可配置</EmptyState>
return (
<div>
<p className="ad-hint" style={{ marginBottom: 14 }}>
模型、密钥与运行基础设施为系统受管配置:仅此面板(管理员)可写,用户与项目设置中的同名项不再生效。
</p>
{Object.entries(groups).map(([group, descs]) => (
<SettingsGroup key={group} title={group === 'models' ? '模型' : group === 'tools' ? '工具服务' : group === 'telemetry' ? '遥测' : group === 'runtime' ? '运行基础设施' : group}>
{descs.map(d => (
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
<SettingInput desc={d} value={draft[d.key]} onChange={v => setDraft(prev => ({ ...prev, [d.key]: v }))} />
</SettingRow>
))}
</SettingsGroup>
))}
<div className="ad-row" style={{ marginTop: 12 }}>
<Btn variant="primary" icon="check" disabled={busy} onClick={save}>保存系统配置</Btn>
</div>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
-280
View File
@@ -1,280 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../api'
import type { Project } from '../../contracts'
import { projectWorkError } from '../../projectmeta'
import { Btn, Collapsible, EmptyState, Icon } from '@ui'
import {
SEARCH_FIELDS, accountCandidatePager, accountSearchKey, dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload,
type DutyAccountIds, type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type SearchFieldsChoice,
} from '../../rdmManagement'
import {
absorbServerDocs, dirtyDocs, editCell, savePayload,
type CellDuty, type DutyDocDraft, type DutyMember,
} from '../../duties/draft'
import { DutyByDocumentView, DutyByMemberView, DutyCandidateSearch, DutyChangePreview, DutyMatrixView } from '../../duties/views'
type ViewName = 'member' | 'document' | 'matrix'
interface MatrixDoc {
docId: string
title: string
code?: string
typeKey: string
stage?: string
dutyVersion: number
activeRound?: number
legacySectionOverrides?: number
}
interface MatrixOut {
canAssign: boolean
docs: MatrixDoc[]
members: DutyMember[]
cells: { docId: string; owner: string; duty: string }[]
nextDocCursor?: string
nextMemberCursor?: string
}
interface PreviewOut {
ok: boolean
docs: {
docId: string
title?: string
replacedAuthor?: string
replacedApprover?: string
needsConfirm?: boolean
reasons?: string[]
}[]
}
interface LegacyOut {
findings: { docId: string; title: string; sectionTitle: string; kind: string }[]
}
export function RdmDutiesSection({ project }: { project: Project }) {
const projectId = project.id
const writeError = projectWorkError(project)
const [view, setView] = useState<ViewName>('member')
const [docs, setDocs] = useState<DutyDocDraft[]>([])
const [members, setMembers] = useState<DutyMember[]>([])
const [member, setMember] = useState('')
const [docId, setDocId] = useState('')
const [query, setQuery] = useState('')
const [stage, setStage] = useState('')
const [typeKey, setTypeKey] = useState('')
const [assignment, setAssignment] = useState('')
const [docCursor, setDocCursor] = useState('')
const [nextDoc, setNextDoc] = useState('')
const [canAssign, setCanAssign] = useState(false)
const [narrow, setNarrow] = useState(false)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [preview, setPreview] = useState<PreviewOut['docs']>([])
const [confirm, setConfirm] = useState(false)
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
const [legacyOpen, setLegacyOpen] = useState(false)
const [candMode, setCandMode] = useState<MemberMatchMode>('fuzzy')
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
const [accountIds, setAccountIds] = useState<DutyAccountIds>({})
const seq = useRef(0)
const op = useRef(0)
const docsRef = useRef(docs)
docsRef.current = docs
const load = async (cursor: string, request = ++seq.current, replaceDirty = false) => {
const seenMembers: DutyMember[] = []
const seenCells: MatrixOut['cells'] = []
let pageDocs: MatrixDoc[] = []
let memberCursor = ''
let nextDocument = ''
let assignable = false
try {
do {
const page = await api.rdmProjectCommand<MatrixOut>(project, 'rdm.duty.matrix', dutyMatrixPayload(projectId, {
view: 'matrix', query, stage, typeKey, assignment, docCursor: cursor, memberCursor, docLimit: 10, memberLimit: 20,
}))
if (request !== seq.current) return
if (pageDocs.length === 0) pageDocs = page.docs ?? []
seenMembers.push(...(page.members ?? []))
seenCells.push(...(page.cells ?? []))
assignable = page.canAssign
nextDocument = page.nextDocCursor ?? ''
memberCursor = page.nextMemberCursor ?? ''
} while (memberCursor)
if (request !== seq.current) return
setMembers(seenMembers)
setCanAssign(assignable)
setNextDoc(nextDocument)
setDocs(current => absorbServerDocs(current, pageDocs, seenCells, replaceDirty))
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
}
}
useEffect(() => {
const media = window.matchMedia('(max-width: 720px)')
const apply = () => setNarrow(media.matches)
apply()
media.addEventListener('change', apply)
return () => media.removeEventListener('change', apply)
}, [])
useEffect(() => {
void load(docCursor)
return () => { seq.current += 1 }
}, [project, query, stage, typeKey, assignment, docCursor])
useEffect(() => { setAccountIds({}) }, [project])
const candOptions = useMemo(() => ({ docId, mode: candMode, searchFields: SEARCH_FIELDS[candFields].fields }),
[docId, candMode, candFields])
const fetchCandidates = useMemo(() => accountCandidatePager(
params => api.rdmProjectCommand<RdmAccountPage>(project, 'rdm.account.list', params), projectId, candOptions,
), [project, candOptions])
const onCell = (id: string, owner: string, duty: CellDuty) => {
setPreview([])
setConfirm(false)
setDocs(current => editCell(current, id, owner, duty))
}
const onCandidate = (id: string, candidate: PersonPickItem, duty: Exclude<CellDuty, 'none'>) => {
setAccountIds(current => ({ ...current, [candidate.owner]: candidate.id }))
onCell(id, candidate.owner, duty)
}
const visibleDocs = docs.filter(doc => {
const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase()
if (query && !text.includes(query.trim().toLowerCase())) return false
if (stage && doc.stage !== stage) return false
if (typeKey && doc.typeKey !== typeKey) return false
return true
})
const save = async () => {
const payload = savePayload(docsRef.current, confirm, accountIds)
if (payload.issues.length > 0) {
setErr(payload.issues.join(';'))
return
}
const request = ++seq.current
const ticket = ++op.current
setBusy(true); setErr(''); setNotice('')
let sent = false
try {
const looked = await api.rdmProjectCommand<PreviewOut>(project, 'rdm.duty.preview', dutyPreviewPayload(projectId, payload.docs))
if (request !== seq.current) return
setPreview(looked.docs ?? [])
if (!looked.ok) {
setErr('预览未通过,未写入')
return
}
if ((looked.docs ?? []).some(item => item.needsConfirm) && !confirm) {
setErr('在途审签将失效。确认后再保存。')
return
}
sent = true
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm, accountIds).docs))
if (request !== seq.current) return
setNotice('已保存文档职责')
setPreview([])
setConfirm(false)
setDocs(current => current.map(doc => ({ ...doc, dirty: false })))
await load(docCursor, request)
} catch (error) {
if (request !== seq.current) return
if (!sent) {
setErr(String(error))
return
}
setErr(`保存结果未确认:${error}`)
setConfirm(false)
await load(docCursor, request, true)
} finally {
if (ticket === op.current) setBusy(false)
}
}
const scanLegacy = async (request = ++seq.current) => {
try {
const out = await api.rdmProjectCommand<LegacyOut>(project, 'rdm.duty.legacy.scan', dutyLegacyPayload(projectId))
if (request !== seq.current) return
setLegacy(out.findings ?? [])
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
}
}
const applyIdentical = async () => {
const request = ++seq.current
const ticket = ++op.current
setBusy(true)
try {
await api.rdmProjectCommand(project, 'rdm.duty.legacy.apply', dutyLegacyPayload(projectId))
if (request !== seq.current) return
setNotice('已归并无歧义的章节职责')
await scanLegacy(request)
await load(docCursor, request)
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
} finally {
if (ticket === op.current) setBusy(false)
}
}
const disabled = busy || !!writeError || !canAssign
const ambiguous = legacy.filter(item => item.kind !== 'identical')
return (
<div>
<p className="ad-hint">本页只分配已有文档的职责,不创建或生成文档。</p>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-row" role="tablist" aria-label="职责视图">
<button className="ad-mini" role="tab" aria-selected={view === 'member'} onClick={() => setView('member')}>按人员</button>
<button className="ad-mini" role="tab" aria-selected={view === 'document'} onClick={() => setView('document')}>按文档</button>
<button className="ad-mini" role="tab" aria-selected={view === 'matrix'} onClick={() => setView('matrix')}>矩阵</button>
</div>
<div className="ad-row">
<input aria-label="筛选文档名称或编号" value={query} placeholder="名称或编号" disabled={busy} onChange={event => { setDocCursor(''); setQuery(event.target.value) }} />
<input aria-label="筛选类型" value={typeKey} placeholder="类型" disabled={busy} onChange={event => { setDocCursor(''); setTypeKey(event.target.value) }} />
<input aria-label="筛选阶段" value={stage} placeholder="阶段" disabled={busy} onChange={event => { setDocCursor(''); setStage(event.target.value) }} />
<select aria-label="筛选当前职责" value={assignment} disabled={busy} onChange={event => { setDocCursor(''); setAssignment(event.target.value) }}>
<option value="">全部</option>
<option value="assigned">已分配</option>
<option value="unassigned">未分配</option>
</select>
</div>
{view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />}
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
{view === 'document' && (() => {
const doc = docs.find(item => item.docId === docId)
return doc ? (
<DutyCandidateSearch doc={doc} mode={candMode} fields={candFields} fetchPage={fetchCandidates}
reloadKey={accountSearchKey(projectId, candOptions)} disabled={disabled}
onMode={setCandMode} onFields={setCandFields} onAssign={(candidate, duty) => onCandidate(doc.docId, candidate, duty)} />
) : null
})()}
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
<div className="ad-row">
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
{nextDoc && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor(nextDoc)}>下一页文档</button>}
<Btn variant="primary" icon="check" disabled={disabled || dirtyDocs(docs).length === 0} onClick={() => { void save() }}>保存职责</Btn>
</div>
<DutyChangePreview impacts={preview} confirm={confirm} onConfirm={setConfirm} />
<Collapsible id="duty-legacy" open={legacyOpen} onToggle={setLegacyOpen} trigger="旧章节职责">
<button className="ad-mini" disabled={busy} onClick={() => { void scanLegacy() }}>扫描</button>
<button className="ad-mini" disabled={disabled} onClick={() => { void applyIdentical() }}>归并无歧义覆盖</button>
{legacy.length === 0 ? <p className="ad-hint">尚未扫描,或没有章节级覆盖。</p> : (
<ul>
{legacy.map(item => <li key={`${item.docId}-${item.sectionTitle}`}>{item.title} / {item.sectionTitle}:{item.kind === 'identical' ? '与文档职责一致' : '有歧义,需逐文档确认'}</li>)}
</ul>
)}
{ambiguous.length > 0 && <p className="ad-hint">有歧义的文档不会自动覆盖。请在上面的视图里逐份确认后再保存。</p>}
</Collapsible>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{docs.length === 0 && !err && <EmptyState icon="inbox">暂无文档</EmptyState>}
</div>
)
}
-21
View File
@@ -1,21 +0,0 @@
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
* 不能沿用登录的「用户名或密码不正确」一刀切。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
-21
View File
@@ -1,21 +0,0 @@
import type { AdminIssuedOneTimePassword } from '../api'
export interface GeneratedCredential {
owner: string
password: string
kind: 'initial' | 'one-time'
}
export function oneTimePasswordCredential(out: AdminIssuedOneTimePassword): GeneratedCredential {
return { owner: out.owner, password: out.oneTimePassword, kind: 'one-time' }
}
export function GeneratedCredentialPanel({ credential, onClose }: { credential: GeneratedCredential; onClose: () => void }) {
return <div className="ad-reset">
<p className="ad-hint">{credential.kind === 'initial'
? `初始密码仅显示一次,请立即安全交付给 ${credential.owner}。`
: `一次性登录密码仅显示一次,请立即安全交付给 ${credential.owner};使用后必须强制改密。`}</p>
<input className="lg-input ad-mono" readOnly value={credential.password} aria-label="一次性密码" />
<button type="button" className="ad-mini" onClick={onClose}>关闭</button>
</div>
}
-27
View File
@@ -1,27 +0,0 @@
import { api, type PasswordChangeMode } from '../api'
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
return mode === 'current_required'
}
export function isForcedPasswordMode(mode: PasswordChangeMode | null | undefined): mode is PasswordChangeMode {
return mode === 'grant_setup' || mode === 'current_required' || mode === 'one_time_password'
}
export function ForcedPasswordModeSection({ mode, current, onCurrentChange }: { mode: PasswordChangeMode | null; current: string; onCurrentChange: (value: string) => void }) {
if (mode === 'current_required') {
return <>
<label className="lg-label" htmlFor="forced-current">当前密码</label>
<input id="forced-current" className="lg-input" type="password" value={current}
onChange={e => onCurrentChange(e.target.value)} autoComplete="current-password" />
</>
}
if (mode === 'grant_setup' || mode === 'one_time_password') {
return <p className="lg-hint">当前为受限设密会话,此次无需输入当前密码。</p>
}
return null
}
export async function submitForcedPassword(mode: PasswordChangeMode, current: string, next: string) {
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
}
-33
View File
@@ -1,33 +0,0 @@
// 统一渲染的失败与操作状态展示:工作区与各面板共用,只渲染,不持有异步状态。
// 原始回执只进 Collapsible 折叠区;可读文案由 describeRenderFailure 统一给出。
import { Btn, Collapsible } from '@ui'
import { describeRenderFailure, type RenderOperation } from '../documentRenderPresentation'
export interface OperationState { op: RenderOperation | null; status: 'idle' | 'running' | 'done' | 'failed'; error: string }
export function RenderFailure({ error, id }: { error: unknown; id: string }) {
const f = describeRenderFailure(error)
return (
<div className="drw-failure" role="alert">
<div className="drw-failure-msg">{f.message}</div>
<Collapsible id={`drw-failure-${id}`} trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
<code className="rdm-audit-err-raw">{f.raw}</code>
</Collapsible>
</div>
)
}
export function OperationStatus({ state, onRetry, labels }: { state: OperationState; onRetry: () => void; labels: Record<string, string> }) {
if (state.status === 'idle' || !state.op) return null
const label = labels[state.op.kind] ?? '操作'
if (state.status === 'running') return <div className="drw-op"><span className="mk-sweep">{label}进行中</span></div>
if (state.status === 'failed') {
return (
<div className="drw-op">
<RenderFailure error={state.error} id={`op-${state.op.kind}`} />
<Btn variant="ghost" onClick={onRetry}>重试</Btn>
</div>
)
}
return null
}
+2 -44
View File
@@ -150,23 +150,8 @@ export interface ActivityCard {
id: ID; projectId: ID; conversationId?: ID;
sourceRunId?: ID; anchorMessageId?: ID;
kind: CardKind; title: string; status: CardStatus; tone?: string;
task?: TaskSnapshot;
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
}
export interface WorkCard {
result?: string; resultVersion: number; readVersion: number; completedAt?: string;
decision?: WorkDecision;
}
export interface WorkDecision {
runId?: ID;
mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'dispatching' | 'submitted';
options?: { id: string; label: string }[];
selectedIds?: string[]; freeText?: string; respondedAt?: string;
}
export interface WorkCardAction {
resultVersion: number; selectedIds?: string[]; freeText?: string;
pinned?: boolean; createdAt: string; updatedAt: string;
}
export interface CardPayload { card: ActivityCard }
@@ -285,8 +270,6 @@ export interface SettingDesc {
default?: unknown; options?: string[];
effect: 'hot' | 'new-run' | 'restart';
secret?: boolean;
/** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */
scope?: 'system';
}
// ---- 插件 ----
@@ -352,7 +335,7 @@ export interface PluginPageDecl {
export interface ParsedDocument {
meta: {
filename: string; mime?: string; parser: string; confidence?: number; origin?: DocumentOrigin;
filename: string; mime?: string; parser: string; confidence?: number;
crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' };
parsedAt: string;
};
@@ -415,28 +398,3 @@ export interface SurfaceMeta {
conversationId?: ID;
updatedAt: string;
}
// Persistent work projection; host-owned identities and revisions.
export interface DocumentOrigin { artifactId: ID; contentHash: string; parserRevision: string }
export interface TaskPhase { id: string; label: string; observedAt?: string }
export interface TaskSourceRef { id: string; artifactId: ID; contentHash: string; parserRevision: string; blockIndex: number; label: string; quote?: string; availability?: string }
export interface TaskResultRef { artifactId?: ID; surfaceId?: ID; version?: string; contentHash?: string; availability: string; name?: string }
export interface TaskFinding { id: string; text: string; impact?: string; kind: string; status: string; revision: number; sources: TaskSourceRef[]; assumptions?: string; corrects?: string }
export interface TaskSnapshot {
inputs?: TaskSourceRef[]
limitations?: string[]
schemaVersion: number; taskId: ID; attemptId: ID; cardId: ID; projectId: ID; conversationId: ID;
sourceMessageId?: ID; parentRunId?: ID; runId?: ID; ownerInstanceId: string; executionEpoch?: number;
revision: number; sourceSeq: number; streamScope: string; inputRevision?: string; title: string; goal?: string;
executionState: 'queued'|'running'|'awaiting_user'|'completed'|'failed'|'interrupted'; outcomeCompleteness: 'none'|'partial'|'complete'; deliveryState: 'pending'|'available'|'reported'; phase: TaskPhase;
focus?: string; coverage?: string; remaining?: string; nextStep?: string; conclusion?: string;
findings?: TaskFinding[]; actions?: UIAction[]; resultRefs?: TaskResultRef[]; lastActivityAt?: string; lastMeaningfulUpdateAt?: string;
checkpointKeys?: Record<string,string>; checkpointCount?: number; checkpointSeq?: number;
}
export interface TaskSourceQuote { sourceId: string; blockIndex: number; quote: string }
interface WorkEvidencePremise { role: "total" | "phase" | "dependent" | "direct_relation"; sourceIndex: number; value?: number }
interface WorkFindingEvidence { mode: "direct_relation" | "difference" | "unresolved_relation"; premises: WorkEvidencePremise[]; result?: number }
export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[]; evidence?: WorkFindingEvidence }
export interface WorkCheckpointCandidate { checkpointId: string; phaseId: string; focus?: string; remaining?: string; nextStep?: string; findings: WorkFindingCandidate[] }
export interface UIAction { id: string; label: string; style?: string; command?: string }
-214
View File
@@ -1,214 +0,0 @@
// 文档驾驶舱 I2 纯逻辑层测试:能力门控、筛选切换、统计呈现(真 0 vs 不可算)、
// 快照一致性/更新提示、上下文键、选择保留、树徽标。组件取数编排不在此覆盖。
import { describe, expect, it } from 'vitest'
import {
contextKey, nodeSummaryLine, sameSnapshot, selectionSurvives, statDisplay,
STAT_FILTERS, toggleFilter, treeBadges, updateNotice,
type CockpitMeta, type CockpitStats, type CockpitTreeNode,
} from './documentCockpit'
import { cockpitReady, COCKPIT_COMMANDS } from './workspaces'
const meta = (rev: string, seq: number): CockpitMeta => ({
schemaVersion: 'document-cockpit.v1', projectId: 'p1', snapshotRevision: rev,
asOfSeq: seq, generatedAt: '2026-10-16T00:00:00Z', truncated: false,
})
describe('cockpitReady', () => {
const all = COCKPIT_COMMANDS.map(id => ({ id, enabled: true }))
it('七命令齐备且 enabled 才就绪', () => {
expect(cockpitReady(null)).toBe(false)
expect(cockpitReady([])).toBe(false)
expect(cockpitReady(all.map(c => ({ ...c, enabled: false })))).toBe(false)
expect(cockpitReady(all.slice(0, 6))).toBe(false)
expect(cockpitReady(all)).toBe(true)
})
})
describe('统计呈现:真 0 与不可算不得互相冒充', () => {
const stats: CockpitStats = {
documentCount: 0,
completion: { known: 3, total: 5, state: 'known' },
emptyNodeCount: 0,
unverifiedSourceCount: 2,
conflictCount: null as unknown as number,
pendingChangeCount: null,
updatedAt: '',
statReasons: { conflictCount: 'upstream_error', pendingChangeCount: 'not_projected_v1' },
}
it('真 0 显示 0', () => {
expect(statDisplay(stats, 'empty').value).toBe('0')
expect(statDisplay(stats, 'documents').value).toBe('0')
})
it('null 显示 — 并带原因', () => {
const d = statDisplay(stats, 'conflict')
expect(d.value).toBe('—')
expect(d.hint).toBe('upstream_error')
})
it('未纳入投影的统计显式标注原因', () => {
const d = statDisplay(stats, 'pending')
expect(d.value).toBe('—')
expect(d.hint).toBe('not_projected_v1')
})
it('完成度三分态:known/partial/unavailable', () => {
expect(statDisplay(stats, 'completion').value).toBe('3/5')
expect(statDisplay({ ...stats, completion: { known: 1, total: 5, state: 'partial' } }, 'completion').hint).toBe('部分计算')
const u = statDisplay({ ...stats, completion: { known: 0, total: 0, state: 'unavailable' } }, 'completion')
expect(u.value).toBe('—')
})
})
describe('筛选切换', () => {
it('点击切换、再点清除;统计键与筛选值一一对应', () => {
expect(toggleFilter('', 'state:empty')).toBe('state:empty')
expect(toggleFilter('state:empty', 'state:empty')).toBe('')
expect(toggleFilter('state:empty', 'state:conflict')).toBe('state:conflict')
expect(STAT_FILTERS.map(s => s.filter)).toEqual(['state:empty', 'state:unverified', 'state:conflict'])
})
})
describe('快照一致性与更新提示', () => {
it('同 revision 同 seq 视为同快照', () => {
expect(sameSnapshot(meta('wm:5', 5), meta('wm:5', 5))).toBe(true)
expect(sameSnapshot(meta('wm:5', 5), meta('wm:6', 6))).toBe(false)
expect(sameSnapshot(undefined, meta('wm:5', 5))).toBe(false)
})
it('展示快照落后时提示刷新,一致时不提示', () => {
expect(updateNotice(meta('wm:5', 5), meta('wm:6', 6))).toContain('wm:6')
expect(updateNotice(meta('wm:6', 6), meta('wm:6', 6))).toBeNull()
expect(updateNotice(null, meta('wm:6', 6))).toBeNull()
})
})
describe('旧响应丢弃的上下文键', () => {
it('项目与筛选任一变化都改变键', () => {
expect(contextKey('p1', '')).not.toBe(contextKey('p2', ''))
expect(contextKey('p1', '')).not.toBe(contextKey('p1', 'state:empty'))
expect(contextKey('p1', 'state:empty')).toBe(contextKey('p1', 'state:empty'))
})
})
describe('刷新不重置选择', () => {
it('文档仍在根清单则保留', () => {
const kept = selectionSurvives({ kind: 'document', id: 'd1' }, ['d1', 'd2'])
expect(kept).toEqual({ kind: 'document', id: 'd1' })
})
it('文档被筛掉则清空', () => {
expect(selectionSurvives({ kind: 'document', id: 'd9' }, ['d1', 'd2'])).toBeNull()
})
it('章节选择随父文档存活', () => {
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d1'], 'd1')).toEqual({ kind: 'section', id: 's1' })
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d2'], 'd1')).toBeNull()
expect(selectionSurvives(null, ['d1'])).toBeNull()
})
})
describe('树徽标与状态行', () => {
const node = (over: Partial<CockpitTreeNode['state']> & { visibility?: string }): CockpitTreeNode => ({
ref: { kind: 'section', id: 's1' }, title: '章节', path: [], childCount: 0,
visibility: over.visibility || 'full',
state: {
content: 'complete', verification: 'not_applicable', sourceCount: 0,
conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true, ...over,
},
} as CockpitTreeNode)
it('诚实徽标:空/受限/未核实/冲突计数', () => {
expect(treeBadges(node({}))).toEqual([])
expect(treeBadges(node({ content: 'empty' }))).toContain('空')
expect(treeBadges(node({ verification: 'unverified' }))).toContain('未核实')
expect(treeBadges(node({ conflictCount: 2 }))).toContain('冲突 2')
expect(treeBadges(node({ visibility: 'metadata_only' }))).toContain('仅元数据')
})
it('结构缺口显式标注(冻结事实:doc→section 两级)', () => {
const line = nodeSummaryLine(node({ content: 'empty' }))
expect(line).toContain('内容为空')
expect(line).toContain('层级止于章节')
})
})
// ---- I3:阅读器与右栏纯逻辑 ----
import {
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
prefsKey, mergePrefs, loadPrefs,
type CockpitHistorySummary, type CockpitVersionSummary,
} from './documentCockpit'
describe('偏好:按用户+项目隔离,枚举外脏值回落默认', () => {
it('prefsKey 区分用户与项目', () => {
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('a', 'p2'))
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('b', 'p1'))
})
it('mergePrefs 逐项校验', () => {
expect(mergePrefs(null)).toEqual({ lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' })
expect(mergePrefs({ lastView: 'reader', density: 'full', rightPanelTab: 'conflicts' })).toEqual({
lastView: 'reader', density: 'full', rightPanelTab: 'conflicts',
})
const junk = mergePrefs({ lastView: 'galaxy', density: 42, rightPanelTab: 'nope' })
expect(junk.lastView).toBe('panorama')
expect(junk.density).toBe('compact')
expect(junk.rightPanelTab).toBe('sources')
})
it('loadPrefs 在无 localStorage 环境安全回落', () => {
expect(loadPrefs('a', 'p1').lastView).toBe('panorama')
})
})
describe('版本摘要与行级差异', () => {
const v = (seq: number, content?: string, redacted?: boolean): CockpitVersionSummary => ({
seq, version: `v${seq}`, at: '2026-10-16T00:00:00Z', basisCount: 0, content, redacted,
})
it('当前+前一版;受限整体降级', () => {
const s = versionSummary([v(3, '三'), v(2, '二'), v(1, '一')])
expect(s.current?.version).toBe('v3')
expect(s.previous?.version).toBe('v2')
expect(s.redacted).toBe(false)
const r = versionSummary([v(3, undefined, true)])
expect(r.redacted).toBe(true)
expect(versionSummary(undefined).current).toBeUndefined()
})
it('diffLines 标记新增/删除/保留', () => {
const d = diffLines('共同\n新增行', '共同\n删除行')
expect(d).toContainEqual({ flag: '=', text: '共同' })
expect(d).toContainEqual({ flag: '+', text: '新增行' })
expect(d).toContainEqual({ flag: '-', text: '删除行' })
})
})
describe('历史严格合并:乱序与重复丢弃(DV-R05)', () => {
const h = (seq: number): CockpitHistorySummary => ({ seq, at: '', op: 'x', summary: `s${seq}` })
it('只接受 seq 更大的条目', () => {
const r = mergeHistoryStrict([h(10), h(9)], [h(11), h(8)])
expect(r.items.map(i => i.seq)).toEqual([10, 9, 11])
expect(r.dropped).toBe(1)
})
it('重复 seq 丢弃且计入 dropped', () => {
const r = mergeHistoryStrict([h(10)], [h(10), h(11)])
expect(r.items.map(i => i.seq)).toEqual([10, 11])
expect(r.dropped).toBe(1)
})
it('空历史接受全部', () => {
expect(mergeHistoryStrict([], [h(1), h(2)]).items.map(i => i.seq)).toEqual([1, 2])
})
it('historyLine 带操作者与本地时间', () => {
expect(historyLine(h(1))).toContain('s1')
})
})
describe('阅读器装配:受限章节给占位不冒充空内容', () => {
const sec = (id: string, content: string): CockpitTreeNode => ({
ref: { kind: 'section', id }, title: id, path: [], childCount: 0,
state: { content, verification: 'not_applicable', sourceCount: 0, conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true },
visibility: 'full',
})
it('正文/受限/未加载三分态', () => {
const out = assembleReader(
[sec('a', 'complete'), sec('b', 'complete'), sec('c', 'empty')],
{
a: { body: [{ kind: 'text', text: '正文A' }] },
b: { body: [{ kind: 'restricted', reason: 'section-duty-or-authorship' }] },
},
)
expect(out[0].blocks).toEqual([{ kind: 'text', text: '正文A' }])
expect(out[1].blocks[0].kind).toBe('restricted')
expect(out[2].blocks[0].reason).toBe('not_loaded')
})
})
-342
View File
@@ -1,342 +0,0 @@
// 文档驾驶舱 I2 纯逻辑层(rollout I2;契约:Dev 仓 docs/superpowers/specs
// 2026-10-16-document-visualization-read-model-contract.md @ 37b272ba,
// 四项取舍冻结见 plans/…-rollout.md §7)。只放可测判定:筛选切换、统计呈现
//(真 0 vs 不可算)、快照一致性与更新提示、上下文键与选择保留。组件只做取数编排与渲染。
// ---- 契约类型(I1 后端 DTO 的前端精简形态) ----
export interface CockpitNodeRef { kind: string; id: string; version?: string }
export interface CockpitMeta {
schemaVersion: string
projectId: string
snapshotRevision: string
asOfSeq: number
generatedAt: string
truncated: boolean
truncationReasons?: string[]
}
export interface CockpitNodeState {
content: string // complete|partial|empty|inaccessible|unavailable
verification: string // verified|mixed|unverified|disputed|not_applicable
sourceCount: number
conflictCount: number
impactCount: number
missingChildCount: number
structuralGap: boolean
}
export interface CockpitTreeNode {
ref: CockpitNodeRef
title: string
path: string[]
parent?: CockpitNodeRef
childCount: number
state: CockpitNodeState
lastChangedAt?: string
lastChangedBy?: string
visibility: string // full|redacted|metadata_only
}
export interface CockpitStats {
documentCount: number
completion: { known: number; total: number; state: string }
emptyNodeCount: number
unverifiedSourceCount: number
conflictCount: number
pendingChangeCount: number | null
updatedAt?: string
statReasons: Record<string, string>
}
export interface CockpitChangeSummary {
node: CockpitNodeRef
title: string
changedAt: string
actor?: string
kind: string
summary: string
version?: string
}
export interface CockpitOverview {
meta: CockpitMeta
project: { title: string }
stats: CockpitStats
documents: CockpitTreeNode[]
recentChanges: CockpitChangeSummary[]
}
export interface CockpitTreePage {
meta: CockpitMeta
items: CockpitTreeNode[]
nextCursor?: string
hasMore: boolean
}
// ---- 统计带:点击即切换共享筛选(视图规格 §2;未知筛选由后端 400 拒绝) ----
export const STAT_FILTERS: { key: string; label: string; filter: string }[] = [
{ key: 'empty', label: '空缺节点', filter: 'state:empty' },
{ key: 'unverified', label: '未核实来源', filter: 'state:unverified' },
{ key: 'conflict', label: '冲突', filter: 'state:conflict' },
]
/** 点击统计卡:同筛选再次点击=清除(只定位,不执行写入)。 */
export function toggleFilter(current: string, filter: string): string {
return current === filter ? '' : filter
}
/** 统计呈现:null(不可算)显示 — 加原因;真 0 显示 0。两侧都不得互相冒充(契约 §2/§6)。 */
export function statDisplay(stats: CockpitStats, key: string): { value: string; hint?: string } {
const reasons = stats.statReasons || {}
if (key === 'documents') return { value: String(stats.documentCount) }
if (key === 'completion') {
const c = stats.completion
if (c.state === 'unavailable') return { value: '—', hint: reasons['completion'] || '无法计算' }
if (c.state === 'partial') return { value: `${c.known}/${c.total}`, hint: '部分计算' }
return { value: `${c.known}/${c.total}` }
}
const map: Record<string, number | null | undefined> = {
empty: stats.emptyNodeCount,
unverified: stats.unverifiedSourceCount,
conflict: stats.conflictCount,
pending: stats.pendingChangeCount,
}
const v = map[key]
// 原因表按后端 DTO 字段名(statReasons 键=字段名),展示键需映射。
const reasonKey: Record<string, string> = {
empty: 'emptyNodeCount', unverified: 'unverifiedSourceCount',
conflict: 'conflictCount', pending: 'pendingChangeCount',
}
if (v === null || v === undefined) return { value: '—', hint: reasons[reasonKey[key] || key] || '暂不可计算' }
return { value: String(v) }
}
// ---- 快照一致性(DV-R05 前端侧):统计与树徽标同快照才同屏可信 ----
export function sameSnapshot(a?: CockpitMeta, b?: CockpitMeta): boolean {
if (!a || !b) return false
return a.snapshotRevision === b.snapshotRevision && a.asOfSeq === b.asOfSeq
}
/** 更新提示:展示中快照落后于最新已见快照时提示刷新(不抢占滚动,视图规格 §5)。 */
export function updateNotice(viewed: CockpitMeta | null, latest: CockpitMeta | null): string | null {
if (!viewed || !latest) return null
if (viewed.snapshotRevision === latest.snapshotRevision && viewed.asOfSeq === latest.asOfSeq) return null
return `内容已更新(快照 ${latest.snapshotRevision})——刷新查看最新状态`
}
/** 上下文键:项目+筛选标识一轮取数上下文;不匹配的旧响应直接丢弃(契约 §8)。 */
export function contextKey(projectId: string, filter: string): string {
return `${projectId}\u0001${filter}`
}
/** 刷新不重置选择:文档仍在根清单(或选中章节的父文档仍在)则保留,否则清空。 */
export function selectionSurvives(
selected: CockpitNodeRef | null,
rootDocIds: string[],
parentDocId?: string,
): CockpitNodeRef | null {
if (!selected) return null
if (selected.kind === 'document') return rootDocIds.includes(selected.id) ? selected : null
if (parentDocId && rootDocIds.includes(parentDocId)) return selected
return null
}
/** 树行徽标(诚实状态:空/部分/受限/未核实/冲突计数;不虚构成功)。 */
export function treeBadges(n: CockpitTreeNode): string[] {
const out: string[] = []
if (n.state.content === 'empty') out.push('空')
else if (n.state.content === 'partial') out.push('部分')
else if (n.state.content === 'inaccessible') out.push('受限')
if (n.visibility === 'metadata_only') out.push('仅元数据')
if (n.state.verification === 'unverified') out.push('未核实')
if (n.state.conflictCount > 0) out.push(`冲突 ${n.state.conflictCount}`)
return out
}
/** 节点状态一句话总结(全景 §4.2)。 */
export function nodeSummaryLine(n: CockpitTreeNode): string {
const parts: string[] = []
const content = { complete: '内容完整', partial: '内容部分', empty: '内容为空', inaccessible: '内容受限', unavailable: '暂不可读' }[n.state.content] || n.state.content
parts.push(content)
if (n.state.structuralGap) parts.push('层级止于章节')
if (n.state.sourceCount > 0) parts.push(`来源 ${n.state.sourceCount}`)
if (n.state.conflictCount > 0) parts.push(`冲突 ${n.state.conflictCount}`)
if (n.state.impactCount > 0) parts.push(`影响 ${n.state.impactCount}`)
return parts.join(' · ')
}
// ---- I3:阅读器与右栏纯逻辑 ----
export interface CockpitVersionSummary {
seq: number
version: string
actor?: string
at: string
basisCount: number
content?: string
redacted?: boolean
}
export interface CockpitContentBlock { kind: string; text?: string; reason?: string }
export interface CockpitSourceSummary {
ref: { kind: string; id: string }
label: string
capturedAt?: string
confidence: string
verification: string
visibility: string
redactedReason?: string
}
export interface CockpitHistorySummary {
seq: number
at: string
op: string
actor?: string
summary: string
operationId?: string
}
export interface CockpitImpactSummary {
ref: { kind: string; id: string }
edgeType: string
edgeState: string
direction: string
layer: string
}
export interface CockpitAssertion { node: CockpitNodeRef; claim: string; visibility: string; redactedReason?: string }
export interface CockpitConflictSummary {
id: string
node: CockpitNodeRef
state: string
left: CockpitAssertion
right: CockpitAssertion
detectedAt: string
resolutionRef?: string
}
export interface CockpitPage<T> { meta?: CockpitMeta; items: T[]; nextCursor?: string; hasMore: boolean; truncated?: boolean }
export const PANEL_TABS = [
{ id: 'sources', label: '内容来源' },
{ id: 'history', label: '修改历史' },
{ id: 'impacts', label: '影响对象' },
{ id: 'conflicts', label: '冲突与风险' },
] as const
export type PanelTabId = (typeof PANEL_TABS)[number]['id']
export type ViewMode = 'panorama' | 'reader'
export type Density = 'compact' | 'full'
export interface CockpitPrefs {
lastView: ViewMode
density: Density
rightPanelTab: PanelTabId
}
/** 偏好按 用户+项目 隔离(设计 §7.3);仅本地存储,不构成授权。 */
export function prefsKey(actor: string, projectId: string): string {
return `cockpit-prefs:${actor}:${projectId}`
}
/** 解析偏好:枚举外的脏值逐项回落默认(panorama+compact+sources)。 */
export function mergePrefs(raw: unknown): CockpitPrefs {
const base: CockpitPrefs = { lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' }
if (!raw || typeof raw !== 'object') return base
const r = raw as Record<string, unknown>
const out = { ...base }
if (r.lastView === 'panorama' || r.lastView === 'reader') out.lastView = r.lastView
if (r.density === 'compact' || r.density === 'full') out.density = r.density
if (PANEL_TABS.some(t => t.id === r.rightPanelTab)) out.rightPanelTab = r.rightPanelTab as PanelTabId
return out
}
export function loadPrefs(actor: string, projectId: string): CockpitPrefs {
try {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(prefsKey(actor, projectId)) : null
return mergePrefs(raw ? JSON.parse(raw) : null)
} catch {
return mergePrefs(null)
}
}
export function savePrefs(actor: string, projectId: string, prefs: CockpitPrefs): void {
try {
if (typeof localStorage !== 'undefined') localStorage.setItem(prefsKey(actor, projectId), JSON.stringify(prefs))
} catch { /* 存储不可用时偏好静默降级为会话内 */ }
}
/** 版本摘要:当前版本 + 前一版本(视图规格 §5)。versions 为最新在前;受限版本无 content。 */
export function versionSummary(versions: CockpitVersionSummary[] | undefined): {
current?: CockpitVersionSummary
previous?: CockpitVersionSummary
redacted: boolean
} {
if (!versions || versions.length === 0) return { redacted: false }
const current = versions[0]
const previous = versions.length > 1 ? versions[1] : undefined
return { current, previous, redacted: !!current.redacted }
}
/** 历史面板合并:只接受严格更新(seq 更大且未见)的条目;过期与重复都计入 dropped(DV-R05/契约 §8)。 */
export function mergeHistoryStrict(existing: CockpitHistorySummary[], incoming: CockpitHistorySummary[]): {
items: CockpitHistorySummary[]
dropped: number
} {
const seen = new Set(existing.map(e => e.seq))
const maxSeq = existing.length > 0 ? Math.max(...seen) : -1
const items = [...existing]
let dropped = 0
for (const e of incoming) {
if (e.seq <= maxSeq || seen.has(e.seq)) {
dropped++
continue
}
seen.add(e.seq)
items.push(e)
}
return { items, dropped }
}
/** 阅读器装配:把文档的章节详情按树顺序组装为连续正文;受限章节给占位说明而非空白。 */
export function assembleReader(
sections: CockpitTreeNode[],
details: Record<string, { body?: CockpitContentBlock[]; versions?: CockpitVersionSummary[] } | undefined>,
): { ref: CockpitNodeRef; title: string; contentState: string; blocks: CockpitContentBlock[]; versions?: CockpitVersionSummary[] }[] {
return sections.map(sec => {
const d = details[sec.ref.id]
const readable = !!d?.body && d.body.length > 0 && d.body[0].kind === 'text'
const restricted = !!d?.body && d.body.length > 0 && d.body[0].kind === 'restricted'
const blocks: CockpitContentBlock[] = readable
? (d!.body as CockpitContentBlock[])
: restricted
? [{ kind: 'restricted', reason: d?.body?.[0]?.reason || 'section-duty-or-authorship' }]
: [{ kind: 'restricted', reason: 'not_loaded' }]
return { ref: sec.ref, title: sec.title, contentState: sec.state.content, blocks, versions: d?.versions }
})
}
/** 行级差异(阅读器 Collapsible 的完整对比):按行对齐的朴素 LCS 免实现——
* 以行为单位做集合比对标记 +/=/-,足够呈现「完整差异」而不冒充精确补丁。 */
export function diffLines(current: string, previous: string): { flag: '+' | '-' | '='; text: string }[] {
const cur = current.split('\n')
const prev = new Set(previous.split('\n'))
const curSet = new Set(cur)
const out: { flag: '+' | '-' | '='; text: string }[] = []
for (const line of cur) out.push({ flag: prev.has(line) ? '=' : '+', text: line })
for (const line of previous.split('\n')) if (!curSet.has(line)) out.push({ flag: '-', text: line })
return out
}
/** 历史条目的自然语言时间线文本(右栏 §6)。 */
export function historyLine(h: CockpitHistorySummary): string {
const who = h.actor ? `${h.actor} ` : ''
return `${h.summary}(${who}${new Date(h.at).toLocaleString('zh-CN')})`
}
-124
View File
@@ -1,124 +0,0 @@
// 统一渲染 Task 11(审签来源):整文审签的正式命令必须锚定一条已提交的对话消息(基座来源门禁)。
// 流程:宿主项目的审签记录对话 → 提交一条中文审签说明(用户消息,幂等编号 = 动作编号)→ 以该消息为锚点分发命令。
// 任一步失败都可读地拒绝且绝不分发命令;身份只由服务端认证主体决定,载荷不含任何身份字段。
import type { Conversation, Message, Project } from './contracts'
import { describeRenderFailure, type DecisionKind } from './documentRenderPresentation'
import { rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
export const DECISION_RECORD_TITLE = '文档审签记录'
const DECISION_ENTRANCE: CommandSource['entrance'] = 'button'
const CONVERSATION_STAGE = '审签记录对话'
const SOURCE_STAGE = '审签来源消息'
/** 只需要的会话端口:调用方传入共享的 api 对象,不另起一层 API。 */
export interface DecisionPort {
listConversations: (projectId: string) => Promise<Conversation[]>
createConversation: (projectId: string, title?: string) => Promise<Conversation>
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) => Promise<{ message?: Message; command?: string }>
rdmCommand: (id: string, params: Record<string, unknown>, source?: CommandSource) => Promise<unknown>
}
export interface DecisionRequest {
host: Project | undefined
/** 同一用户动作的编号:重试复用,服务端据此幂等返回同一条来源消息。 */
operationId: string
decision: DecisionKind
doc: { id: string; title: string }
round: number
note: string
command: string
params: Record<string, unknown>
}
function decisionLabel(kind: DecisionKind): string {
switch (kind) {
case 'approve': return '审核通过'
case 'reject': return '驳回'
case 'final': return '终批'
default: {
const exhaustive: never = kind
throw new Error(`unknown decision kind: ${String(exhaustive)}`)
}
}
}
/** 审签来源正文(中文):决定、文档、轮次与说明/原因;不含任何身份字段,首字符不为 “/” 以免被当作命令。 */
export function decisionSourceText(input: Pick<DecisionRequest, 'decision' | 'doc' | 'round' | 'note'>): string {
const doc = input.doc.title ? `${input.doc.title}(${input.doc.id})` : input.doc.id
const noteLabel = input.decision === 'reject' ? '驳回原因' : '说明'
return [
`审签决定:${decisionLabel(input.decision)}`,
`文档:${doc}`,
`轮次:第 ${input.round} 轮`,
`${noteLabel}:${input.note.trim() || '无'}`,
'来源:文档渲染工作区',
].join('\n')
}
/** 研发项目 → 其宿主对话项目(settings 中绑定的 rdm.projectId 相同者)。 */
export function hostProjectFor(projects: Project[], rdmProjectId: string): Project | undefined {
return projects.find(p => {
try { return rdmCommandProjectId(p) === rdmProjectId } catch { return false }
})
}
function refusal(stage: string, detail: string): Error {
return new Error(`${stage}未能提交,审签未执行:${detail}`)
}
function stepFailure(stage: string, err: unknown): Error {
const raw = err instanceof Error ? err.message : String(err ?? '')
if (/\b403\b/.test(raw)) return refusal(stage, '当前账号无权在该对话中记录审签')
if (/\b409\b/.test(raw)) return refusal(stage, '会话正在处理其他任务,请稍后重试')
if (/\b429\b/.test(raw)) return refusal(stage, '会话排队已满,请稍后重试')
return refusal(stage, describeRenderFailure(err).message)
}
/** 记录对话:复用宿主项目中标题为审签记录、未归档的对话;没有则新建。 */
async function recordConversation(port: DecisionPort, projectId: string): Promise<Conversation> {
let list: Conversation[]
try {
list = await port.listConversations(projectId)
} catch (e) {
throw stepFailure(CONVERSATION_STAGE, e)
}
const existing = list.find(c => c.title === DECISION_RECORD_TITLE && !c.archived)
if (existing) return existing
try {
return await port.createConversation(projectId, DECISION_RECORD_TITLE)
} catch (e) {
throw stepFailure(CONVERSATION_STAGE, e)
}
}
/** 提交来源消息:必须是已提交的同会话用户消息;被当作命令路由的说明不算来源。 */
async function recordSource(port: DecisionPort, convId: string, req: DecisionRequest): Promise<string> {
let out: { message?: Message; command?: string }
try {
out = await port.sendMessage(convId, decisionSourceText(req), [], `decision:${req.operationId}`)
} catch (e) {
throw stepFailure(SOURCE_STAGE, e)
}
if (out.command) throw refusal(SOURCE_STAGE, '审签说明被识别为命令,未作为来源记录')
const msg = out.message
if (!msg?.id || msg.role !== 'user' || msg.conversationId !== convId) {
throw refusal(SOURCE_STAGE, '服务端未返回已提交的对话消息')
}
return msg.id
}
/**
* 整文审签的唯一写路径:先记录来源,再以来源消息为锚点分发命令。
* 宿主项目缺失、项目未就绪、任一记录步骤失败时,命令一律不分发。
*/
export async function commitDecision(port: DecisionPort, req: DecisionRequest): Promise<unknown> {
const host = req.host
if (!host) throw new Error('未找到该研发项目对应的对话项目,请刷新项目列表后重试;审签未执行')
const gate = rdmProjectCommandError(host, req.command)
if (gate) throw new Error(`${gate};审签未执行`)
const conversation = await recordConversation(port, host.id)
const anchorMessageId = await recordSource(port, conversation.id, req)
return port.rdmCommand(req.command, req.params, {
conversationId: conversation.id, anchorMessageId, entrance: DECISION_ENTRANCE,
})
}
File diff suppressed because it is too large Load Diff
-207
View File
@@ -1,207 +0,0 @@
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
import { dutyAccountIds, type DutyAccountIds } from '../rdmManagement'
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
interface DutyRoles {
author: string
reviewers: string[]
approver: string
}
export interface DutyDocDraft extends DutyRoles {
docId: string
title: string
code: string
typeKey: string
stage: string
dutyVersion: number
activeRound: number
legacySectionOverrides: number
dirty: boolean
}
export interface DutyMember {
owner: string
role: string
valid: boolean
canAuthor: boolean
canReview: boolean
canApprove: boolean
}
interface DutySaveItem {
docId: string
author: string
reviewers: string[]
approver: string
expectedDutyVersion: number
confirmInvalidation: boolean
accountIds?: DutyAccountIds
}
export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
if (roles.author === owner) return 'author'
if (roles.approver === owner) return 'approver'
if (roles.reviewers.includes(owner)) return 'reviewer'
return 'none'
}
export function applyCell(roles: DutyRoles, owner: string, duty: CellDuty): {
roles: DutyRoles
replacedAuthor: string
replacedApprover: string
} {
const replacedAuthor = duty === 'author' && roles.author !== '' && roles.author !== owner ? roles.author : ''
const replacedApprover = duty === 'approver' && roles.approver !== '' && roles.approver !== owner ? roles.approver : ''
let author = roles.author === owner ? '' : roles.author
let approver = roles.approver === owner ? '' : roles.approver
let reviewers = roles.reviewers.filter(person => person !== owner)
switch (duty) {
case 'author':
author = owner
break
case 'approver':
approver = owner
break
case 'reviewer':
reviewers = [...reviewers, owner]
break
case 'none':
break
default: {
const exhaustive: never = duty
throw new Error(exhaustive)
}
}
return { roles: { author, reviewers, approver }, replacedAuthor, replacedApprover }
}
export function replacementNote(roles: DutyRoles, owner: string, duty: CellDuty): string {
if (duty === 'author' && roles.author !== '' && roles.author !== owner) return `将替换${roles.author}`
if (duty === 'approver' && roles.approver !== '' && roles.approver !== owner) return `将替换${roles.approver}`
return ''
}
function dutyConflicts(roles: DutyRoles): number {
const seen = new Map<string, number>()
const mark = (owner: string) => {
if (!owner) return
seen.set(owner, (seen.get(owner) ?? 0) + 1)
}
mark(roles.author)
mark(roles.approver)
for (const reviewer of roles.reviewers) mark(reviewer)
let conflicts = 0
for (const count of seen.values()) if (count > 1) conflicts += 1
return conflicts
}
export function completeness(roles: DutyRoles): { authors: number; reviewers: number; approvers: number; conflicts: number } {
return {
authors: roles.author ? 1 : 0,
reviewers: roles.reviewers.length,
approvers: roles.approver ? 1 : 0,
conflicts: dutyConflicts(roles),
}
}
export function saveIssues(roles: DutyRoles): string[] {
const issues: string[] = []
if (!roles.author) issues.push('需指定编制人')
if (roles.reviewers.length === 0) issues.push('审核至少一人')
if (!roles.approver) issues.push('需指定批准人')
if (dutyConflicts(roles) > 0) issues.push('一人只能担一个职责')
return issues
}
function rolesFromCells(docId: string, cells: { docId: string; owner: string; duty: string }[]): DutyRoles {
const roles: DutyRoles = { author: '', reviewers: [], approver: '' }
for (const cell of cells) {
if (cell.docId !== docId) continue
if (cell.duty !== 'none' && cell.duty !== 'author' && cell.duty !== 'reviewer' && cell.duty !== 'approver') continue
const next = applyCell(roles, cell.owner, cell.duty)
roles.author = next.roles.author
roles.reviewers = next.roles.reviewers
roles.approver = next.roles.approver
}
return roles
}
export function absorbServerDocs(
current: DutyDocDraft[],
docs: {
docId: string; title: string; code?: string; typeKey: string; stage?: string
dutyVersion: number; activeRound?: number; legacySectionOverrides?: number
}[],
cells: { docId: string; owner: string; duty: string }[],
replaceDirty = false,
): DutyDocDraft[] {
const byId = new Map(current.map(doc => [doc.docId, doc]))
const pageIds = new Set(docs.map(doc => doc.docId))
const next: DutyDocDraft[] = []
for (const doc of docs) {
const previous = byId.get(doc.docId)
const serverRoles = rolesFromCells(doc.docId, cells)
const keepDraft = !replaceDirty && previous?.dirty === true
next.push({
docId: doc.docId,
title: doc.title,
code: doc.code ?? doc.docId,
typeKey: doc.typeKey,
stage: doc.stage ?? '',
dutyVersion: keepDraft ? previous.dutyVersion : doc.dutyVersion,
activeRound: doc.activeRound ?? 0,
legacySectionOverrides: doc.legacySectionOverrides ?? 0,
dirty: keepDraft,
author: keepDraft ? previous.author : serverRoles.author,
reviewers: keepDraft ? previous.reviewers : serverRoles.reviewers,
approver: keepDraft ? previous.approver : serverRoles.approver,
})
}
for (const doc of current) {
if (!replaceDirty && doc.dirty && !pageIds.has(doc.docId)) next.push(doc)
}
return next
}
export function editCell(docs: DutyDocDraft[], docId: string, owner: string, duty: CellDuty): DutyDocDraft[] {
return docs.map(doc => {
if (doc.docId !== docId) return doc
const next = applyCell(doc, owner, duty)
return { ...doc, ...next.roles, dirty: true }
})
}
export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
return docs.filter(doc => doc.dirty)
}
/** accountIds:候选检索选中的 owner → userId;每份文档只携带其职责涉及的人。 */
export function savePayload(
docs: DutyDocDraft[], confirmInvalidation: boolean, accountIds?: DutyAccountIds,
): { docs: DutySaveItem[]; issues: string[] } {
const dirty = dirtyDocs(docs)
const issues: string[] = []
if (dirty.length === 0) issues.push('没有未保存的职责')
if (dirty.length > 20) issues.push('一次保存最多 20 份文档')
for (const doc of dirty) {
for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`)
}
return {
docs: dirty.slice(0, 20).map(doc => {
const ids = dutyAccountIds(doc, accountIds)
return {
docId: doc.docId,
author: doc.author,
reviewers: doc.reviewers,
approver: doc.approver,
expectedDutyVersion: doc.dutyVersion,
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
...(ids ? { accountIds: ids } : {}),
}
}),
issues,
}
}
-270
View File
@@ -1,270 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Picker } from '@ui'
import type { CellDuty, DutyDocDraft, DutyMember } from './draft'
import { cellDuty, completeness, replacementNote, saveIssues } from './draft'
import {
MATCH_MODE_LABELS, SEARCH_FIELDS,
type MemberMatchMode, type PersonPickItem, type SearchFieldsChoice,
} from '../rdmManagement'
const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [
{ value: 'none', label: '无职责' },
{ value: 'author', label: '编制' },
{ value: 'reviewer', label: '审核' },
{ value: 'approver', label: '批准' },
]
function DutySelect({
doc, owner, disabled, onChange,
}: {
doc: DutyDocDraft
owner: string
disabled: boolean
onChange: (duty: CellDuty) => void
}) {
const current = cellDuty(doc, owner)
return (
<label className="duty-cell">
<select
aria-label={`${owner} 在 ${doc.title} 的职责`}
value={current}
disabled={disabled}
onChange={event => {
const value = event.target.value
if (value === 'none' || value === 'author' || value === 'reviewer' || value === 'approver') onChange(value)
}}
>
{DUTY_OPTIONS.map(option => {
const hint = replacementNote(doc, owner, option.value)
return <option key={option.value} value={option.value}>{hint ? `${option.label}(${hint})` : option.label}</option>
})}
</select>
</label>
)
}
export function DutyByMemberView({
member, members, docs, disabled, onMember, onCell,
}: {
member: string
members: DutyMember[]
docs: DutyDocDraft[]
disabled: boolean
onMember: (owner: string) => void
onCell: (docId: string, owner: string, duty: CellDuty) => void
}) {
return (
<div>
<div className="setting-row">
<label htmlFor="duty-member">成员</label>
<select id="duty-member" aria-label="选择成员" value={member} onChange={event => onMember(event.target.value)}>
<option value="">选择成员</option>
{members.map(person => <option key={person.owner} value={person.owner}>{person.owner}</option>)}
</select>
</div>
{!member ? <p className="ad-hint">先选择成员,再为每份文档指定无职责、编制、审核或批准。</p> : (
<table className="ad-table">
<thead><tr><th>文档</th><th>类型 / 阶段</th><th>职责</th><th>完整性</th></tr></thead>
<tbody>
{docs.map(doc => {
const score = completeness(doc)
return (
<tr key={doc.docId}>
<td>{doc.title}<span className="ad-dim ad-mono"> · {doc.code}</span></td>
<td className="ad-dim">{doc.typeKey}{doc.stage ? ` / ${doc.stage}` : ''}</td>
<td><DutySelect doc={doc} owner={member} disabled={disabled} onChange={duty => onCell(doc.docId, member, duty)} /></td>
<td className="ad-dim">编 {score.authors}/1 · 审 {score.reviewers} · 批 {score.approvers}/1</td>
</tr>
)
})}
{docs.length === 0 && <tr><td colSpan={4} className="ad-dim">没有符合筛选的文档</td></tr>}
</tbody>
</table>
)}
</div>
)
}
export function DutyByDocumentView({
docId, docs, members, disabled, onDoc, onCell,
}: {
docId: string
docs: DutyDocDraft[]
members: DutyMember[]
disabled: boolean
onDoc: (id: string) => void
onCell: (docId: string, owner: string, duty: CellDuty) => void
}) {
const doc = docs.find(item => item.docId === docId)
const score = doc ? completeness(doc) : null
const issues = doc ? saveIssues(doc) : []
return (
<div>
<div className="setting-row">
<label htmlFor="duty-doc">文档</label>
<select id="duty-doc" aria-label="选择文档" value={docId} onChange={event => onDoc(event.target.value)}>
<option value="">选择整份文档</option>
{docs.map(item => <option key={item.docId} value={item.docId}>{item.title}</option>)}
</select>
</div>
{score && <p className="ad-hint">编制 {score.authors}/1、审核 {score.reviewers} 人、批准 {score.approvers}/1、冲突 {score.conflicts}{issues.length > 0 ? `。${issues.join(',')}` : ''}</p>}
{!doc ? <p className="ad-hint">先选择整份文档。这里不能选择章节,也没有生成入口。</p> : (
<table className="ad-table">
<thead><tr><th>成员</th><th>项目身份</th><th>职责</th></tr></thead>
<tbody>
{members.map(person => (
<tr key={person.owner}>
<td className="ad-mono">{person.owner}{person.valid ? '' : '(无效)'}</td>
<td className="ad-dim">{person.role === 'owner' ? '项目负责人' : '项目成员'}</td>
<td><DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} /></td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
type AssignedDuty = Exclude<CellDuty, 'none'>
/** 文档职责候选检索:rdm.account.list 携带 docId,只有当前有效成员可选;选中后按 userId 指派。 */
export function DutyCandidateSearch({
doc, mode, fields, fetchPage, reloadKey, disabled, onMode, onFields, onAssign,
}: {
doc: DutyDocDraft
mode: MemberMatchMode
fields: SearchFieldsChoice
fetchPage: (query: string, cursor: string, limit: number) => Promise<{ items: PersonPickItem[]; nextCursor?: string }>
reloadKey: string
disabled: boolean
onMode: (mode: MemberMatchMode) => void
onFields: (fields: SearchFieldsChoice) => void
onAssign: (candidate: PersonPickItem, duty: AssignedDuty) => void
}) {
const [picked, setPicked] = useState<PersonPickItem | null>(null)
const [duty, setDuty] = useState<AssignedDuty>('reviewer')
const known = useRef(new Map<string, PersonPickItem>())
useEffect(() => { known.current.clear(); setPicked(null) }, [reloadKey])
const fetchKnown = useCallback(async (query: string, cursor: string, limit: number) => {
const page = await fetchPage(query, cursor, limit)
for (const item of page.items) known.current.set(item.id, item)
return page
}, [fetchPage])
return (
<div className="settings-group">
<h4>搜索候选人</h4>
<div className="ad-row">
<select className="lg-input" aria-label="候选匹配方式" value={mode} onChange={event => onMode(event.target.value as MemberMatchMode)}>
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m => <option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select>
<select className="lg-input" aria-label="候选检索字段" value={fields} onChange={event => onFields(event.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f => <option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
</div>
<div className="ad-row">
<Picker
aria-label="职责候选人"
selected={picked ? [picked.id] : []}
selectedItems={picked ? [picked] : []}
onSelect={ids => setPicked(known.current.get(ids[0] ?? '') ?? null)}
fetchPage={fetchKnown}
reloadKey={reloadKey}
disabled={disabled}
placeholder={mode === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示当前有效成员"
/>
<select className="lg-input" aria-label="候选人职责" value={duty} onChange={event => setDuty(event.target.value as AssignedDuty)}>
{DUTY_OPTIONS.filter(option => option.value !== 'none').map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<button className="ad-mini" disabled={disabled || !picked || picked.disabled}
onClick={() => { if (picked) { onAssign(picked, duty); setPicked(null) } }}>
{picked ? `指派到 ${doc.title}` : '指派'}
</button>
</div>
{picked && <p className="ad-hint">{picked.title} · {picked.owner}{replacementNote(doc, picked.owner, duty) ? `(${replacementNote(doc, picked.owner, duty)})` : ''}</p>}
</div>
)
}
export function DutyMatrixView({
docs, members, disabled, narrow, onCell,
}: {
docs: DutyDocDraft[]
members: DutyMember[]
disabled: boolean
narrow: boolean
onCell: (docId: string, owner: string, duty: CellDuty) => void
}) {
if (narrow) {
return <p className="ad-hint">当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。</p>
}
return (
<div className="duty-matrix-wrap">
<table className="ad-table duty-matrix">
<thead>
<tr>
<th className="duty-sticky">成员</th>
{docs.map(doc => {
const score = completeness(doc)
const ready = score.authors === 1 && score.reviewers >= 1 && score.approvers === 1 && score.conflicts === 0
return <th key={doc.docId}>{doc.title}<span className={ready ? 'ad-chip' : 'ad-chip ad-chip-warn'}>{ready ? '三权齐' : '未齐'}</span></th>
})}
</tr>
</thead>
<tbody>
{members.map(person => (
<tr key={person.owner}>
<td className="duty-sticky ad-mono">{person.owner}</td>
{docs.map(doc => (
<td key={doc.docId}>
<DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export function DutyChangePreview({
impacts, confirm, onConfirm,
}: {
impacts: {
docId: string
title?: string
replacedAuthor?: string
replacedApprover?: string
needsConfirm?: boolean
reasons?: string[]
}[]
confirm: boolean
onConfirm: (value: boolean) => void
}) {
if (impacts.length === 0) return null
const needs = impacts.some(item => item.needsConfirm)
return (
<div className="settings-group">
<h4>保存前预览</h4>
<ul>
{impacts.map(item => (
<li key={item.docId}>
{item.title || item.docId}
{item.replacedAuthor ? `:编制将替换${item.replacedAuthor}` : ''}
{item.replacedApprover ? `:批准将替换${item.replacedApprover}` : ''}
{item.needsConfirm ? ';在途审签将失效,历史名单保持不变' : ''}
{item.reasons && item.reasons.length > 0 ? `;${item.reasons.join(',')}` : ''}
</li>
))}
</ul>
{needs && (
<label>
<input type="checkbox" checked={confirm} onChange={event => onConfirm(event.target.checked)} />
确认使受影响的在途审签失效
</label>
)}
</div>
)
}
+5 -86
View File
@@ -3,9 +3,8 @@
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定。
// 参数增量(编写中)一到即算工具,解说不用等工具真正开始执行才离开回复区。
import type { Message, Part, StreamEvent } from './contracts'
import type { Message, Part } from './contracts'
import type { LiveState } from './store'
import { splitTimeline, type ReplayPart } from './messageparts'
export type ToolLiveState = 'composing' | 'running' | 'done'
@@ -13,7 +12,6 @@ export type ToolLiveState = 'composing' | 'running' | 'done'
export type LiveItem =
| { kind: 'thinking'; text: string; startedAt: number; endedAt: number }
| { kind: 'text'; index: number; text: string }
| { kind: 'agent'; text: string; agentRun?: string; agentPhase?: 'spawn' | 'progress' | 'result' }
| {
kind: 'tool'
callId?: string
@@ -120,13 +118,13 @@ export function applyToolCall(items: LiveItem[], name: string, callId?: string,
}
// applyToolOutput 把执行中的输出追加到对应工具,只保留尾部,避免长日志撑住内存。
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string, maxChars = OUTPUT_TAIL): LiveItem[] {
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string): LiveItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
if (!isTool(it) || it.state === 'done') continue
if (callId && it.callId && it.callId !== callId) continue
let output = it.output + text
if (output.length > maxChars) output = output.slice(output.length - maxChars)
if (output.length > OUTPUT_TAIL) output = output.slice(output.length - OUTPUT_TAIL)
const out = items.slice()
out[i] = { ...it, output }
return out
@@ -151,27 +149,6 @@ export function applyCheck(items: LiveItem[], passed: boolean, reason: string):
return [...items, { kind: 'check', passed, reason }]
}
/** A child streams cumulative previews. Replace that child's latest preview in
* place, while preserving real phase changes and the order of other children. */
function applyAgentProgress(items: LiveItem[], agentRun: string, note: string): LiveItem[] {
const text = note.trim()
if (!text) return items
for (let i = items.length - 1; i >= 0; i--) {
const previous = items[i]
if (previous.kind !== 'agent' || previous.agentRun !== agentRun) continue
if (previous.agentPhase !== 'progress') break
const value = previous.text.slice('协作进展:'.length)
if (value === text || value.startsWith(text)) return items
if (text.startsWith(value) && !/^调用\s/.test(text)) {
const next = items.slice()
next[i] = { ...previous, text: `协作进展:${text}` }
return next
}
break
}
return [...items, { kind: 'agent', text: `协作进展:${text}`, agentRun, agentPhase: 'progress' }]
}
// applyFinalizing 标记收尾开始:此前的正文都是过程,此后的正文才是给用户的回复。
export function applyFinalizing(items: LiveItem[]): LiveItem[] {
if (items.some(it => it.kind === 'final')) return items
@@ -216,9 +193,7 @@ export function liveToMessage(live: LiveState): Message {
parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } })
continue
}
if (it.kind === 'agent') {
parts.push({ type: 'text', text: it.text, process: true, replaySource: 'agent' } as ReplayPart)
} else if (it.kind === 'thinking') {
if (it.kind === 'thinking') {
const durationMs = it.endedAt - it.startedAt
if (it.text) parts.push({
type: 'thinking', text: it.text, process: true,
@@ -246,63 +221,7 @@ export function liveToMessage(live: LiveState): Message {
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
}
}
const createdAt = live.startedAt > 0 && Number.isFinite(live.startedAt) ? new Date(live.startedAt).toISOString() : ''
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt }
}
/** Rebuild one run's process from its durable ordered events, without the live preview's output-tail limit. */
export function runEventsToTimeline(events: StreamEvent[], parentRunId = events[0]?.runId, completeRecord = false): ReplayPart[] {
let items: LiveItem[] = []
let finalizing = false
for (const event of [...events].sort((a, b) => a.seq - b.seq)) {
const p = event.payload as Record<string, unknown> | undefined
const now = Date.parse(event.ts) || 0
if (event.runId !== parentRunId && event.type !== 'agent.progress' && event.type !== 'agent.result') continue
switch (event.type) {
case 'agent.spawn':
if (typeof p?.name === 'string') items.push({ kind: 'agent', text: `协作代理「${p.name}」已开始`,
agentRun: String(p.agentRun ?? event.runId ?? ''), agentPhase: 'spawn' })
break
case 'agent.progress':
if (typeof p?.note === 'string') items = applyAgentProgress(items, String(p.agentRun ?? event.runId ?? ''), p.note)
break
case 'agent.result':
items.push({ kind: 'agent', text: `协作结束:${typeof p?.summary === 'string' && p.summary.trim() ? p.summary : String(p?.status ?? '已结束')}`,
agentRun: String(p?.agentRun ?? event.runId ?? ''), agentPhase: 'result' })
break
case 'thinking.delta':
if (typeof p?.text === 'string') items = applyThinkingDelta(items, p.text, now)
break
case 'message.delta':
if ((!finalizing || completeRecord) && typeof p?.text === 'string') items = applyTextDelta(items, typeof p.index === 'number' ? p.index : 0, p.text)
break
case 'tool.call.delta':
if (typeof p?.delta === 'string') items = applyToolCallDelta(items, typeof p.index === 'number' ? p.index : 0,
p.delta, typeof p.callId === 'string' ? p.callId : undefined, typeof p.name === 'string' ? p.name : undefined, now)
break
case 'tool.call':
items = applyToolCall(items, typeof p?.name === 'string' ? p.name : '工具',
typeof p?.callId === 'string' ? p.callId : undefined,
typeof p?.arguments === 'string' ? p.arguments : undefined, now)
break
case 'tool.output':
if (typeof p?.text === 'string') items = applyToolOutput(items,
typeof p.callId === 'string' ? p.callId : undefined, p.text, Number.POSITIVE_INFINITY)
break
case 'tool.result':
items = applyToolResult(items, typeof p?.callId === 'string' ? p.callId : undefined,
typeof p?.status === 'string' ? p.status : 'ok', typeof p?.summary === 'string' ? p.summary : undefined, now)
break
case 'run.phase':
if (p?.phase === 'finalizing') { finalizing = true; items = applyFinalizing(items) }
break
case 'run.check':
items = applyCheck(items, !!p?.passed, typeof p?.reason === 'string' ? p.reason : '')
break
}
}
const live: LiveState = { runId: events[0]?.runId ?? '', phase: 'done', items, agentNotes: {}, startedAt: 0 }
return completeRecord ? liveToMessage(live).parts : splitTimeline(liveToMessage(live).parts).timeline
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
}
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
+10 -75
View File
@@ -1,4 +1,5 @@
// 消息切分:每轮过程按落库顺序留在可回看的时间线,终轮回复直接可读。
// 消息切分:技术记录留在过程时间线,正文、错误与文件入口始终直接可读。
// 两区各自保留落库顺序,后续工具或终轮检查不再把解说藏入折叠区。
import type { Part } from './contracts'
export interface TimelineSplit {
@@ -6,86 +7,20 @@ export interface TimelineSplit {
final: Part[]
}
/** Client-only provenance. It is never written to the Core message contract. */
export interface ReplayPart extends Part { replaySource?: 'agent' }
/** 最后一个 process 部件之前的记录属于该轮过程;错误与文件入口始终可见。
* 终态没有回复时,只把最后一段过程解说提升为可见回复。 */
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
/** 最后一个 process 部件之前的技术记录留在时间线,其余为可见内容。
* 保留旧 promoteLastText 参数以兼容调用方;解说现在始终可见,不再依赖终态提升。 */
export function splitTimeline(parts: Part[], _promoteLastText = false): TimelineSplit {
let last = -1
for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i
if (last < 0) return { timeline: [], final: parts }
const timeline: Part[] = []
const final: Part[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (i > last || part.type === 'error' || part.type === 'artifact_ref') final.push(part)
else timeline.push(part)
}
if (promoteLastText && !final.some(part => part.type === 'text' || part.type === 'error')) {
let idx = -1
for (let i = timeline.length - 1; i >= 0; i--) {
if (timeline[i].type === 'text') { idx = i; break }
}
if (idx >= 0) final.unshift(...timeline.splice(idx, 1))
}
// text 是公开解说;thinking、工具参数与结果、终轮检查不提升为正文。
const visible = (part: Part) => part.type === 'text' || part.type === 'error' || part.type === 'artifact_ref'
const before = parts.slice(0, last + 1)
const timeline = before.filter(part => !visible(part))
const final = [...before.filter(visible), ...parts.slice(last + 1)]
return { timeline, final }
}
const normalized = (text?: string) => (text ?? '').replace(/\s+/g, ' ').trim()
function sameProcessPart(saved: Part, replayed: Part): boolean {
if (saved.type !== replayed.type) return false
switch (saved.type) {
case 'tool_call': return !!saved.toolCall?.id && saved.toolCall.id === replayed.toolCall?.id
case 'tool_result': return !!saved.toolResult?.callId && saved.toolResult.callId === replayed.toolResult?.callId
case 'check': return saved.check?.passed === replayed.check?.passed && saved.check?.reason === replayed.check?.reason
case 'thinking':
case 'text': return normalized(saved.text) === normalized(replayed.text)
default: return false
}
}
/** Keep the settled message as the content authority; add only event-only records
* and full tool output. Event deltas can be missing, while terminal parts can
* contain retry notes or parsed inline thinking that the event stream cannot reconstruct. */
export function mergeProcessTimeline(saved: Part[], replayed: ReplayPart[], final: Part[]): Part[] {
if (replayed.length === 0) return saved
const finalTexts = final.filter(part => part.type === 'text').map(part => normalized(part.text)).filter(Boolean)
const merged: Part[] = []
let cursor = 0
let pending: Part[] = []
for (const part of replayed) {
const text = normalized(part.text)
const agentNote = part.replaySource === 'agent'
if (part.type === 'text' && !agentNote && ((text && finalTexts.some(value => value.includes(text) || text.includes(value)))
|| /<\/?think\b/i.test(part.text ?? ''))) continue
let match = -1
for (let i = cursor; i < saved.length; i++) {
if (sameProcessPart(saved[i], part)) { match = i; break }
}
if (match < 0) {
// A missing delta batch can leave only a prefix in events. The complete
// terminal part is authoritative; never render that prefix beside it.
if (!agentNote && (part.type === 'text' || part.type === 'thinking') && text && saved.some(item => {
if (item.type !== part.type) return false
const value = normalized(item.text)
return value && (value.includes(text) || text.includes(value))
})) continue
pending.push(part)
continue
}
merged.push(...saved.slice(cursor, match), ...pending)
pending = []
const output = (part as Part & { toolOutput?: string }).toolOutput
merged.push(output && saved[match].type === 'tool_call'
? { ...saved[match], toolOutput: output } as Part : saved[match])
cursor = match + 1
}
merged.push(...saved.slice(cursor), ...pending)
return merged
}
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
export function dedupeDeliverables(parts: Part[]): { artifact: NonNullable<Part['artifact']>; updates: number }[] {
const order: string[] = []
+1 -6
View File
@@ -1,22 +1,17 @@
// 输入排队(纯数据):回合进行中先入本地队列,终态再逐条发送。
export interface WorkReference { cardId: string; resultVersion: number; title: string }
export interface QueuedMessage {
id: string
text: string
attachmentIds: string[]
attachmentNames: string[]
taskRef?: WorkReference
deliveryError?: string
}
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference): QueuedMessage {
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[]): QueuedMessage {
return {
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
text,
attachmentIds,
attachmentNames,
...(taskRef ? { taskRef } : {}),
}
}
+11
View File
@@ -0,0 +1,11 @@
// 浏览器端随机初始密码(管理员未显式给定密码时):
// crypto.getRandomValues 拒绝易混淆字符(0/O/1/l/I),保证可抄写。
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'
export function randomPassword(length = 12): string {
const buf = new Uint32Array(length)
crypto.getRandomValues(buf)
let out = ''
for (let i = 0; i < length; i++) out += ALPHABET[buf[i] % ALPHABET.length]
return out
}
+1 -2
View File
@@ -3,14 +3,13 @@
export interface PreviewTab {
id: string
name: string
sourceContext?: {notice:string;quote:string;expectedHash?:string}
}
export function openPreviewTab(tabs: PreviewTab[], tab: PreviewTab): { tabs: PreviewTab[]; activeId: string } {
const i = tabs.findIndex(t => t.id === tab.id)
if (i >= 0) {
const next = tabs.slice()
next[i] = { ...next[i], name: tab.name || next[i].name,sourceContext:tab.sourceContext }
next[i] = { ...next[i], name: tab.name || next[i].name }
return { tabs: next, activeId: tab.id }
}
return { tabs: [...tabs, tab], activeId: tab.id }
-38
View File
@@ -3,41 +3,3 @@ export function projectStage(settings?: Record<string, unknown> | null): string
const stage = settings?.stage
return typeof stage === 'string' && stage.trim() !== '' ? stage.trim() : null
}
/** Service metadata describes readiness; permission is always checked by the server. */
export function projectWorkError(project: { settings?: Record<string, unknown> } | undefined): string | null {
if (!project) return '项目已不可用,请刷新项目列表或联系项目负责人'
const settings = project.settings
if (!settings || (!('rdm.state' in settings) && !('rdm.projectId' in settings))) return null
switch (settings['rdm.state']) {
case 'ready':
if (typeof settings['rdm.projectId'] === 'string' && settings['rdm.projectId'].trim()) return null
break
case 'provisioning': return '项目正在准备,请稍后刷新项目列表再试'
case 'failed': return '项目准备失败,请联系项目负责人重试创建'
case 'archived': return '项目已归档,请联系项目负责人恢复后再新建对话'
case 'closed': return '项目已关闭,请选择其他项目或联系项目负责人'
}
return '项目尚未绑定,请联系项目负责人完成项目接入'
}
export function projectStateLabel(settings?: Record<string, unknown>): string | null {
switch (settings?.['rdm.state']) {
case 'provisioning': return '准备中'
case 'failed': return '准备失败'
case 'unbound': return '未接入'
case 'archived': return '已归档'
case 'closed': return '已关闭'
default: return null
}
}
export function projectActionError(error: unknown): string {
const raw = String(error)
if (/\b401\b/.test(raw)) return '登录已失效,请重新登录后再试'
if (/\b403\b/.test(raw)) return '当前账号无法管理此项目,请联系项目负责人'
if (/\b404\b/.test(raw)) return '项目已不可用,请刷新项目列表或联系项目负责人'
if (/\b409\b/.test(raw)) return '项目名称已被占用或项目状态已变化,请更换名称或刷新后再试'
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
return raw.replace(/^Error:\s*/, '')
}
-121
View File
@@ -1,121 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState,
isTreeItemExpandable, relationOtherEndpoint,
} from './rdmAuditPresentation'
describe('RDM audit presentation', () => {
it('turns a structured document event into a readable action and impact', () => {
expect(describeAuditEvent({
seq: 18,
at: '2026-10-15T08:00:00Z',
op: 'doc.instantiate',
actor: 'alice',
source: 'template:dhf-proposal@v2',
target: { kind: 'doc', id: 'doc-1', title: '项目建议书' },
detail: '{"raw":"must not be shown"}',
operationId: 'op-1',
disclosure: 'ok',
result: 'committed',
impact: '新增 12 个递归节点',
reason: '从已发布模板建立项目实例',
})).toEqual({
action: '创建文档实例',
subject: 'Alice',
object: '文档“项目建议书”',
outcome: '已生效',
reason: '从已发布模板建立项目实例',
impact: '新增 12 个递归节点',
source: '模板:DHF 项目建议书(版本 v2)',
})
})
it('does not expose unknown operation codes or raw JSON as user-facing text', () => {
const view = describeAuditEvent({
seq: 19,
at: '2026-10-15T08:01:00Z',
op: 'internal.weird_opcode',
actor: 'user-42',
source: 'host',
target: { kind: 'node', id: 'node-secret' },
detail: '{"secret":"raw payload"}',
operationId: 'op-2',
disclosure: 'restricted',
})
expect(view.action).toBe('更新项目内容')
expect(JSON.stringify(view)).not.toContain('internal.weird_opcode')
expect(JSON.stringify(view)).not.toContain('raw payload')
expect(view.outcome).toBe('部分信息受限')
expect(view.object).toBe('节点(node-secret)')
expect(view.source).toBe('系统受信操作记录')
})
it('preserves domain provenance and object identity in readable audit rows', () => {
expect(describeAuditEvent({
seq: 20,
at: '2026-10-15T08:02:00Z',
op: 'edge.candidate',
actor: 'model-runner',
source: 'model',
target: { kind: 'edge', id: 'edg-7' },
operationId: 'op-3',
disclosure: 'model-candidate',
})).toMatchObject({
action: '提出追溯关系候选',
object: '追溯边(edg-7)',
source: '模型分析候选',
})
expect(describeAuditEvent({
seq: 21,
at: '2026-10-15T08:03:00Z',
op: 'section.add',
actor: 'alice',
source: 'human',
target: { kind: 'section', id: 'sec-2' },
operationId: 'op-4',
disclosure: 'ok',
}).source).toBe('项目成员操作')
})
it('humanizes tree, relationship, and lifecycle vocabulary', () => {
expect(humanizeKind('section')).toBe('章节')
expect(humanizeState('candidate')).toBe('待确认')
expect(humanizeState('retired')).toBe('已停用')
expect(humanizeState('stale')).toBe('待复核')
expect(humanizeRelation({ layer: 'compliance', type: 'verifies', state: 'active' })).toEqual({
layer: '合规关系',
type: '验证',
state: '已生效',
})
})
it('allows recursive sections to expand when the server reports children', () => {
expect(isTreeItemExpandable({ kind: 'doc', id: 'doc-1', title: '文档', hasChildren: true })).toBe(true)
expect(isTreeItemExpandable({ kind: 'doc', id: 'doc-2', title: '空文档', hasChildren: false })).toBe(false)
expect(isTreeItemExpandable({ kind: 'section', id: 'sec-1', title: '章节', hasChildren: true })).toBe(true)
expect(isTreeItemExpandable({ kind: 'paragraph', id: 'p-1', title: '段落', hasChildren: false })).toBe(false)
})
it('gives untitled template nodes a distinct readable name', () => {
expect(describeTreeItem({
kind: 'paragraph',
id: 'dn-1',
title: '',
order: 6,
templateBlockId: 'dhf.dx.a7850d1881.3.placeholder',
})).toEqual({ kind: '段落', title: '模板内容 7(3)' })
expect(describeTreeItem({ kind: 'table', id: 'dn-2', title: '', order: 2 })).toEqual({
kind: '表格',
title: '模板内容 3',
})
})
it('shows the endpoint opposite the selected object in a trace relationship', () => {
const relation = {
from: { kind: 'slice', id: 'slc-6' },
to: { kind: 'section', id: 'sec-2' },
}
expect(relationOtherEndpoint(relation, { kind: 'section', id: 'sec-2' })).toEqual(relation.from)
expect(relationOtherEndpoint(relation, { kind: 'slice', id: 'slc-6' })).toEqual(relation.to)
})
})
-184
View File
@@ -1,184 +0,0 @@
export interface AuditEventViewInput {
seq: number
at: string
op: string
actor: string
source: string
target: { kind: string; id: string; title?: string }
detail?: string
operationId: string
disclosure: string
summary?: string
result?: string
reason?: string
impact?: string
}
interface AuditEventPresentation {
action: string
subject: string
object: string
outcome: string
reason: string
impact: string
source: string
}
const KIND_LABELS: Record<string, string> = {
doc: '文档', section: '章节', node: '节点', note: '知识', slice: '资料切片', spine: '知识主题', edge: '追溯边',
paragraph: '段落', table: '表格', row: '表格行', cell: '表格单元格',
field: '填写字段', attachment: '附件',
requirement: '要求', risk: '风险', proposal: '变更提案', relation: '追溯关系',
template: '模板', project: '项目', conclusion: '结论', issue: '问题',
}
const STATE_LABELS: Record<string, string> = {
active: '已生效', candidate: '待确认', pending: '待处理', baselined: '已建立基线',
retired: '已停用', stale: '待复核',
approved: '已批准', rejected: '已拒绝', revoked: '已撤销', superseded: '已被取代',
failed: '失败', committed: '已生效', accepted: '已接受', cancelled: '已取消',
delivered: '已记录',
unknown: '结果未知', restricted: '部分信息受限',
}
const OP_LABELS: Record<string, string> = {
'doc.instantiate': '创建文档实例',
'section.add': '新增文档章节',
'section.version': '更新文档内容',
'section.state': '变更章节状态',
'section.tombstone': '停用文档章节',
'slice.ingest': '新增项目资料',
'slice.retag': '更新资料标签',
'slice.tombstone': '停用项目资料',
'spine.add': '新增知识主题',
'mount.add': '挂接项目资料',
'mount.move': '移动项目资料',
'mount.remove': '移除资料挂接',
'edge.candidate': '提出追溯关系候选',
'edge.confirm': '确认追溯关系',
'edge.retire': '停用追溯关系',
'change.requested': '提出变更申请',
'change.approved': '批准变更申请',
'change.applied': '应用变更',
'open-item.add': '登记项目问题',
'open-item.close': '关闭项目问题',
'dictionary.revise': '修订项目字典',
'req.change': '更新项目要求',
}
const RELATION_LABELS: Record<string, string> = {
verifies: '验证', supports: '支持', derives: '派生自', impacts: '影响',
traces: '追溯到', replaces: '取代', conflicts: '存在冲突', references: '引用',
}
const LAYER_LABELS: Record<string, string> = {
compliance: '合规关系', knowledge: '知识关系', document: '文档关系', project: '项目关系',
}
export function humanizeKind(kind: string): string {
return KIND_LABELS[kind] || '项目对象'
}
export function describeTreeItem(item: {
kind: string
id: string
title?: string
order?: number
templateBlockId?: string
}): { kind: string; title: string } {
const title = item.title?.trim()
if (title) return { kind: humanizeKind(item.kind), title }
const position = typeof item.order === 'number' ? item.order + 1 : undefined
const parts = (item.templateBlockId || '').split('.').filter(Boolean)
const semantic = new Set(['placeholder', 'example', 'fixed'])
while (parts.length > 0 && semantic.has(parts[parts.length - 1])) parts.pop()
const hint = parts.at(-1)
const numbered = position ? `模板内容 ${position}` : '未命名模板内容'
return {
kind: humanizeKind(item.kind),
title: hint ? `${numbered}(${hint})` : numbered,
}
}
export function humanizeState(state: string): string {
return STATE_LABELS[state] || '状态已记录'
}
function humanizeOperation(op: string): string {
if (OP_LABELS[op]) return OP_LABELS[op]
if (op.startsWith('doc.')) return '更新文档内容'
if (op.startsWith('section.')) return '更新文档内容'
if (op.startsWith('slice.') || op.startsWith('spine.') || op.startsWith('mount.') || op.startsWith('knowledge.')) return '更新项目知识'
if (op.startsWith('edge.') || op.startsWith('relation.') || op.startsWith('trace.')) return '更新追溯关系'
if (op.startsWith('change.')) return '处理项目变更'
if (op.startsWith('open-item.')) return '处理项目问题'
if (op.startsWith('risk.')) return '处理项目风险'
if (op.startsWith('proposal.')) return '处理结构变更'
return '更新项目内容'
}
function humanizeActor(actor: string): string {
if (!actor) return '未知成员'
if (/^[a-z][a-z0-9._-]*$/i.test(actor) && !actor.includes('-') && !actor.includes('_')) {
return actor.charAt(0).toUpperCase() + actor.slice(1)
}
return actor
}
function humanizeSource(source: string): string {
if (!source) return '当时未记录来源'
const template = /^template:([^@]+)@(.+)$/.exec(source)
if (template) {
const title = template[1] === 'dhf-proposal' ? 'DHF 项目建议书' : template[1]
return `模板:${title}(版本 ${template[2]})`
}
if (source === 'host') return '系统受信操作记录'
if (source === 'human') return '项目成员操作'
if (source === 'rule') return '项目规则推导'
if (source === 'model') return '模型分析候选'
if (source.startsWith('message:')) return '项目对话'
if (source.startsWith('artifact:')) return '项目文件'
return '已登记项目来源'
}
function humanizeOutcome(input: AuditEventViewInput): string {
if (input.disclosure !== 'ok') return input.disclosure === 'model-candidate' ? '模型候选,尚未确认' : '部分信息受限'
return humanizeState(input.result || 'committed')
}
export function describeAuditEvent(input: AuditEventViewInput): AuditEventPresentation {
const targetTitle = input.target?.title ? `“${input.target.title}”` : ''
const targetIdentity = targetTitle || (input.target?.id ? `(${input.target.id})` : '')
return {
action: input.summary || humanizeOperation(input.op),
subject: humanizeActor(input.actor),
object: `${humanizeKind(input.target?.kind)}${targetIdentity}`,
outcome: humanizeOutcome(input),
reason: input.reason || '未记录具体原因',
impact: input.impact || '影响范围尚未记录',
source: humanizeSource(input.source),
}
}
export function humanizeRelation(input: { layer?: string; type?: string; kind?: string; state?: string }) {
const type = input.type || input.kind || ''
return {
layer: LAYER_LABELS[input.layer || ''] || '项目关系',
type: RELATION_LABELS[type] || '关联',
state: humanizeState(input.state || ''),
}
}
export function relationOtherEndpoint<T extends { kind?: string; id?: string }>(
relation: { from?: T; to?: T },
selected: { kind: string; id: string },
): T | undefined {
if (relation.to?.kind === selected.kind && relation.to.id === selected.id) return relation.from
if (relation.from?.kind === selected.kind && relation.from.id === selected.id) return relation.to
return relation.from || relation.to
}
export function isTreeItemExpandable(item: { kind: string; hasChildren?: boolean; id?: string; title?: string }): boolean {
return item.kind === 'spine' || item.hasChildren === true
}
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, it } from 'vitest'
import { collectRecursiveTree, isTreeItemInspectable, mergeTreePage, type AuditTreePage } from './rdmAuditTree'
describe('RDM audit tree traversal', () => {
it('keeps child cursors and appends later pages', () => {
const first: AuditTreePage = {
items: [{ kind: 'paragraph', id: 'p-1', title: '一' }],
nextCursor: 'next', hasMore: true, truncated: false,
}
const second: AuditTreePage = {
items: [{ kind: 'paragraph', id: 'p-2', title: '二' }],
hasMore: false, truncated: false,
}
expect(mergeTreePage(first, second)).toEqual({
items: [...first.items, ...second.items],
hasMore: false,
truncated: false,
})
})
it('collects every page and every server-reported depth for trace selection', async () => {
const pages = new Map<string, AuditTreePage>([
['|', { items: [{ kind: 'doc', id: 'doc-1', title: '文档', hasChildren: true }], nextCursor: 'root-2', hasMore: true, truncated: false }],
['|root-2', { items: [{ kind: 'doc', id: 'doc-2', title: '文档二', hasChildren: false }], hasMore: false, truncated: false }],
['doc-1|', { items: [{ kind: 'section', id: 'dn-sec', title: '模板章', templateBlockId: 'tpl.sec', hasChildren: true }], nextCursor: 'child-2', hasMore: true, truncated: false }],
['doc-1|child-2', { items: [{ kind: 'section', id: 'sec-2', title: '正文', hasChildren: false }], hasMore: false, truncated: false }],
['dn-sec|', { items: [{ kind: 'paragraph', id: 'p-deep', title: '深层内容', hasChildren: false }], hasMore: false, truncated: false }],
])
const items = await collectRecursiveTree(async (parent, cursor) => {
const page = pages.get(`${parent}|${cursor}`)
if (!page) throw new Error(`unexpected page ${parent}|${cursor}`)
return page
})
expect(items.map(item => item.id)).toEqual(['doc-1', 'doc-2', 'dn-sec', 'sec-2', 'p-deep'])
})
it('only requests object details for legacy sections supported by the server', () => {
expect(isTreeItemInspectable({ kind: 'section', id: 'sec-2', title: '正文', templateBlockId: '' })).toBe(true)
expect(isTreeItemInspectable({ kind: 'section', id: 'dn-sec', title: '模板章', templateBlockId: 'tpl.sec' })).toBe(false)
expect(isTreeItemInspectable({ kind: 'paragraph', id: 'p-1', title: '内容' })).toBe(false)
})
})
-59
View File
@@ -1,59 +0,0 @@
export interface AuditTreeItem {
kind: string
id: string
title: string
state?: string
hasChildren?: boolean
order?: number
templateBlockId?: string
}
export interface AuditTreePage {
items: AuditTreeItem[]
nextCursor?: string
hasMore: boolean
truncated: boolean
}
type AuditTreePageFetcher = (parent: string, cursor: string) => Promise<AuditTreePage>
export function mergeTreePage(current: AuditTreePage, next: AuditTreePage): AuditTreePage {
return { ...next, items: [...current.items, ...next.items] }
}
async function collectParentPages(fetchPage: AuditTreePageFetcher, parent: string): Promise<AuditTreeItem[]> {
const items: AuditTreeItem[] = []
const seenCursors = new Set<string>()
let cursor = ''
for (let pageNo = 0; pageNo < 100; pageNo++) {
if (seenCursors.has(cursor)) throw new Error('树分页游标重复,已停止加载')
seenCursors.add(cursor)
const page = await fetchPage(parent, cursor)
items.push(...page.items)
if (!page.hasMore) return items
if (!page.nextCursor) throw new Error('树分页缺少后续游标')
cursor = page.nextCursor
}
throw new Error('树分页超过安全上限')
}
export async function collectRecursiveTree(fetchPage: AuditTreePageFetcher): Promise<AuditTreeItem[]> {
const roots = await collectParentPages(fetchPage, '')
const out = [...roots]
const queue = roots.filter(item => item.hasChildren === true)
const seenParents = new Set<string>()
while (queue.length > 0) {
const parent = queue.shift()!
if (seenParents.has(parent.id)) continue
seenParents.add(parent.id)
const children = await collectParentPages(fetchPage, parent.id)
out.push(...children)
if (out.length > 5000) throw new Error('树节点超过安全上限')
queue.push(...children.filter(item => item.hasChildren === true))
}
return out
}
export function isTreeItemInspectable(item: AuditTreeItem): boolean {
return item.kind === 'section' && !item.templateBlockId && !item.id.startsWith('dn-')
}
+13 -266
View File
@@ -2,37 +2,6 @@
// 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与
// api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不
// 携带任何身份字段(G1-5)。
import type { Project } from './contracts'
import { projectWorkError } from './projectmeta'
export function rdmCommandProjectId(project: Project): string {
const id = project.settings?.['rdm.projectId']
if (typeof id !== 'string' || !id.trim()) {
throw new Error('项目尚未绑定,请联系项目负责人完成项目接入')
}
return id.trim()
}
/** Historical project reads remain available; this does not grant read permission. */
export function rdmProjectReadError(project: Project | undefined): string | null {
const state = project?.settings?.['rdm.state']
if (project && (state === 'ready' || state === 'archived' || state === 'closed')) {
try { rdmCommandProjectId(project); return null }
catch (e) { return String(e).replace(/^Error:\s*/, '') }
}
return projectWorkError(project) ?? '项目尚未绑定,请联系项目负责人完成项目接入'
}
const PROJECT_READ_COMMANDS = new Set([
'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan',
'rdm.role.options',
])
export function rdmProjectCommandError(project: Project, command: string): string | null {
return PROJECT_READ_COMMANDS.has(command) ? rdmProjectReadError(project) : projectWorkError(project)
}
/** 系统管理「RDM 角色权限」分区消费的命令面。 */
export const RDM_ROLES_COMMANDS = [
@@ -56,12 +25,14 @@ export const RDM_PERM_MATRIX_COMMANDS = [
'rdm.project.member.list',
] as const
/** 项目设置「文档职责」页消费的命令面。审签命令留在文档工作区,不作为本页门控。 */
export const RDM_DUTY_COMMANDS = [
'rdm.duty.matrix',
'rdm.duty.preview',
/** 项目设置「文档职责 · 整文审签」页消费的命令面。 */
export const RDM_DOC_REVIEW_COMMANDS = [
'rdm.review.assign.doc',
'rdm.review.assign.doc.batch',
'rdm.review.doc.submit',
'rdm.review.doc.approve',
'rdm.review.doc.reject',
'rdm.review.doc.final',
'rdm.review.doc.status',
] as const
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
@@ -79,9 +50,9 @@ export const rdmRolesReady = (cmds: CommandListItem[] | null | undefined) =>
export const rdmMembersReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_MEMBER_COMMANDS)
/** 文档职责页是否就绪。命令未齐则 fail-closed。 */
export const rdmDutyReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_DUTY_COMMANDS)
/** 文档职责/整文审签页是否就绪。 */
export const rdmDocReviewReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_DOC_REVIEW_COMMANDS)
/** 项目权限矩阵页是否就绪。 */
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
@@ -102,67 +73,13 @@ export const roleActionPayload = (roleId: string, action: string, def: boolean,
export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId })
export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled })
/** userId 为候选检索选中的稳定账号;服务端按 member 精确查找后比对,不一致即拒。 */
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member', userId?: string) =>
({ projectId, member, role, ...(userId ? { userId } : {}) })
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
({ projectId, member, role })
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
/** 职责账号身份:owner → 候选检索选中的 userId。 */
export type DutyAccountIds = Record<string, string>
/** 只保留本条职责实际涉及的人;为空不携带。 */
export function dutyAccountIds(
roles: { author: string; reviewers: string[]; approver: string }, known: DutyAccountIds | undefined,
): DutyAccountIds | undefined {
if (!known) return undefined
const out: DutyAccountIds = {}
for (const owner of [roles.author, ...roles.reviewers, roles.approver]) {
if (owner && known[owner]) out[owner] = known[owner]
}
return Object.keys(out).length > 0 ? out : undefined
}
export const assignDocDutiesPayload = (
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean; accountIds?: DutyAccountIds },
) => {
const accountIds = dutyAccountIds({ author, reviewers, approver }, extra?.accountIds)
return {
projectId, docId, author, reviewers, approver,
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
...(accountIds ? { accountIds } : {}),
}
}
export const dutyMatrixPayload = (
projectId: string,
query: {
view: 'member' | 'document' | 'matrix'
memberOwner?: string
docId?: string
query?: string
stage?: string
typeKey?: string
assignment?: string
memberQuery?: string
memberCursor?: string
docCursor?: string
memberLimit?: number
docLimit?: number
},
) => ({ projectId, ...query })
export const dutyPreviewPayload = (
projectId: string,
docs: {
docId: string; author: string; reviewers: string[]; approver: string
expectedDutyVersion: number; confirmInvalidation: boolean; accountIds?: DutyAccountIds
}[],
) => ({ projectId, docs })
export const dutyLegacyPayload = (projectId: string) => ({ projectId })
) => ({ projectId, docId, author, reviewers, approver })
export const docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId })
export const docApprovePayload = (projectId: string, docId: string, note?: string) =>
@@ -176,176 +93,6 @@ export const docStatusPayload = (projectId: string, docId: string) => ({ project
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
/**
* 命令来源(基座 cmdbus.RequiresProjectWorkDecision):正式写命令必须带已提交的对话来源,
* 且位于请求体顶层(conversationId/anchorMessageId/entrance),不得放进 params。
* entrance 取服务端 CanonicalEntrance 集合;页面动作为 button。
*/
export interface CommandSource {
conversationId: string
anchorMessageId: string
entrance: 'button' | 'card' | 'slash' | 'chat' | 'api' | 'agent'
}
export const commandRequestBody = (params: Record<string, unknown>, source?: CommandSource) =>
source
? { params, conversationId: source.conversationId, anchorMessageId: source.anchorMessageId, entrance: source.entrance }
: { params }
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
export type MemberMatchMode = 'fuzzy' | 'exact'
type AccountSearchField = 'owner' | 'name'
export interface RdmProfRole { roleId: string; name: string }
/** rdm.account.list 单条候选。 */
export interface RdmAccount {
userId: string; owner: string; name?: string; valid: boolean; joined?: boolean
projectRole?: string; profRole?: RdmProfRole | null
}
export interface RdmAccountPage {
accounts?: RdmAccount[]; next?: string; directoryVersion?: string; count?: number
}
/** rdm.project.member.list 单条成员。 */
export interface RdmMemberRow {
owner: string; name?: string; role: string; addedBy: string; addedAt: string
profRole?: RdmProfRole | null
}
export interface RdmMemberPage {
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
canManage?: boolean
}
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */
export interface PersonPickItem {
id: string; owner: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string
}
export const MEMBER_PAGE_LIMIT = 50
const ACCOUNT_PAGE_LIMIT = 50
export const JOINED_REASON = '已加入项目'
export const INVALID_REASON = '账号无效'
export const NOT_MEMBER_REASON = '非当前项目成员'
export const MATCH_MODE_LABELS: Record<MemberMatchMode, string> = {
fuzzy: '模糊匹配',
exact: '精确账号或姓名',
}
export type SearchFieldsChoice = 'both' | 'owner' | 'name'
export const SEARCH_FIELDS: Record<SearchFieldsChoice, { label: string; fields: AccountSearchField[] }> = {
both: { label: '账号和姓名', fields: ['owner', 'name'] },
owner: { label: '仅账号', fields: ['owner'] },
name: { label: '仅姓名', fields: ['name'] },
}
export const roleOptionsPayload = (projectId: string) => ({ projectId, purpose: 'members' as const })
interface AccountSearchOptions {
mode?: MemberMatchMode
searchFields?: AccountSearchField[]
roleId?: string
/** 携带即按该文档的职责候选检索(仅当前有效成员)。 */
docId?: string
}
/** 只用 mode(match 是服务端旧别名,两者并存且不一致会被拒);空串筛选不携带。 */
export function accountListParams(projectId: string, opts: AccountSearchOptions & {
query?: string; cursor?: string; limit: number
}) {
const query = opts.query?.trim() ?? ''
return {
projectId,
limit: Math.min(Math.max(1, opts.limit), ACCOUNT_PAGE_LIMIT),
mode: opts.mode ?? 'fuzzy',
...(opts.searchFields && opts.searchFields.length > 0 ? { searchFields: [...opts.searchFields] } : {}),
...(query ? { query } : {}),
...(opts.cursor ? { cursor: opts.cursor } : {}),
...(opts.roleId ? { roleId: opts.roleId } : {}),
...(opts.docId ? { docId: opts.docId } : {}),
}
}
/** Picker reloadKey:项目/文档/匹配方式/检索字段/角色任一变化都清空查询与游标。 */
export function accountSearchKey(projectId: string, opts: AccountSearchOptions): string {
return JSON.stringify([projectId, opts.docId ?? '', opts.mode ?? 'fuzzy', opts.searchFields ?? [], opts.roleId ?? ''])
}
/**
* Picker 取数适配。空 query 不发请求(服务端空查询不枚举);cursor 只在签发它的
* 同一检索条件(query + 选项)下沿用,条件变化即回到首页。
*/
export function accountCandidatePager(
run: (params: ReturnType<typeof accountListParams>) => Promise<RdmAccountPage>,
projectId: string,
opts: AccountSearchOptions & { joinedOwners?: () => Iterable<string> },
) {
const base = accountSearchKey(projectId, opts)
let issuedFor = ''
return async (query: string, cursor: string, limit: number): Promise<{ items: PersonPickItem[]; nextCursor?: string }> => {
const q = query.trim()
if (!q) { issuedFor = ''; return { items: [] } }
const key = `${base}|${q}`
const out = await run(accountListParams(projectId, { ...opts, query: q, cursor: key === issuedFor ? cursor : '', limit }))
issuedFor = out.next ? key : ''
const items = accountsToPickItems(out.accounts, opts.joinedOwners?.() ?? [], opts.docId ? 'duty' : 'member')
return { items, nextCursor: out.next || undefined }
}
}
export function memberListParams(projectId: string, opts: {
query?: string; roleId?: string; cursor?: string; limit?: number
match?: MemberMatchMode; projectRole?: 'owner' | 'member'
} = {}) {
const query = opts.query?.trim() ?? ''
return {
projectId,
limit: opts.limit ?? MEMBER_PAGE_LIMIT,
...(query ? { query } : {}),
...(opts.roleId ? { roleId: opts.roleId } : {}),
...(opts.cursor ? { cursor: opts.cursor } : {}),
...(opts.match ? { match: opts.match } : {}),
...(opts.projectRole ? { projectRole: opts.projectRole } : {}),
}
}
/** 人员标签:「姓名 · 账号」,姓名为空时只显示账号。 */
export function personLabel(p: { owner: string; name?: string }): string {
const name = p.name?.trim()
return name && name !== p.owner ? `${name} · ${p.owner}` : p.owner
}
/**
* 候选映射(id=userId;无 userId 的行无法稳定提交,不提供)。无效账号可见但不可选;
* member:已加入(服务端 joined 或已在成员表)不可选;duty:只有当前成员可选。
*/
export function accountsToPickItems(
accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = [], purpose: 'member' | 'duty' = 'member',
): PersonPickItem[] {
const joined = new Set(joinedOwners)
return (accounts ?? []).filter(a => a.userId && a.owner).map(a => {
const isJoined = a.joined === true || joined.has(a.owner)
const reason = !a.valid ? INVALID_REASON
: purpose === 'member' && isJoined ? JOINED_REASON
: purpose === 'duty' && !isJoined ? NOT_MEMBER_REASON : ''
return {
id: a.userId,
owner: a.owner,
title: a.name?.trim() || a.owner,
subtitle: a.owner,
...(reason ? { disabled: true, disabledReason: reason } : {}),
}
})
}
/** 成员表追加下一页:保序,已存在的账号不重复。 */
export function appendMemberPage(prev: RdmMemberRow[] | null, page: RdmMemberRow[] | undefined): RdmMemberRow[] {
const out = [...(prev ?? [])]
const seen = new Set(out.map(m => m.owner))
for (const m of page ?? []) {
if (seen.has(m.owner)) continue
seen.add(m.owner)
out.push(m)
}
return out
}
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
export function describeSource(source: string): string {
switch (source) {
-85
View File
@@ -1,85 +0,0 @@
/* U7 法规结构化引用:编号标记、引用卡片、引用清单与审计法规树。
颜色、圆角、字号只消费 @ui/tokens.css;折叠动效由 @ui Collapsible(mk-col)承担。
有效依据(verified)用实线成功色;未核验用虚线警告色;无效用虚线错误色加删除线。 */
.regcite-chips { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.regcite-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); color: var(--text-dim); word-break: break-all; }
.regcite-mark {
display: inline;
margin: 0 2px;
padding: 0 4px;
border: 1px solid var(--border);
border-radius: var(--radius-xs);
background: none;
font: inherit;
font-size: var(--fs-ui-sm);
line-height: 1.4;
vertical-align: baseline;
cursor: pointer;
}
.regcite-mark:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.regcite-mark.regcite-valid { color: var(--ok-text); border-color: var(--ok-border); background: var(--ok-soft); }
.regcite-mark.regcite-unverified { color: var(--warn-text); border-style: dashed; border-color: var(--warn); background: var(--warn-soft); }
.regcite-mark.regcite-invalid { color: var(--err-text); border-style: dashed; border-color: var(--err); background: var(--err-soft); text-decoration: line-through; }
.regcite-mark-open { outline: 1px solid var(--accent); outline-offset: 1px; }
.regcite-block { margin: 0; }
.regcite-card {
margin: 6px 0;
padding: 8px 12px;
border: 1px solid var(--border-soft);
border-left-width: 3px;
border-radius: var(--radius);
background: var(--bg-raised);
font-size: var(--fs-ui-sm);
color: var(--text);
}
.regcite-card-valid { border-left-color: var(--ok); }
.regcite-card-unverified { border-left-color: var(--warn); border-left-style: dashed; }
.regcite-card-invalid { border-left-color: var(--err); border-left-style: dashed; }
.regcite-card-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.regcite-card-title { font-weight: 600; color: var(--text); }
.regcite-note { margin-top: 4px; color: var(--text-dim); }
.regcite-claim { margin-top: 4px; color: var(--text); }
.regcite-fields { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 2px 10px; margin: 6px 0 0; }
.regcite-fields dt { color: var(--text-dim); }
.regcite-fields dd { margin: 0; color: var(--text); word-break: break-word; }
.regcite-link { color: var(--accent-text); word-break: break-all; }
.regcite-toggle { font-size: var(--fs-ui-sm); color: var(--accent-text); }
.regcite-excerpt {
margin: 4px 0 0;
padding: 6px 10px;
border-left: 2px solid var(--border-strong);
border-radius: var(--radius-xs);
background: var(--bg-inset);
white-space: pre-wrap;
color: var(--text);
}
.regcite-redaction { margin-top: 6px; color: var(--text-dim); }
.regcite-list { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 2px; }
.regcite-list-head { font-size: var(--fs-ui-sm); color: var(--text-dim); padding-bottom: 4px; }
.regcite-item-head { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; }
.regcite-item-num { font-family: var(--mono); color: var(--text-dim); }
.regcite-item-title { font-weight: 550; }
.regcite-item-sub { color: var(--text-dim); }
.regcite-item-invalid .regcite-item-title { color: var(--err-text); text-decoration: line-through; }
.regcite-reasons { margin: 0; padding-left: 18px; color: var(--text-dim); }
.regcite-tree-row { display: flex; align-items: center; gap: 2px; }
.regcite-tree-row .rdm-audit-tree-node { flex: 1; min-width: 0; }
.regcite-tree-caret {
flex: none;
width: 22px;
height: 26px;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-dim);
font-size: var(--fs-ui-sm);
cursor: pointer;
}
.regcite-tree-caret:hover { background: var(--bg-hover); color: var(--text); }
-235
View File
@@ -1,235 +0,0 @@
// U7 法规结构化引用纯逻辑层(契约:Dev 仓 docs/superpowers/specs
// 2026-10-18-regulations-citation-contract.md §1/§3/§5/§6)。只放可测判定:
// 正文 [cit-…] 标记解析与编号、核验状态文案与样式、原文省略原因、审计树请求参数。
// 引用是 RDM 证据批的派生视图;除 verified 外一律不得呈现为有效法律依据。
// ---- 契约类型 ----
interface CitationWeb {
url: string
finalUrl?: string
captureId?: string
contentSha256?: string
official?: boolean
fetchedAt?: string
excerpt?: string
excerptStart?: number
excerptEnd?: number
}
export interface CitationView {
id: string
batchId?: string
claim?: string
state: string // verified|single_source|degraded|edition_unresolved|conflict|unverified|not_applicable|invalid
stale?: boolean
staleReason?: string
regulationId?: string
editionId?: string
nodeId?: string
docNo?: string
title?: string
editionLabel?: string
legalStatus?: string
clauseNo?: string
headingPath?: string[]
corpusRevision?: string
excerpt?: string
excerptStart?: number
excerptEnd?: number
nodeSha256?: string
disclosure?: string // full|excerpt|metadata_only|withheld
redactionReason?: string
web?: CitationWeb
manifestSha256?: string
}
interface RegLegStates { kb: string; web: string; kbReason?: string; webReason?: string }
interface RegSealView { state: string; keyId?: string; manifestSha256?: string }
interface RegResolution { actor: string; decision: string; note?: string; at: string; batchManifestSha256?: string }
/** rdm.inspect.object kind=reg-batch(契约 §5)。 */
export interface RegBatchDetail {
title: string
state: string
stateReasons?: string[] | null
legStates?: RegLegStates
seal?: RegSealView
citations?: CitationView[] | null
resolutions?: RegResolution[] | null
}
/** rdm.inspect.object kind=reg-citation(契约 §5)。 */
export interface RegCitationDetail { title: string; citation: CitationView }
// ---- 正文标记:[cit-<12 位小写十六进制>] ----
const MARKER = /\[(cit-[0-9a-f]{12})\]/g
type CitedSegment =
| { kind: 'text'; text: string }
| { kind: 'cite'; id: string; n: number }
/** 解析多段正文:按首次出现编号(跨段连续),重复标记复用编号;不合规的近似标记原样保留为文本。 */
export function parseCitedBlocks(texts: string[]): { blocks: CitedSegment[][]; order: string[] } {
const order: string[] = []
const numbers = new Map<string, number>()
const blocks = texts.map(text => {
const out: CitedSegment[] = []
let last = 0
for (const m of text.matchAll(MARKER)) {
const at = m.index ?? 0
if (at > last) out.push({ kind: 'text', text: text.slice(last, at) })
const id = m[1]
let n = numbers.get(id)
if (n === undefined) {
order.push(id)
n = order.length
numbers.set(id, n)
}
out.push({ kind: 'cite', id, n })
last = at + m[0].length
}
if (last < text.length) out.push({ kind: 'text', text: text.slice(last) })
return out
})
return { blocks, order }
}
/** 引用清单顺序:正文编号在前,正文未出现的引用按返回顺序续编。 */
export function citationListOrder(order: string[], citations: CitationView[] | null | undefined): string[] {
const out = [...order]
const seen = new Set(order)
for (const c of citations || []) {
if (!seen.has(c.id)) { seen.add(c.id); out.push(c.id) }
}
return out
}
// ---- 核验状态 ----
const STATE_LABELS: Record<string, string> = {
verified: '已核验',
single_source: '单一来源',
degraded: '降级',
edition_unresolved: '版本未定',
conflict: '冲突',
unverified: '未核验',
not_applicable: '不适用',
invalid: '无效引用',
}
/** 状态文案;未知状态原样显示,不冒充已知状态。 */
export function citationStateLabel(state: string | undefined): string {
if (!state) return STATE_LABELS.unverified
return STATE_LABELS[state] ?? state
}
type CitationStyle = 'valid' | 'unverified' | 'invalid'
/** 样式档:仅 verified 且未过期才是有效依据;ID 缺失或 invalid 为无效;其余一律未核验档。 */
export function citationStyle(c: { state?: string; stale?: boolean } | undefined): CitationStyle {
if (!c || c.state === 'invalid') return 'invalid'
if (c.state === 'verified' && !c.stale) return 'valid'
return 'unverified'
}
export const CITATION_CHIP_TONE: Record<CitationStyle, 'ok' | 'warn' | 'danger'> = {
valid: 'ok',
unverified: 'warn',
invalid: 'danger',
}
const STALE_REASONS: Record<string, string> = {
node_text_changed: '条款文本已变更',
legal_status_changed: '效力状态已变更',
edition_superseded: '版本已被替代',
}
/** 过期提示:stale 显示「待复核」并附原因;未过期返回 null。 */
export function staleLabel(c: { stale?: boolean; staleReason?: string }): string | null {
if (!c.stale) return null
if (!c.staleReason) return '待复核'
return `待复核(${STALE_REASONS[c.staleReason] ?? c.staleReason})`
}
/** 标记文字:有效依据只显示编号;其余附状态文案,不靠颜色单独区分。 */
export function citationMarkText(n: number, c: CitationView | undefined): string {
const style = citationStyle(c)
if (style === 'valid') return `${n}`
if (style === 'invalid') return `${n}·${STATE_LABELS.invalid}`
return `${n}·${c?.stale && c.state === 'verified' ? '待复核' : citationStateLabel(c?.state)}`
}
// ---- 原文披露 ----
const REDACTION_LABELS: Record<string, string> = {
withheld_by_rights: '受版权限制',
metadata_only: '仅元数据',
no_source_text_permission: '无原文查看权限',
source_withdrawn: '来源已撤回',
}
/** 原文省略原因;未知原因原样显示。 */
export function redactionLabel(reason: string | undefined): string {
if (!reason) return '未提供原文'
return REDACTION_LABELS[reason] ?? reason
}
/** 原文片段呈现:有片段给片段;省略时给中文原因,不留空白。 */
export function excerptView(c: Pick<CitationView, 'excerpt' | 'redactionReason'>): { excerpt: string } | { redaction: string } {
if (c.excerpt) return { excerpt: c.excerpt }
return { redaction: redactionLabel(c.redactionReason) }
}
const DISCLOSURE_LABELS: Record<string, string> = {
full: '全文',
excerpt: '片段',
metadata_only: '仅元数据',
withheld: '已扣留',
}
export function disclosureLabel(d: string | undefined): string {
if (!d) return '—'
return DISCLOSURE_LABELS[d] ?? d
}
const LEG_LABELS: Record<string, string> = {
ok: '正常',
empty: '无结果',
unavailable: '不可用',
degraded: '降级',
error: '出错',
rejected: '已拒绝',
}
export function legStateLabel(state: string | undefined, reason?: string): string {
const label = state ? (LEG_LABELS[state] ?? state) : '—'
return reason ? `${label}(${reason})` : label
}
export function sealStateLabel(state: string | undefined): string {
if (state === 'sealed') return '已封存'
if (state === 'unsealed') return '未封存'
return state || '—'
}
/** 只有 http(s) 地址才可作为外链;其余地址按文本显示。 */
export function safeWebHref(url: string | undefined): string | null {
if (!url) return null
return /^https?:\/\//i.test(url) ? url : null
}
// ---- 审计读模型请求(rdm.inspect.tree kind=regs;契约 §5) ----
/** 根层列证据批;parent=<batchId> 列该批的引用。cursor 沿用既有键集分页。 */
export function regsTreeParams(projectId: string, opts: { parent?: string; cursor?: string } = {}): Record<string, unknown> {
const params: Record<string, unknown> = { projectId, kind: 'regs', cursor: opts.cursor ?? '' }
if (opts.parent) params.parent = opts.parent
return params
}
export const REG_TREE_KIND_LABELS: Record<string, string> = {
'reg-batch': '证据批',
'reg-citation': '引用',
}
-70
View File
@@ -1,70 +0,0 @@
export type RolePermissionValue = { allowed: boolean; capAllowed: boolean }
type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean }
export interface RolePermissionDraft {
baseline: Record<string, RolePermissionValue>
values: Record<string, RolePermissionValue>
generation: number
}
function copy(values: Record<string, RolePermissionValue>): Record<string, RolePermissionValue> {
return Object.fromEntries(Object.entries(values).map(([actionId, value]) => [actionId, { ...value }]))
}
export function createRolePermissionDraft(values: Record<string, RolePermissionValue>, generation = 0): RolePermissionDraft {
return { baseline: copy(values), values: copy(values), generation }
}
export function updateRolePermission(
draft: RolePermissionDraft,
actionId: string,
value: RolePermissionValue,
): RolePermissionDraft {
return { ...draft, values: { ...draft.values, [actionId]: { ...value } }, generation: draft.generation + 1 }
}
export function mergeRoleDraft(
current: RolePermissionDraft,
serverValues: Record<string, RolePermissionValue>,
responseGeneration: number,
): RolePermissionDraft {
if (responseGeneration < current.generation) return current
return createRolePermissionDraft(serverValues, responseGeneration)
}
// Rebase local edits on a newer server baseline after an optimistic conflict.
// Only fields changed from the old baseline are replayed.
export function rebaseRoleDraft(
current: RolePermissionDraft,
serverValues: Record<string, RolePermissionValue>,
): RolePermissionDraft {
const values = copy(serverValues)
for (const [actionId, local] of Object.entries(current.values)) {
const old = current.baseline[actionId]
if (!old || old.allowed !== local.allowed || old.capAllowed !== local.capAllowed) values[actionId] = { ...local }
}
return { baseline: copy(serverValues), values, generation: current.generation + 1 }
}
export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: string[]): RolePermissionChange[] {
const seen = new Set<string>()
const changes: RolePermissionChange[] = []
for (const actionId of visibleActionIds) {
if (seen.has(actionId)) continue
seen.add(actionId)
const value = draft.values[actionId]
if (!value) continue
const baseline = draft.baseline[actionId]
if (baseline && baseline.allowed === value.allowed && baseline.capAllowed === value.capAllowed) continue
changes.push({ actionId, ...value })
}
return changes
}
export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null {
for (const actionId of Object.keys(draft.values).sort()) {
const value = draft.values[actionId]
if (value.allowed && !value.capAllowed) return { actionId, reason: 'default-exceeds-cap' }
}
return null
}
-17
View File
@@ -1,17 +0,0 @@
import { api } from './api'
import type { StreamEvent } from './contracts'
/** Read the durable record in bounded pages; callers own cancellation and projection. */
export async function loadRunEvents(conversationId: string, runId: string, signal: AbortSignal): Promise<StreamEvent[]> {
const events: StreamEvent[] = []
let after = 0
while (true) {
const page = await api.runEventsPage(conversationId, runId, after, signal)
const rows = page.events ?? []
events.push(...rows)
if (rows.length < 500) return events
const next = rows[rows.length - 1].seq
if (next <= after) throw new Error('process event cursor did not advance')
after = next
}
}
+21 -1
View File
@@ -1,4 +1,24 @@
// Explicit message/card location feedback. The middle and work panes scroll independently.
// 中栏 → 右栏单向滚动同步,以及互定位后的 1.2s 闪烁。
// 比例按可滚动范围计算;任一侧没有溢出时不同步,避免把静止栏拽动。
export interface ScrollBox {
scrollTop: number
scrollHeight: number
clientHeight: number
}
export function scrollRatio(box: ScrollBox): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
return box.scrollTop / range
}
export function scrollTopForRatio(box: Pick<ScrollBox, 'scrollHeight' | 'clientHeight'>, ratio: number): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
const clamped = Math.min(1, Math.max(0, ratio))
return clamped * range
}
/** 给已定位的节点加上 mk-flash,1.2s 后摘掉。 */
export function flashElement(el: Element | null) {
+77 -316
View File
@@ -2,18 +2,15 @@
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { type AdminZoneSection, api, connectEvents } from './api'
import { auth } from './auth'
import { projectActionError, projectWorkError } from './projectmeta'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
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 { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement'
import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation'
import { rdmAuditReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement'
type FontSizeChoice = 13 | 14 | 16 | 18
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
@@ -23,7 +20,6 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
}
import type {
ActivityCard,
TaskSourceRef,
AgentProfile,
Artifact,
Conversation,
@@ -36,8 +32,7 @@ import type {
StreamEvent,
SurfaceMeta,
SurfaceSnapshot,
TodoList,
WorkCardAction
TodoList
} from './contracts'
export interface ContextObs {
@@ -91,7 +86,7 @@ export interface UIState {
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; mustChangePassword?: boolean }
interface AppState {
contextObs: Record<string, ContextObs>
@@ -116,8 +111,6 @@ interface AppState {
projectCards: ActivityCard[]
/** 同一操作的双入口反馈;未知结果必须先对账,不重新发送副作用。 */
cardActionStates: Record<string, 'submitting' | 'unknown'>
workRecord: { cardId: string; runId: string; conversationId: string; title: string } | null
workReference: WorkReference | null
inboxScope: 'conv' | 'project'
/** runtime.mock:只控制演示指令提示,不改变对话能力。 */
runtimeMock: boolean
@@ -126,16 +119,11 @@ interface AppState {
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
rdmAuditAvailable: boolean
rdmCockpitAvailable: boolean
/** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */
rdmExportAvailable: boolean
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
rdmRolesAvailable: boolean
rdmMembersAvailable: boolean
rdmDocReviewAvailable: boolean
rdmPermMatrixAvailable: boolean
/** 统一渲染(Task 11):rdm.render.* 核心命令齐备才 ready;审阅/批注/导出各自门控。 */
rdmRender: RenderCapabilities
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
adminSections: AdminZoneSection[]
ui: UIState
@@ -201,13 +189,10 @@ let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
rdmAuditAvailable: false,
rdmCockpitAvailable: false,
rdmExportAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
rdmRender: NO_RENDER_CAPABILITIES,
adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
outbox: [], outboxDraining: false,
@@ -233,8 +218,7 @@ function syncFilesEntry(hasTabs: boolean) {
function setUI(patch: Partial<UIState>) {
const ui = { ...state.ui, ...patch }
const navigation=(['rightTab','filesView','rightCollapsed','activeWorkspace'] as const).some(key=>key in patch&&patch[key]!==state.ui[key])
state = { ...state, ui,previewOpenIntent:state.previewOpenIntent+(navigation?1:0) }
state = { ...state, ui }
try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
applyTheme(ui.theme)
applyFontSize(ui.fontSize)
@@ -295,14 +279,7 @@ let sse: { close: () => void } | null = null
let projectSse: { close: () => void } | null = null
let projectSeq = 0
let conversationEpoch = 0
let identityEpoch = 0
let projectsRequest = 0
let tasksRequest = 0
let meRequest = 0
let streamSeq = 0
let streamHasWatermark = false
let gapReconnectPending = false
let streamPending = new Map<number, StreamEvent>()
let detailRequest = 0
let detailSeq = 0
let detailVersion = 0
@@ -320,7 +297,6 @@ let cardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
let projectCardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
const actionInflight = new Set<string>()
const actionFeedbackOwners: Record<string, symbol> = {}
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
const version = ++detailVersion
@@ -358,24 +334,12 @@ function scheduleTerminalReconcile() {
// Snapshot queries may overlap events. Only rows changed after the request began
// override that snapshot; a newer request/context invalidates the old query entirely.
export function mergeTaskSnapshot<T extends {taskId:string;attemptId:string;revision:number}>(current:T,incoming:T):T {
if(current.taskId!==incoming.taskId||current.attemptId!==incoming.attemptId||!Number.isSafeInteger(incoming.revision)||incoming.revision<=current.revision)return current
return incoming
}
function mergeTaskCard(current:ActivityCard|undefined,incoming:ActivityCard):ActivityCard {
if(!current?.task||!incoming.task||current.task.schemaVersion!==1||incoming.task.schemaVersion!==1)return incoming
const a=current.task,b=incoming.task
if(a.taskId!==b.taskId||a.attemptId!==b.attemptId||a.projectId!==b.projectId||a.conversationId!==b.conversationId||a.ownerInstanceId!==b.ownerInstanceId||(a.executionEpoch??0)>(b.executionEpoch??0)||a.revision>b.revision)return current
if(['completed','failed','interrupted'].includes(a.executionState)&&!['completed','failed','interrupted'].includes(b.executionState))return current
if(a.revision!==b.revision&&mergeTaskSnapshot(a,b)===a)return current
return incoming
}
function mergeCardSnapshot(snapshot: ActivityCard[], current: ActivityCard[], versions: Record<string, number>, started: number,
patches: Record<string, Pick<ActivityCard, 'status' | 'tone'>>) {
const fresh = current.filter(c => (versions[c.id] ?? 0) > started)
const freshIds = new Set(fresh.map(c => c.id))
const combined=fresh.map(c=>{const row=snapshot.find(r=>r.id===c.id);return row?.task&&c.task&&row.task.revision>c.task.revision?mergeTaskCard(c,row):c})
return [...combined, ...snapshot.filter(c => !freshIds.has(c.id)).map(row => {const c=mergeTaskCard(current.find(old=>old.id===row.id),row);return (versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c})]
return [...fresh, ...snapshot.filter(c => !freshIds.has(c.id)).map(c =>
(versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c)]
}
function hitlSource(card: ActivityCard): string | undefined {
@@ -393,7 +357,7 @@ function actionFeedback(key: string, value?: 'submitting' | 'unknown', ids: stri
if (value ? current[id] === value : !(id in current)) continue
states ??= { ...state.cardActionStates }
if (value) states[id] = value
else { delete states[id]; delete actionFeedbackOwners[id]; workActionExpectations.delete(id) }
else { delete states[id]; delete actionFeedbackOwners[id] }
}
if (states) set({ cardActionStates: states })
}
@@ -408,22 +372,13 @@ function startActionFeedback(key: string, ids: string[] = []) {
function settleCardFeedback(cards: ActivityCard[]) {
let states: AppState['cardActionStates'] | undefined
for (const card of cards) {
const expected = workActionExpectations.get(card.id)
if (expected) {
const decision = card.work?.decision?.state
const settled = expected.actionId === 'work.read' ? (card.work?.readVersion ?? 0) >= expected.payload.resultVersion
: expected.actionId === 'work.defer' ? decision === 'deferred'
: expected.actionId === 'work.reopen' ? decision === 'open' : decision === 'submitted'
if (!settled) continue
}
if (!expected && card.status === 'action_required') continue
if (card.status === 'action_required') continue
const source = hitlSource(card)
for (const id of [card.id, ...(source ? [`hitl:${source}`] : [])]) {
if (!(id in (states ?? state.cardActionStates))) continue
states ??= { ...state.cardActionStates }
delete states[id]
delete actionFeedbackOwners[id]
workActionExpectations.delete(id)
}
}
if (states) set({ cardActionStates: states })
@@ -443,59 +398,24 @@ export function useStore<T>(selector: (s: AppState) => T): T {
export const store = {
getState: () => state,
getIdentityEpoch: () => identityEpoch,
resetIdentity() {
identityEpoch++; projectsRequest++; tasksRequest++; meRequest++
sse?.close(); sse = null
projectSse?.close(); projectSse = null
conversationEpoch++; detailRequest++; cardsRequest++; projectCardsRequest++
projectSeq = 0; streamSeq = 0; detailSeq = 0; detailVersion = 0
cardVersion = 0; projectCardVersion = 0
cardVersions = {}; projectCardVersions = {}; cardPatches = {}; projectCardPatches = {}
runObservations = {}; messageObservations = {}; hitlObservations = {}
actionInflight.clear()
workActionExpectations.clear()
for (const key of Object.keys(actionFeedbackOwners)) delete actionFeedbackOwners[key]
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
convRestoreDone = true
persistConvId(null)
set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES,
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
settingsOpen: false, changePwOpen: false, trackerRunId: null, workspaceSurfacePin: null,
previewExpanded: false, filesView: 'list', showArchived: false })
},
async refreshProjects() {
const epoch = identityEpoch
const request = ++projectsRequest
try {
const projects = await api.listProjects()
if (epoch !== identityEpoch || request !== projectsRequest) return
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
set({ projects, currentProjectId: current?.id ?? null, error: null })
if (current) await store.openProject(current.id)
} catch (e) {
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
set({ error: String(e) })
}
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
store.refreshTasks()
store.refreshMe()
},
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
async refreshTasks(includeArchived = state.ui.showArchived) {
const epoch = identityEpoch
const request = ++tasksRequest
try {
const tasks = await api.listTasks(includeArchived)
if (epoch !== identityEpoch || request !== tasksRequest) return
set({ tasks })
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
if (!convRestoreDone) {
@@ -509,35 +429,14 @@ export const store = {
},
async refreshMe() {
const epoch = identityEpoch
const request = ++meRequest
try {
const me = await api.me()
if (epoch !== identityEpoch || request !== meRequest) return
const changed = state.me !== null && state.me.owner !== me.owner
if (changed) store.resetIdentity()
set({ me })
if (changed) {
await store.refreshProjects()
void store.refreshProfiles()
void store.refreshPlugins()
}
} catch { /* 身份不可用时侧栏只显示通用入口 */ }
try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ }
},
async createProject(name: string) {
const epoch = identityEpoch
try {
const p = await api.createProject(name)
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
await store.refreshProjects()
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
store.toast(`项目「${p.name}」已创建`)
return p
} catch (e) {
if (epoch === identityEpoch) store.toast(`项目创建失败:${projectActionError(e)}`)
throw e
}
const p = await api.createProject(name)
await store.refreshProjects()
store.toast(`项目「${p.name}」已创建`)
return p
},
async openProject(projectId: string) {
@@ -547,10 +446,10 @@ export const store = {
projectSeq = 0
projectCardsRequest++
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], plan: null, todos: null, surfaces: [], projectSurfaces: [],
artifacts: [], outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false, filesView: 'list' })
}
@@ -566,43 +465,28 @@ export const store = {
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
* 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */
async createTask(projectId?: string) {
const epoch = identityEpoch
try {
let pid = projectId ?? state.currentProjectId
if (!pid && state.projects.length > 0) pid = state.projects[0].id
if (!pid) {
const p = await store.createProject('默认项目')
pid = p.id
}
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
const error = projectWorkError(state.projects.find(p => p.id === pid))
if (error) throw new Error(error)
if (pid !== state.currentProjectId) await store.openProject(pid)
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
const c = await api.createConversation(pid)
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
await store.refreshTasks()
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
await store.openTask(c.id)
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
} catch (e) {
if (epoch === identityEpoch) store.toast(`对话创建失败:${projectActionError(e)}`)
throw e
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 !== state.currentProjectId) await store.openProject(pid)
const c = await api.createConversation(pid)
await store.refreshTasks()
await store.openTask(c.id)
},
/** 兼容旧入口(⌘/命令总线) */
async createConversation(title?: string) {
await store.createTask()
if (title) store.toast('已在当前项目创建任务')
await store.createTask()
},
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
async openTask(convId: string) {
const epoch = identityEpoch
const t = state.tasks.find(x => x.id === convId)
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
if (epoch !== identityEpoch) return
await store.openConversation(convId)
},
@@ -638,9 +522,9 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
const epoch = ++conversationEpoch
streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], workRecord: null, workReference: null, surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false })
persistConvId(convId)
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
@@ -736,21 +620,15 @@ export const store = {
streamSeq = Math.max(streamSeq, since ?? state.lastSeq)
sse?.close()
sse = connectEvents(convId, since ?? state.lastSeq, {
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) {set({ connected: true });void store.refreshCards(convId)} },
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: true }) },
onError: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: false }) },
onEvent: e => { if (state.currentConvId === convId && conversationEpoch === epoch) store.applyEvent(e) },
})
},
applyEvent(e: StreamEvent) {
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isSafeInteger(e.seq) || e.seq <= streamSeq) return
if(streamHasWatermark&&e.seq>streamSeq+1){
if(streamPending.size<1000)streamPending.set(e.seq,e)
if(!gapReconnectPending){gapReconnectPending=true;const conv=state.currentConvId,epoch=conversationEpoch;queueMicrotask(()=>{gapReconnectPending=false;if(conv===state.currentConvId&&epoch===conversationEpoch)store.connectLive(streamSeq)})}
return
}
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isFinite(e.seq) || e.seq <= streamSeq) return
streamSeq = e.seq
if(streamPending.has(e.seq+1)){const pending=streamPending.get(e.seq+1)!;streamPending.delete(e.seq+1);queueMicrotask(()=>store.applyEvent(pending))}
const previousSeq = state.lastSeq
// Running-turn replay reconstructs transient output, but cannot regress the
// persistent card/plan/status snapshot already returned by the detail query.
@@ -759,9 +637,6 @@ export const store = {
if (e.runId && liveEventTypes.has(e.type)) observeDetail(runObservations, e.runId, e.seq)
const now = eventTime(e)
const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now)
const belongsToLive = (agentRun: string) => !!state.live && (e.runId === state.live.runId
|| Object.prototype.hasOwnProperty.call(state.live.agentNotes, agentRun)
|| state.runs.some(run => run.id === agentRun && run.parentRunId === state.live?.runId))
switch (e.type) {
case 'message.delta': {
const p = e.payload as { index: number; text: string }
@@ -823,17 +698,17 @@ export const store = {
}
case 'agent.spawn': {
const p = e.payload as { agentRun: string; name: string; task: string }
if (state.live && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
break
}
case 'agent.progress': {
const p = e.payload as { agentRun: string; note?: string }
if (state.live && p.note && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
if (state.live && p.note) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
break
}
case 'agent.result': {
const p = e.payload as { agentRun: string; summary?: string }
if (state.live && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.summary ?? '任务已结束' } } })
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.summary ?? '任务已结束' } } })
store.reconcile()
break
}
@@ -901,7 +776,6 @@ export const store = {
}
case 'artifact.created':
case 'artifact.updated': {
if(e.type==='artifact.updated'&&state.currentConvId)void store.refreshCards(state.currentConvId)
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
if (p?.artifact) {
const ref = { artifactId: p.artifact.id, name: p.artifact.name, kind: p.artifact.kind }
@@ -935,7 +809,7 @@ export const store = {
}
},
async sendMessage(text: string, attachmentIds: string[], requestId?: string, taskRef?: WorkReference): Promise<'ok' | 'busy' | 'rejected'> {
async sendMessage(text: string, attachmentIds: string[], requestId?: string): Promise<'ok' | 'busy'> {
const convId = state.currentConvId
const epoch = conversationEpoch
const sameContext = () => state.currentConvId === convId && conversationEpoch === epoch
@@ -943,7 +817,7 @@ export const store = {
const id = requestId ?? newQueuedMessage(text, attachmentIds, []).id
set({ posting: true })
try {
const out = await (taskRef ? api.sendMessage(convId, text, attachmentIds, id, taskRef) : api.sendMessage(convId, text, attachmentIds, id))
const out = await api.sendMessage(convId, text, attachmentIds, id)
if (!sameContext()) return 'ok'
if (out.command) {
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
@@ -969,8 +843,8 @@ export const store = {
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
const live = !state.live || state.live.runId !== started.id
? { runId: started.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: eventTime({ ts: started.createdAt }) }
: { ...state.live, startedAt: eventTime({ ts: started.createdAt }, state.live.startedAt) }
? { runId: started.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
: state.live
set({ live, runs, messages })
store.connectIfNeeded()
}
@@ -978,22 +852,19 @@ export const store = {
} catch (e) {
if (!sameContext()) return 'ok'
if (isConversationBusy(e)) {
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [], ...(taskRef ? { taskRef } : {}) }) })
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [] }) })
return 'busy'
}
store.toast(`发送失败:${e}`)
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds,
attachmentNames: attachmentIds.map(attachmentId => state.artifacts.find(artifact => artifact.id === attachmentId)?.name ?? '已添加材料'),
...(taskRef ? { taskRef } : {}), deliveryError: '发送未成功,内容已保留;可修改后重试或移除' }) })
await store.reconcile()
return 'rejected'
return 'ok'
} finally {
if (sameContext()) set({ posting: false })
}
},
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference) {
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames, taskRef)) })
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[]) {
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames)) })
},
updateOutbox(id: string, text: string) {
@@ -1004,10 +875,6 @@ export const store = {
set({ outbox: removeQueuedMessage(state.outbox, id) })
},
clearOutboxReference(id: string) {
set({ outbox: state.outbox.map(item => item.id === id ? { ...item, taskRef: undefined } : item) })
},
async sendOutboxNow(id: string) {
const item = state.outbox.find(q => q.id === id)
if (!item) return
@@ -1018,23 +885,23 @@ export const store = {
return
}
set({ outbox: rest })
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item), item.taskRef)
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item))
},
async flushOutbox() {
if (state.outboxDraining) return
if (!canFlushOutbox(shellPhase())) return
const next = state.outbox.find(item => !item.deliveryError)
const next = state.outbox[0]
if (!next) return
set({ outbox: removeQueuedMessage(state.outbox, next.id), outboxDraining: true })
let result: 'ok' | 'busy' | 'rejected' = 'ok'
set({ outbox: state.outbox.slice(1), outboxDraining: true })
let result: 'ok' | 'busy' = 'ok'
try {
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next), next.taskRef)
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next))
} finally {
set({ outboxDraining: false })
}
if (result === 'busy') return
if (canFlushOutbox(shellPhase()) && state.outbox.some(item => !item.deliveryError)) {
if (canFlushOutbox(shellPhase()) && state.outbox.length > 0) {
await store.flushOutbox()
}
},
@@ -1098,7 +965,6 @@ export const store = {
if (card.conversationId && card.conversationId !== state.currentConvId) return
cardVersions[card.id] = ++cardVersion
delete cardPatches[card.id]
card=mergeTaskCard(state.cards.find(c=>c.id===card.id),card)
const i = state.cards.findIndex(c => c.id === card.id)
const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards]
set({ cards })
@@ -1110,10 +976,8 @@ export const store = {
const request = ++cardsRequest
const started = cardVersion
try {
const out = await api.listCardsSnapshot(convId)
const snapshot=out.cards??[]
const snapshot = await api.listCards(convId)
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== cardsRequest) return
if(Number.isSafeInteger(out.replaySeq)&&out.replaySeq!>=0)streamHasWatermark=true
const cards = mergeCardSnapshot(snapshot, state.cards, cardVersions, started, cardPatches)
set({ cards })
settleCardFeedback(cards)
@@ -1122,8 +986,7 @@ export const store = {
// 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项);
// 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。
async cardAction(card: ActivityCard, actionId: string, payload?: string | WorkCardAction) {
const freeText = typeof payload === 'object' ? payload.freeText : payload
async cardAction(card: ActivityCard, actionId: string, freeText?: string) {
const source = hitlSource(card)
if (source) {
await store.resolveHITL(source, actionId ? { choiceId: actionId } : { freeText })
@@ -1133,14 +996,12 @@ export const store = {
const convId = state.currentConvId
const pid = state.currentProjectId
const epoch = conversationEpoch
const identity = identityEpoch
const startedVersion = card.conversationId === state.currentConvId ? cardVersion : projectCardVersion
const sameContext = () => identity === identityEpoch && state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
const sameContext = () => state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
actionInflight.add(card.id)
const feedbackOwner = startActionFeedback(card.id)
if (actionId.startsWith('work.') && typeof payload === 'object') workActionExpectations.set(card.id, { actionId, payload })
try {
const out = await api.cardAction(card.id, actionId, payload)
const out = await api.cardAction(card.id, actionId, freeText)
if (!sameContext()) return
const newer = (card.conversationId === state.currentConvId ? cardVersions[card.id] : projectCardVersions[card.id]) > startedVersion
if (!newer && card.conversationId === state.currentConvId) store.upsertCard(out.card)
@@ -1149,7 +1010,7 @@ export const store = {
const cards = state.projectCards
set({ projectCards: cards.some(c => c.id === out.card.id) ? cards.map(c => c.id === out.card.id ? out.card : c) : [out.card, ...cards] })
}
if (!actionId.startsWith('work.') && out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
if (out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
observeDetail(messageObservations, out.receipt.id)
set({ messages: [...state.messages, out.receipt as Message] })
}
@@ -1160,7 +1021,7 @@ export const store = {
}
if (sameContext()) {
const current = [...state.cards, ...state.projectCards].find(c => c.id === card.id)
actionFeedback(card.id, !actionId.startsWith('work.') && actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
actionFeedback(card.id, actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
}
} catch (e) {
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
@@ -1174,83 +1035,57 @@ export const store = {
else await store.loadProjectCards()
}
} finally {
if (identity === identityEpoch) actionInflight.delete(card.id)
actionInflight.delete(card.id)
}
},
async cardPin(card: ActivityCard, pinned: boolean) {
const epoch = identityEpoch
try {
const out = await api.cardPin(card.id, pinned)
if (epoch === identityEpoch) store.upsertCard(out.card)
} catch (e) { if (epoch === identityEpoch) store.toast(`置顶失败:${e}`) }
try { store.upsertCard((await api.cardPin(card.id, pinned)).card) } catch (e) { store.toast(`置顶失败:${e}`) }
},
async cardDismiss(card: ActivityCard) {
const epoch = identityEpoch
try {
const out = await api.cardDismiss(card.id)
if (epoch === identityEpoch) store.upsertCard(out.card)
} catch (e) { if (epoch === identityEpoch) store.toast(`关闭失败:${e}`) }
try { store.upsertCard((await api.cardDismiss(card.id)).card) } catch (e) { store.toast(`关闭失败:${e}`) }
},
async refreshArtifacts(projectId: string) {
const epoch = identityEpoch
try {
const artifacts = await api.listArtifacts(projectId)
if (epoch !== identityEpoch || state.currentProjectId !== projectId) return
if (state.currentProjectId !== projectId) return
set({ artifacts })
} catch { /* 项目删除竞态等,静默 */ }
},
async refreshProfiles() {
const epoch = identityEpoch
try {
const profiles = await api.listProfiles()
if (epoch === identityEpoch) set({ profiles })
} catch { /* ignore */ }
try { set({ profiles: await api.listProfiles() }) } catch { /* ignore */ }
},
async refreshPlugins() {
const epoch = identityEpoch
try {
const list = await api.listPlugins() as { panels?: PluginPanel[] }[]
if (epoch !== identityEpoch) return
const panels = list.flatMap(p => p.panels ?? [])
set({ pluginPanels: panels })
} catch { /* ignore */ }
try {
const ws = await api.listWorkspaces()
if (epoch !== identityEpoch) return
set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ }
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
try {
const cmds = await api.listCommands()
if (epoch !== identityEpoch) return
set({
rdmAuditAvailable: rdmAuditReady(cmds),
rdmCockpitAvailable: cockpitReady(cmds),
rdmExportAvailable: rdmExportReady(cmds),
rdmRolesAvailable: rdmRolesReady(cmds),
rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDutyReady(cmds),
rdmDocReviewAvailable: rdmDocReviewReady(cmds),
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
rdmRender: renderCapabilities(cmds),
})
} catch {
if (epoch !== identityEpoch) return
set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES })
}
} catch { set({ rdmAuditAvailable: false }) }
try {
const sections = await api.adminZoneSections()
if (epoch !== identityEpoch) return
set({ adminSections: sections ?? [] })
} catch { /* ignore */ }
try {
const settings = await api.getSettings()
if (epoch !== identityEpoch) return
const runtime = settings.user.runtime
const mock = !!runtime && typeof runtime === 'object' && (runtime as { mock?: unknown }).mock === true
set({ runtimeMock: mock })
@@ -1259,16 +1094,15 @@ export const store = {
async uploadAttachment(file: File): Promise<string | null> {
const pid = state.currentProjectId
const epoch = identityEpoch
if (!pid) return null
try {
const up = await api.upload(pid, file)
if (epoch !== identityEpoch || state.currentProjectId !== pid) return null
if (state.currentProjectId !== pid) return null
store.toast(`已收到材料:${up.filename}${up.parsed ? ' · 已读取内容' : ''}`)
await store.refreshArtifacts(pid)
return epoch === identityEpoch && state.currentProjectId === pid ? up.artifactId : null
return up.artifactId
} catch (e) {
if (epoch === identityEpoch && state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
if (state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
return null
}
},
@@ -1306,17 +1140,15 @@ export const store = {
connectProject() {
const pid = store.getState().currentProjectId
const epoch = identityEpoch
if (!pid) return
projectSse?.close()
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (epoch === identityEpoch && state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
},
applyProjectEvent(e: StreamEvent) {
if (!Number.isFinite(e.seq) || e.seq <= projectSeq) return
projectSeq = e.seq
const upsert = (incoming: ActivityCard) => {
const card=mergeTaskCard(state.projectCards.find(c=>c.id===incoming.id),incoming)
const upsert = (card: ActivityCard) => {
if (card.projectId !== state.currentProjectId) return
projectCardVersions[card.id] = ++projectCardVersion
const list = store.getState().projectCards
@@ -1351,10 +1183,8 @@ export const store = {
},
async cardToProject(card: ActivityCard) {
const epoch = identityEpoch
try {
const out = await api.cardToProject(card.id)
if (epoch !== identityEpoch) return
store.upsertCard(out.origin) // 会话视图:原卡 dismissed 留痕
const list = store.getState().projectCards
const i = list.findIndex(c => c.id === out.card.id)
@@ -1362,23 +1192,17 @@ export const store = {
store.toast('已留档到项目收件箱')
store.setInboxScope('project')
} catch (e) {
if (epoch === identityEpoch) store.toast(`留档失败:${e}`)
store.toast(`留档失败:${e}`)
}
},
async tidyCards() {
const epoch = identityEpoch
// U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。
const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger')
for (const c of done) {
if (epoch !== identityEpoch) return
try {
const out = await api.cardDismiss(c.id)
if (epoch !== identityEpoch) return
store.upsertCard(out.card)
} catch { /* 单卡失败不阻断 */ }
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
}
if (epoch === identityEpoch && done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
},
closeWorkspace() { setUI({ activeWorkspace: null }) },
/** 铃铛:打开右栏并切到待处理。哪一侧有待办就看哪一侧。 */
@@ -1389,33 +1213,9 @@ export const store = {
setUI({ rightCollapsed: false, rightTab: 'work', inboxFilter: 'todo' })
},
openInbox(cardId?: string) {
if (state.workRecord) set({ workRecord: null })
if (cardId) {
const scope = state.cards.some(card => card.id === cardId) ? 'conv'
: state.projectCards.some(card => card.id === cardId) ? 'project' : 'conv'
set({ inboxScope: scope })
}
setUI({ rightCollapsed: false, rightTab: 'work', ...(cardId ? { inboxFilter: 'all' as const } : {}) })
setUI({ rightCollapsed: false, rightTab: 'work' })
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
},
openWorkRecord(card: ActivityCard) {
if (!card.sourceRunId || !card.conversationId || card.conversationId !== state.currentConvId || card.projectId !== state.currentProjectId) {
store.toast('请先打开这项工作所属的对话,再查看执行记录')
return
}
set({ workRecord: { cardId: card.id, runId: card.sourceRunId, conversationId: card.conversationId, title: card.title } })
},
closeWorkRecord() { set({ workRecord: null }) },
referenceWork(card: ActivityCard) {
if (card.projectId !== state.currentProjectId || card.conversationId !== state.currentConvId || !card.work?.result?.trim()) {
store.toast('请在这项工作所属的对话中引用结果')
return
}
set({ workReference: { cardId: card.id, resultVersion: card.work.resultVersion, title: card.title } })
if (card.work.resultVersion > card.work.readVersion) void store.cardAction(card, 'work.read', { resultVersion: card.work.resultVersion })
if (typeof document !== 'undefined') document.querySelector<HTMLTextAreaElement>('.input-row textarea')?.focus()
},
clearWorkReference() { set({ workReference: null }) },
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
openFiles() { setUI({ rightTab: 'files', rightCollapsed: false }) },
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
@@ -1429,41 +1229,6 @@ export const store = {
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
*/
async openTaskSource(cardId: string, source: TaskSourceRef) {
const conversationId = state.currentConvId
const projectId = state.currentProjectId
const epoch = conversationEpoch
const intent = state.previewOpenIntent + 1
set({ previewOpenIntent: intent })
const isCurrent = () => conversationId === state.currentConvId && projectId === state.currentProjectId
&& epoch === conversationEpoch && intent === state.previewOpenIntent
try {
const out = await api.taskSource(cardId, source.id)
if (!isCurrent()) return
if (!out.artifact) {
store.toast('来源当前不可用,正在核实工作记录')
if (conversationId) void store.refreshCards(conversationId)
return
}
store.requestTaskSourcePreview(out.artifact, {
notice: out.versionState === 'changed'
? '当前文件已更新;下方引用来自任务处理时的版本,不能作为当前文件的结论'
: '任务处理时引用的来源版本;下方是本项发现引用的片段',
quote: out.source.quote ?? '',
expectedHash: out.versionState === 'current' ? out.source.contentHash : undefined,
})
if (out.versionState === 'changed' && conversationId) void store.refreshCards(conversationId)
} catch {
if (!isCurrent()) return
store.toast('来源当前无法读取,正在核实工作记录')
if (conversationId) void store.refreshCards(conversationId)
}
},
requestTaskSourcePreview(a:Artifact,sourceContext:{notice:string;quote:string;expectedHash?:string}){
store.requestPreview(a)
const conv=previewTabsKey(state.currentConvId)
set({previewTabs:{...state.previewTabs,[conv]:(state.previewTabs[conv]??[]).map(tab=>tab.id===a.id?{...tab,sourceContext}:tab)}})
},
requestPreview(a: Artifact) {
const conv = previewTabsKey(state.currentConvId)
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
@@ -1515,7 +1280,7 @@ export const store = {
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
activatePreview(id: string) {
const conv = previewTabsKey(state.currentConvId)
set({ previewActive: { ...state.previewActive, [conv]: id },previewOpenIntent:state.previewOpenIntent+1 })
set({ previewActive: { ...state.previewActive, [conv]: id } })
},
closePreview(id: string) {
const conv = previewTabsKey(state.currentConvId)
@@ -1523,7 +1288,6 @@ export const store = {
set({
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
previewActive: { ...state.previewActive, [conv]: next.activeId },
previewOpenIntent:state.previewOpenIntent+1,
})
if (next.tabs.length === 0) setUI({ previewExpanded: false })
syncFilesEntry(next.tabs.length > 0)
@@ -1536,13 +1300,10 @@ export const store = {
},
}
auth.onIdentityChange(() => store.resetIdentity())
function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null {
if (state.runs.find(r => r.id === _runId)?.kind === 'subagent') return state.live
if (state.live && state.live.runId !== _runId && !isTerminal(state.runs.find(r => r.id === state.live?.runId)?.status)) return state.live
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {},
startedAt: eventTime({ ts: state.runs.find(run => run.id === _runId)?.createdAt }, now) }
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: now }
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
mutate(next)
return next
@@ -1580,5 +1341,5 @@ 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
return Number.isNaN(t) ? now : t
}
+29 -294
View File
@@ -194,10 +194,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tl-tools-sum { margin-left: 8px; color: var(--text-dim); }
.tl-check { color: var(--text-dim); font-size: var(--fs-ui-sm); margin: 4px 0; }
.tl-tools .status-error { margin-left: 8px; color: var(--err-text); }
.tl-tool-detail { margin-top: 4px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.tl-thinking { margin: 8px 0; padding: 8px 12px; border-left: 2px solid var(--border-strong); color: var(--text-dim); }
.tl-thinking-label { display: block; margin-bottom: 4px; font-size: var(--fs-ui-sm); font-weight: 600; }
.tl-thinking-body { white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--fs-ui-sm); line-height: 1.6; }
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .right-panel .preview-pane) */
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
@@ -230,7 +226,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.process-collapse .process-collapse-meta { color: var(--text-dim); }
.process-collapse .process-collapse-excerpt { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
.process-collapse .body { padding: 4px 12px 10px; }
.process-load-note { padding: 8px 0; color: var(--text-dim); font-size: var(--fs-ui-sm); }
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
@@ -502,52 +497,29 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.az-section-head p { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-faint); }
/* ---- 用户管理 / 改密弹层 ---- */
.ad-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
.ad-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
.ad-row { display: flex; gap: 8px; align-items: center; }
.ad-row .lg-input { flex: 1; min-width: 0; height: 34px; }
.ad-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
.ad-mini:hover { color: var(--text); border-color: var(--accent); }
.ad-hint { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.ad-notice { font-size: var(--fs-ui-sm); color: var(--ok-text); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.ad-notice svg { flex: none; margin-top: 1px; }
.ad-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
.ad-table tr:last-child td { border-bottom: none; }
.rp-panel { display: flex; flex-direction: column; gap: 12px; }
.rp-filters .lg-input { min-width: 140px; }
.rp-view-toggle { justify-content: flex-end; }
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); }
.rp-single { display: grid; grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
.rp-disable-confirm { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; font-size: var(--fs-ui-sm); color: var(--warn-text); background: var(--warn-soft); border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; }
.rp-role-list { display: flex; flex-direction: column; gap: 2px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); background: var(--bg-panel); }
.rp-role-list .az-nav-item { justify-content: space-between; width: 100%; }
.rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.rp-matrix-wrap { overflow-x: auto; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
.rp-matrix { min-width: 760px; }
.rp-matrix th:first-child, .rp-matrix td:first-child { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
.rp-matrix tr.rp-selected td { background: var(--bg-active); }
.rp-matrix tr.rp-selected td:first-child { background: var(--bg-active); }
.rp-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.rp-grants { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; margin-bottom: 12px; }
.rp-grants .ad-check { align-items: flex-start; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-raised); }
.rp-grants .ad-mono { margin-left: auto; }
@media (max-width: 820px) {
.rp-single { grid-template-columns: 1fr; }
.rp-role-list { max-height: 220px; overflow-y: auto; }
}
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.ad-dim { color: var(--text-dim); }
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
.ad-chip-super { background: var(--accent); color: var(--text-on-accent); }
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
.admin-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
.admin-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
.admin-row { display: flex; gap: 8px; align-items: center; }
.admin-row .lg-input { flex: 1; min-width: 0; height: 34px; }
.admin-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
.admin-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
.admin-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
.admin-mini:hover { color: var(--text); border-color: var(--accent); }
.admin-hint { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.admin-notice { font-size: var(--fs-ui-sm); color: var(--ok-text); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.admin-notice svg { flex: none; margin-top: 1px; }
.admin-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
.admin-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
.admin-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
.admin-table tr:last-child td { border-bottom: none; }
.admin-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.admin-dim { color: var(--text-dim); }
.admin-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
.admin-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
.admin-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
.admin-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
.admin-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
.admin-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
/* ---- 收起轨道(左栏收起态) ---- */
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }
@@ -559,7 +531,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
.role-mark.role-assistant { background: none; border: none; padding: 0; }
.msg.user .msg-role { align-items: center; }
.ad-user-cell { display: flex; align-items: center; gap: 8px; }
.admin-user-cell { display: flex; align-items: center; gap: 8px; }
.artifact-chip .ac-act { border: none; background: none; }
@@ -648,251 +620,14 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; }
.rdm-audit-opgroup-head:hover { background: var(--bg-hover); }
.rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); }
.rdm-audit-opgroup-action { color: var(--text-dim); }
.rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); }
.rdm-audit-event { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
.rdm-audit-event-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-dim); }
.rdm-audit-event-action { color: var(--text); }
.rdm-audit-event-summary { color: var(--text); }
.rdm-audit-event-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; }
.rdm-audit-event-fields > div { min-width: 0; padding: 8px; background: var(--bg-raised); border-radius: var(--radius-sm); }
.rdm-audit-event-fields dt { color: var(--text-dim); font-size: var(--fs-ui-xs); }
.rdm-audit-event-fields dd { margin: 3px 0 0; color: var(--text); overflow-wrap: anywhere; }
.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; }
.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; }
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
.rdm-audit-coverage { margin-top: 12px; }
.rdm-audit-export { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-export-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; }
.rdm-audit-export-kind { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-export-kind .ui-input { min-width: 180px; height: 32px; font-size: var(--fs-ui-sm); }
.rdm-audit-export-draft { padding: 8px 10px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); }
.rdm-audit-export-draft p { margin: 0 0 8px; }
.rdm-audit-export-result { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-export-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
.rdm-audit-materials-side[hidden] { display: none; }
.rdm-audit-materials > .drw { flex: 1; }
.rdm-audit-tree-row { display: flex; align-items: center; gap: 4px; }
.rdm-audit-tree-row .rdm-audit-tree-node { flex: 1; min-width: 0; }
.rdm-audit-tree-open { flex: none; font-size: var(--fs-ui-sm); padding: 4px 8px; }
/* 统一渲染 Task 11:文档阅读与交付工作区(结构画布 / 阅读视图 + 规则、预览、审阅、下载) */
.drw { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.drw-header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.drw-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-lg); color: var(--text); }
.drw-view-tabs { flex: none; }
.drw-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); gap: 16px; align-items: start; }
.drw-main, .drw-side { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.drw-filters { display: flex; gap: 8px; flex-wrap: wrap; }
.drw-search { flex: 1; min-width: 140px; height: 32px; font-size: var(--fs-ui-sm); }
.drw-select { height: 32px; font-size: var(--fs-ui-sm); max-width: 100%; }
.drw-canvas { position: relative; height: min(60vh, 520px); overflow: auto; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); cursor: grab; touch-action: none; }
.drw-canvas.is-panning { cursor: grabbing; user-select: none; }
.drw-canvas-stage { position: relative; }
.drw-canvas-edges { position: absolute; inset: 0; pointer-events: none; }
.drw-canvas-edge { fill: none; stroke: var(--border-strong); stroke-width: 1.5; }
.drw-canvas-node { position: absolute; display: flex; align-items: center; gap: 6px; padding: 0 10px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-dim); font-size: var(--fs-ui-sm); text-align: left; cursor: pointer; }
.drw-canvas-node:hover { background: var(--bg-hover); color: var(--text); }
.drw-canvas-node.depth-0 { color: var(--text); font-weight: 600; border-color: var(--border-strong); }
.drw-canvas-node.sel { background: var(--bg-active); color: var(--text); box-shadow: inset 0 0 0 1px var(--accent); }
.drw-canvas-node:focus-visible, .drw-reader-title:focus-visible { outline: none; box-shadow: var(--ring); }
.drw-canvas-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drw-reader { max-height: min(60vh, 520px); overflow: auto; padding: 4px 16px 16px; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius); }
.drw-reader-node { padding: 4px 8px; border-radius: var(--radius-sm); scroll-margin-top: 8px; }
.drw-reader-node.sel { background: var(--bg-active); }
.drw-reader-heading { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; font-weight: 600; color: var(--text); }
h2.drw-reader-heading { font-size: var(--fs-ui-xl); }
h3.drw-reader-heading { font-size: var(--fs-ui-lg); }
h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); }
.drw-reader-title { background: none; border: none; padding: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: var(--radius-xs); overflow-wrap: anywhere; }
.drw-reader-title[data-anchor-node] { cursor: text; }
.drw-anchor { color: var(--text-faint); font-size: var(--fs-ui-sm); text-decoration: none; }
.drw-anchor:hover { color: var(--accent-text); }
.drw-badges { display: inline-flex; gap: 4px; flex: none; }
.drw-badge { min-width: 18px; padding: 0 5px; border-radius: var(--radius-full); font-size: var(--fs-ui-xs); font-weight: 600; line-height: 16px; text-align: center; color: var(--text-on-semantic); }
.drw-badge.sev-error { background: var(--err-text); }
.drw-badge.sev-warning { background: var(--warn-text); }
.drw-badge.review { background: var(--accent-soft); color: var(--accent-text); }
.drw-selected { padding: 12px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); }
.drw-selected-title { font-size: var(--fs-ui-base); color: var(--text); margin-bottom: 6px; overflow-wrap: anywhere; }
.drw-facts { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); }
.drw-fact { display: flex; gap: 8px; align-items: baseline; }
.drw-fact-k { flex: none; min-width: 4.5em; color: var(--text-dim); }
.drw-fact-v { flex: 1; min-width: 0; color: var(--text); overflow-wrap: anywhere; }
.drw-diff-del, .drw-diff-add { padding: 4px 8px; border-radius: var(--radius-xs); font-size: var(--fs-ui-sm); white-space: pre-wrap; overflow-wrap: anywhere; }
.drw-diff-del { background: var(--diff-remove); color: var(--diff-remove-text); text-decoration: line-through; }
.drw-diff-add { background: var(--diff-add); color: var(--diff-add-text); }
.drw-diff-del + .drw-diff-add { margin-top: 4px; }
.drw-conflict { color: var(--warn-text); }
.drw-byline { display: inline-flex; gap: 6px; align-items: baseline; min-width: 0; }
.drw-byline-name { color: var(--text); font-weight: 550; }
.drw-byline-time { color: var(--text-dim); font-size: var(--fs-ui-sm); }
.drw-panel { display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); min-width: 0; }
.drw-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
.drw-row-between { justify-content: space-between; }
.drw-muted { font-size: var(--fs-ui-sm); color: var(--text-dim); }
.drw-mono { font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
.drw-ok { font-size: var(--fs-ui-sm); color: var(--ok-text); }
.drw-op { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; font-size: var(--fs-ui-sm); }
.drw-receipt { display: flex; flex-direction: column; gap: 6px; }
.drw-filename { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
.drw-failure { display: flex; flex-direction: column; gap: 4px; }
.drw-failure-msg { font-size: var(--fs-ui-sm); color: var(--err-text); overflow-wrap: anywhere; }
.drw-notice { padding: 8px 10px; background: var(--wait-soft); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); }
.drw-profiles { display: flex; flex-direction: column; gap: 8px; }
.drw-profile-list { display: flex; flex-direction: column; gap: 4px; }
.drw-profile { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; }
.drw-profile:hover { background: var(--bg-hover); }
.drw-profile.sel { background: var(--bg-active); }
.drw-profile.off { cursor: not-allowed; }
.drw-profile input { margin-top: 3px; flex: none; }
.drw-profile-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.drw-profile-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-profile-source { color: var(--text); font-weight: 600; }
.drw-profile-id { max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
.drw-profile-ver { color: var(--text-dim); }
.drw-profile-reason { font-size: var(--fs-ui-sm); color: var(--text-dim); }
.drw-diags, .drw-annos { display: flex; flex-direction: column; gap: 6px; }
.drw-group-trigger { display: flex; width: 100%; padding: 4px 0; background: none; border: none; cursor: pointer; text-align: left; }
.drw-group-label { font-size: var(--fs-ui-sm); font-weight: 600; color: var(--text); }
.drw-group-label.sev-error { color: var(--err-text); }
.drw-group-label.sev-warning { color: var(--warn-text); }
.drw-diag-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }
.drw-diag { display: flex; flex-wrap: wrap; gap: 4px 8px; padding: 6px 8px; border-left: 2px solid var(--border); font-size: var(--fs-ui-sm); }
.drw-diag.sev-error { border-left-color: var(--err); }
.drw-diag.sev-warning { border-left-color: var(--warn); }
.drw-diag-node { max-width: 16em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; }
.drw-diag-node.unlocated { color: var(--text-dim); cursor: default; }
.drw-diag-msg { flex: 1 1 12em; min-width: 0; color: var(--text); overflow-wrap: anywhere; }
.drw-diag-code { font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
.drw-diag-hint { flex-basis: 100%; color: var(--text-dim); }
.drw-more { align-self: flex-start; font-size: var(--fs-ui-sm); }
.drw-anno-group { display: flex; flex-direction: column; gap: 4px; }
.drw-anno-node { align-self: flex-start; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); font-weight: 600; cursor: pointer; }
.drw-anno, .drw-rev { padding: 6px 8px; background: var(--bg-raised); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); }
.drw-anno.resolved { opacity: .8; }
.drw-anno-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 2px; }
.drw-anno-text { color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
.drw-anno-replies { margin-top: 6px; padding-left: 12px; border-left: 2px solid var(--border-soft); display: flex; flex-direction: column; gap: 6px; }
.drw-anno-orphan-note { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 4px; }
.drw-anno.needs-reanchor { border-color: var(--warn); }
.drw-anno-quote { margin: 2px 0 4px; padding: 0 8px; border-left: 2px solid var(--border-strong); color: var(--text-dim); white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.drw-anno-reanchor { margin-bottom: 4px; font-size: var(--fs-ui-sm); color: var(--warn-text); }
.drw-rev .drw-diff-del, .drw-rev .drw-diff-add { margin-top: 4px; }
.drw-traces { display: flex; flex-direction: column; gap: 6px; }
.drw-trace { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 6px 8px; border-left: 2px solid var(--border); font-size: var(--fs-ui-sm); }
.drw-trace.masked { border-left-style: dashed; }
.drw-trace-type { font-weight: 600; color: var(--text); }
.drw-trace-subject { min-width: 0; color: var(--text-dim); overflow-wrap: anywhere; }
.drw-trace-masked { padding: 0 6px; border: 1px dashed var(--border-strong); border-radius: var(--radius-xs); color: var(--text-dim); }
.drw-trace-body { flex-basis: 100%; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
.drw-decisions { display: flex; flex-direction: column; gap: 4px; }
.drw-decision { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-downloads { display: flex; gap: 6px; flex-wrap: wrap; }
.drw-download { font-size: var(--fs-ui-sm); padding: 4px 8px; }
.drw-bind-rules { display: flex; flex-direction: column; gap: 4px; }
.drw-bind-rule { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); }
.drw-bind-rule:hover { background: var(--bg-hover); }
.drw-bind-rule.sel { background: var(--bg-active); }
.drw-bind-row { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--border-soft); font-size: var(--fs-ui-sm); }
.drw-composer { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--border-soft); }
.drw-composer-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-composer-node { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.drw-composer-reply { display: flex; gap: 8px; align-items: center; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.drw-composer-scope { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-composer-quote { min-width: 0; color: var(--text); overflow-wrap: anywhere; }
.drw-anchor-notice { font-size: var(--fs-ui-sm); color: var(--warn-text); }
.drw-textarea { min-height: 64px; resize: vertical; font-size: var(--fs-ui-sm); }
.drw-inline-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
.drw-link { padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; }
.drw-link:disabled { color: var(--text-dim); cursor: default; }
.drw-deliveries { display: flex; flex-direction: column; gap: 6px; }
.drw-delivery { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); }
.drw-delivery.sel { background: var(--bg-active); }
@media (max-width: 860px) {
.drw-body { grid-template-columns: minmax(0, 1fr); }
.drw-canvas, .drw-reader { height: auto; max-height: 50vh; }
.drw-canvas { min-height: 240px; }
}
.duty-matrix-wrap { overflow: auto; }
.duty-matrix .duty-sticky { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
.duty-matrix thead th { position: sticky; top: 0; background: var(--bg-panel); z-index: 2; }
.duty-cell select { font-size: var(--fs-ui-sm); color: var(--text); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); }
/* ---- 文档驾驶舱(I2;令牌驱动,词汇沿用 rdm-audit 卡片体系) ---- */
.cockpit { display: flex; flex-direction: column; min-height: 320px; }
.cockpit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
.cockpit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
.cockpit-filter-chip, .cockpit-refresh { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); color: var(--text); cursor: pointer; }
.cockpit-snap { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-banner { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 6px 10px; margin-bottom: 8px; font-size: var(--fs-ui-sm); }
.cockpit-banner-err { border-color: var(--border); }
.cockpit-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; margin-bottom: 12px; }
.cockpit-stat { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; text-align: center; font: inherit; }
button.cockpit-stat { cursor: pointer; }
.cockpit-stat-active { border-color: var(--text); }
.cockpit-stat-unavail { color: var(--text-muted, var(--text)); }
.cockpit-stat-num { display: block; font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
.cockpit-stat-label { display: block; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-columns { display: grid; grid-template-columns: minmax(220px, 300px) 1fr; gap: 14px; }
.cockpit-tree { min-height: 0; overflow-y: auto; }
.cockpit-tree-row { display: flex; align-items: center; gap: 6px; padding: 3px 4px; border-radius: var(--radius-sm); }
.cockpit-tree-row-active { background: var(--bg-panel); }
.cockpit-tree-caret { border: none; background: none; cursor: pointer; padding: 0 2px; font-size: var(--fs-ui-sm); color: var(--text); }
.cockpit-tree-title { border: none; background: none; padding: 0; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; }
.cockpit-badge { font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 4px; color: var(--text-muted, var(--text)); }
.cockpit-tree-time { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-tree-empty, .cockpit-tree-more { border: none; background: none; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 2px 4px; }
button.cockpit-tree-more { cursor: pointer; text-decoration: underline; }
.cockpit-truncated { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 4px 0; }
.cockpit-center { min-height: 0; overflow-y: auto; }
.cockpit-doclist { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.cockpit-docrow { display: flex; align-items: baseline; gap: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 8px 10px; cursor: pointer; font: inherit; text-align: left; }
.cockpit-docrow-title { font-size: var(--fs-ui-base, var(--fs-ui-sm)); color: var(--text); }
.cockpit-docrow-meta { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-changes { display: flex; flex-direction: column; gap: 4px; }
.cockpit-changerow { display: flex; justify-content: space-between; gap: 10px; font-size: var(--fs-ui-sm); }
.cockpit-changesum { color: var(--text); }
.cockpit-changetime { color: var(--text-muted, var(--text)); }
.cockpit-nodehead { display: flex; align-items: baseline; gap: 10px; padding-bottom: 8px; }
.cockpit-back { border: none; background: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); padding: 0; text-decoration: underline; }
.cockpit-nodetitle { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
.cockpit-nodesum { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-childtable { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
.cockpit-childtable th, .cockpit-childtable td { border-bottom: 1px solid var(--border); padding: 4px 6px; text-align: left; color: var(--text); }
@media (max-width: 760px) { .cockpit-columns { grid-template-columns: 1fr; } .cockpit-tree { max-height: 40vh; } }
/* ---- 驾驶舱 I3:阅读器与右栏(令牌驱动) ---- */
.cockpit-columns-3 { grid-template-columns: minmax(200px, 260px) 1fr minmax(240px, 300px); }
.cockpit-right { min-height: 0; overflow-y: auto; border-left: 1px solid var(--border); padding-left: 12px; }
.cockpit-viewtoggle { display: flex; gap: 0; }
.cockpit-viewbtn { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); background: var(--bg-panel); color: var(--text); cursor: pointer; }
.cockpit-viewbtn-active { background: var(--text); color: var(--bg-panel); }
.cockpit-reader-para { white-space: pre-wrap; font-size: var(--fs-ui-base, var(--fs-ui-sm)); line-height: 1.6; color: var(--text); margin: 6px 0; }
.cockpit-density-full .cockpit-reader-para { line-height: 1.9; margin: 10px 0; }
.cockpit-reader-section { border-top: 1px solid var(--border); padding: 8px 0; }
.cockpit-reader-sechead { display: flex; align-items: baseline; gap: 8px; }
.cockpit-reader-sectitle { font-weight: 600; color: var(--text); font-size: var(--fs-ui-base, var(--fs-ui-sm)); }
.cockpit-versions { font-size: var(--fs-ui-sm); }
.cockpit-density-full .cockpit-versions { line-height: 1.7; }
.cockpit-versions-toggle { font-size: var(--fs-ui-sm); color: var(--text); text-decoration: underline; cursor: pointer; }
.cockpit-versions-head { color: var(--text-muted, var(--text)); padding: 4px 0; }
.cockpit-diff { white-space: pre-wrap; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--fs-ui-sm); }
.cockpit-diff-add { color: var(--text); }
.cockpit-diff-del { color: var(--text-muted, var(--text)); text-decoration: line-through; }
.cockpit-diff-same { color: var(--text-muted, var(--text)); }
.cockpit-panel-tabs { display: flex; gap: 4px; padding-bottom: 8px; }
.cockpit-panel-tab { border: 1px solid var(--border); background: none; padding: 3px 8px; font-size: var(--fs-ui-sm); color: var(--text); cursor: pointer; border-radius: var(--radius-sm); }
.cockpit-panel-tab-active { background: var(--bg-panel); border-color: var(--text); }
.cockpit-panel-body { display: flex; flex-direction: column; gap: 6px; }
.cockpit-panel-count { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-panel-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border); padding: 4px 0; }
.cockpit-panel-kind { color: var(--text-muted, var(--text)); }
.cockpit-panel-label { color: var(--text); }
.cockpit-panel-conflict { flex-direction: column; }
.cockpit-conflict-side { color: var(--text); }
@media (max-width: 760px) {
.rdm-audit-event-fields { grid-template-columns: 1fr; }
.rdm-audit-trace-cols { grid-template-columns: 1fr; }
}
-39
View File
@@ -30,51 +30,15 @@ export const RDM_AUDIT_COMMANDS = [
'rdm.inspect.history',
] as const
/** 概览页导出区单独探测。不并入上面的七命令就绪门。 */
export const RDM_EXPORT_COMMANDS = [
'rdm.inspect.export.create',
'rdm.inspect.export.status',
'rdm.inspect.export.download',
] as const
/** 三条导出命令都启用才显示概览导出区。缺一或清单不可用则隐藏。 */
export function rdmExportReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return RDM_EXPORT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
/** 正式报告被完整性门拒绝时,界面先确认再带 draft 重试。 */
export function exportNeedsDraft(message: string): boolean {
return /不完整|草稿/.test(message)
}
/** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */
export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
/** 文档驾驶舱消费的全部 rdm.cockpit.* 命令(I1 只读面;rollout I2)。 */
export const COCKPIT_COMMANDS = [
'rdm.cockpit.overview',
'rdm.cockpit.tree',
'rdm.cockpit.node',
'rdm.cockpit.node.sources',
'rdm.cockpit.node.history',
'rdm.cockpit.node.impacts',
'rdm.cockpit.node.conflicts',
] as const
/** 七命令齐备且 enabled 才展示驾驶舱入口(与 rdmAuditReady 同源语义)。 */
export function cockpitReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return COCKPIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
{ key: 'agents', title: '代理', icon: '🤖' },
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
{ key: 'rdm-cockpit', title: '文档驾驶舱', icon: '📋' },
]
function shortcutNum(raw?: string): number | null {
@@ -88,8 +52,6 @@ function shortcutNum(raw?: string): number | null {
interface BuildWorkspaceOptions {
/** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */
rdmAudit?: boolean
/** rdm.cockpit.* 命令面探测通过(store.rdmCockpitAvailable)。默认 false:能力未知/缺失不展示驾驶舱入口。 */
rdmCockpit?: boolean
}
export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] {
@@ -121,7 +83,6 @@ export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWork
}
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
if (opts.rdmAudit === true) ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
if (opts.rdmCockpit === true) ranked.push({ rank: takeFree(3), entry: BUILTINS[2] })
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
return ranked
.filter(item => item.rank <= 9)
-10
View File
@@ -1,10 +0,0 @@
import { describe, expect, it } from 'vitest'
import { credentialLabel } from '../src/components/AccountUsersPanel'
describe('account directory credential state', () => {
it('shows whether password is ready without legacy initialization labels', () => {
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待设置密码')
expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).toBe('已设置密码')
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('待设置密码')
})
})
+8 -73
View File
@@ -69,29 +69,6 @@ describe('conversation work reading', () => {
expect(html).not.toContain('data-testid="input-status"')
})
it('renders stable semantic timestamps on user and assistant messages', () => {
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理材料' }]), id: 'user', role: 'user' }, reply([{ type: 'text', text: '材料已收到' }])]
const html = render()
expect(html.match(/<time class="message-timestamp"/g)).toHaveLength(2)
expect(html.match(/dateTime="2026-10-12T10:00:00.000Z"/g)).toHaveLength(2)
expect(html).toContain('title="2026/10/12')
})
it('announces unread work without inserting its full result into the chat', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card({ status: 'done', work: { result: 'background-result-full-text', resultVersion: 2, readVersion: 1 } })]
const html = render()
expect(html).toContain('1 项工作有新结果')
expect(html).not.toContain('background-result-full-text')
})
it('shows an explicit removable result reference without copying its content into the draft', () => {
snapshot.state!.workReference = { cardId: 'work', resultVersion: 2, title: '材料整理' }
const html = render()
expect(html).toContain('基于「材料整理」的第 2 版结果')
expect(html).toContain('aria-label="移除工作结果引用"')
})
it('shows the current real work with the auxiliary pane closed', () => {
snapshot.state!.cards = [card()]
const html = render()
@@ -137,30 +114,25 @@ describe('conversation work reading', () => {
if (pinned) expect(html).not.toContain('data-card-id="work_active"')
})
it('leaves completed results to the visible right work pane', () => {
it('keeps the newest completed result as a compact anchor with the auxiliary pane open', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = completedResults()
const html = renderWork()
expect(html).toBe('')
expect(html).toContain('最新整理成果 · 查看摘要')
expect(html).not.toContain('最新成果的主要结论')
expect(html).not.toContain('先前整理成果')
})
it('keeps the work card in one detail location when the right work pane is visible', () => {
it('keeps a compact work anchor when the same summary is visible in the right pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card()]
const html = render()
expect(html).not.toContain('data-conversation-work')
expect(html).toContain('整理项目材料')
expect(html).not.toContain('核对会议结论')
snapshot.state!.ui.rightCollapsed = true
expect(render()).toContain('核对会议结论')
})
it('keeps the conversation detail when the right pane is reading files', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.ui.rightTab = 'files'
snapshot.state!.cards = [card()]
expect(render()).toContain('核对会议结论')
})
it('keeps secondary message operations available through progressive disclosure', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '已经整理好要点' }])]
const html = render()
@@ -218,29 +190,6 @@ describe('conversation work reading', () => {
expect(html).toContain('确认新增')
})
it('shows only a location link for anchored confirmation when the work pane owns its actions', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理资料' }]), role: 'user' }]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
const html = render()
expect(html).toContain('新增一份项目草稿')
expect(html).toContain('在工作栏处理')
expect(html).not.toContain('确认新增')
})
it('keeps a submitted card as a location link when its full body is in the work pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card({ id: 'result_1', kind: 'result', title: '材料核对结果', status: 'done',
body: [{ type: 'markdown', markdown: '材料核对的完整正文' }] })]
snapshot.state!.messages = [reply([{ type: 'card', card: { cardId: 'result_1', kind: 'result', data: [] } }])]
const html = render()
expect(html).toContain('材料核对结果')
expect(html).not.toContain('展开全文')
snapshot.state!.ui.rightCollapsed = true
expect(render()).toContain('展开全文')
})
it('keeps a required confirmation accessible when its anchor is an assistant message', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '准备新增项目草稿' }])]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
@@ -248,26 +197,12 @@ describe('conversation work reading', () => {
expect(render()).toContain('确认新增')
})
it('keeps a thinking-only record available for deliberate review', () => {
it('does not reveal internal thinking when records are deliberately opened', () => {
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'thinking', process: true, text: 'private-internal-deliberation' }],
live: true, idKey: 'thinking-test', renderPart: () => null,
}))
expect(html).toContain('private-internal-deliberation')
snapshot.state!.messages = [reply([{ type: 'thinking', process: true, text: 'private-internal-deliberation' },
{ type: 'text', text: '已完成核对' }])]
expect(render()).toContain('执行记录')
})
it('lets readers reopen the full output of a grouped read action', () => {
setPersistedOpen('tool-record:read-history:call-read:0', true)
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'tool_call', process: true,
toolCall: { id: 'call-read', name: 'fs_read_file', arguments: '{"path":"材料.txt"}' },
toolOutput: 'full-read-output' } as Message['parts'][number]],
live: false, idKey: 'read-history', renderPart: () => null,
}))
expect(html).toContain('full-read-output')
expect(html).not.toContain('private-internal-deliberation')
})
it('blocks repeated HITL choices when the shared action result is unknown', () => {

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