Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a63f0221 | ||
|
|
128aafed46 | ||
|
|
054f134111 | ||
|
|
c37c33bcac | ||
|
|
7d9ac7c093 | ||
|
|
1d907c16a1 | ||
|
|
a6ce695d6c | ||
|
|
d965732106 | ||
|
|
4be7362842 | ||
|
|
6db528df10 | ||
|
|
22ef3c4b67 | ||
|
|
080c630816 | ||
|
|
e4041b36b9 | ||
|
|
afd9e7504b | ||
|
|
72c941c772 | ||
|
|
d47c49350b | ||
|
|
1650b4fff0 | ||
|
|
4d7dffeb4b | ||
|
|
ac45aa71aa | ||
|
|
cf07aef3d1 | ||
|
|
d562d6a95c | ||
|
|
df7818f02a | ||
|
|
50ec83657a | ||
|
|
1f6bff9487 | ||
|
|
426d1044ac | ||
|
|
8260bf5bb4 | ||
|
|
2d65a819f1 | ||
|
|
7050c91334 | ||
|
|
a9e2c3fa25 | ||
|
|
6a3f2447f0 | ||
|
|
96ab2ebd32 | ||
|
|
57f2746c59 | ||
|
|
04595677a2 | ||
|
|
7df77c1a8c | ||
|
|
1445f9e2af | ||
|
|
0d6b8d7745 | ||
|
|
50d12ce974 | ||
|
|
0e05624823 | ||
|
|
12553f53eb | ||
|
|
6727cdd30f | ||
|
|
92864db8d3 | ||
|
|
fb1f9f246b | ||
|
|
2f8009b967 | ||
|
|
0f31f91af5 | ||
|
|
61986dbcd9 | ||
|
|
bc866c4489 | ||
|
|
d64dbc3a15 | ||
|
|
5670c68e1f | ||
|
|
8b59998d3c | ||
|
|
c9ba3442fc | ||
|
|
ed23de28df | ||
|
|
83e81d12ac | ||
|
|
e503cc9e93 | ||
|
|
c3d8205839 | ||
|
|
dd159c24d0 | ||
|
|
0d4d1be198 | ||
|
|
a56ce3e1df | ||
|
|
10f8388bb8 | ||
|
|
22f59cd1b2 | ||
|
|
7f3538da22 | ||
|
|
5ede8df52a | ||
|
|
d5e6b12e75 | ||
|
|
520e7df58b | ||
|
|
123f4fb052 | ||
|
|
36f2fbea34 | ||
|
|
de65026238 | ||
|
|
85428b4633 | ||
|
|
05609ff61c | ||
|
|
5c50943731 | ||
|
|
f2b5532b7b | ||
|
|
95e1948a5f | ||
|
|
987be7442f | ||
|
|
ad503c9535 | ||
|
|
20c20b7959 | ||
|
|
1ef68abb8c | ||
|
|
9d0ae331c7 | ||
|
|
1fc9dabc13 | ||
|
|
505aba36c1 | ||
|
|
b921bef6a6 | ||
|
|
c9b55eb502 | ||
|
|
70f0d2d41f | ||
|
|
bd736f528c | ||
|
|
b52f9654a0 | ||
|
|
814643f277 | ||
|
|
bc9b17a665 | ||
|
|
a1bd3e2983 | ||
|
|
93ee723131 | ||
|
|
ee735f2e2e | ||
|
|
3203e8d4f3 | ||
|
|
bed8a5c766 | ||
|
|
055476d135 | ||
|
|
34f6a1bf2f | ||
|
|
2e7e5b7a64 | ||
|
|
36ac754300 | ||
|
|
a961b19ebb | ||
|
|
010540f203 | ||
|
|
731c9b7776 |
@@ -0,0 +1,68 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out fixed Frontend and UI commits
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }}
|
||||
UI_SHA: 5c2b1036dd1aff89ec4eeae0c96493694e33ad4c
|
||||
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
|
||||
@@ -0,0 +1,150 @@
|
||||
name: governance
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
governance:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 只物化两个治理 gitlink(Guidelines 与 deps/development-manager),非递归;
|
||||
# 其它 gitlink 不物化、不取回。凭据只以 URL 限定的 extraheader 存在于本进程树内。
|
||||
python3 - <<'PY_CHECKOUT'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed_server = urlsplit(SERVER)
|
||||
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
|
||||
sys.exit("Checkout server must be the platform-injected http(s) URL")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
|
||||
REPOSITORIES = {
|
||||
"deps/development-guidelines": "Development-Guidelines",
|
||||
"deps/development-manager": "Development-Manager",
|
||||
}
|
||||
TARGET = "weichen/Agent-System-Frontend"
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
if not root_token or not component_token:
|
||||
sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN")
|
||||
if os.environ.get("GITHUB_REPOSITORY") != TARGET:
|
||||
sys.exit("Checkout repository is outside the reviewed allowlist")
|
||||
sha = os.environ.get("GITHUB_SHA", "")
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
|
||||
sys.exit("Checkout requires a fixed commit SHA")
|
||||
|
||||
# 丢弃继承的 Git 追踪/配置/SSH 钩子;凭据只存在于单个 Git 进程树的精确 URL 头中。
|
||||
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
||||
clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null",
|
||||
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0")
|
||||
config = {
|
||||
"core.hooksPath": "/dev/null", "core.fsmonitor": "false",
|
||||
"init.templateDir": "", "credential.helper": "",
|
||||
"fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never",
|
||||
"protocol." + urlsplit(SERVER).scheme + ".allow": "always",
|
||||
}
|
||||
|
||||
|
||||
def git(repo, *args, extra=None):
|
||||
values = config | (extra or {})
|
||||
env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return subprocess.run(["git", "-C", str(repo), *args], env=env,
|
||||
check=True, stdout=subprocess.PIPE).stdout
|
||||
|
||||
|
||||
def materialize_governance(root):
|
||||
# -r 列出任意深度的 gitlink,但不进入 submodule 内部;其它 gitlink 只记录、从不取回。
|
||||
links = {}
|
||||
for entry in git(root, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if not entry:
|
||||
continue
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
for name in REPOSITORIES:
|
||||
if name not in links:
|
||||
raise ValueError("Governance gitlink is missing from the fixed commit")
|
||||
raw = git(root, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
continue
|
||||
name, field = match.groups()
|
||||
if name not in REPOSITORIES:
|
||||
continue
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
# 先整体校验两个治理条目,再进行任何带凭据的取回。
|
||||
for name, repository in REPOSITORIES.items():
|
||||
fields = entries.get(name, {})
|
||||
if fields.get("path") != name:
|
||||
raise ValueError("Governance submodule path is outside the reviewed allowlist")
|
||||
trusted_url = f"{SERVER}/weichen/{repository}.git"
|
||||
if fields.get("url") not in {SSH + repository + ".git", trusted_url}:
|
||||
raise ValueError("Governance submodule URL is outside the reviewed allowlist")
|
||||
for name, repository in REPOSITORIES.items():
|
||||
url = f"{SERVER}/weichen/{repository}.git"
|
||||
# 仅持久化审查过的公开 URL,使 Git 在进程级配置消失后仍识别该 submodule。
|
||||
git(root, "config", f"submodule.{name}.url", url)
|
||||
git(root, "submodule", "update", "--init", "--checkout", "--", name,
|
||||
extra={f"submodule.{name}.url": url,
|
||||
f"submodule.{name}.update": "checkout",
|
||||
f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token})
|
||||
child = root / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != links[name]:
|
||||
raise ValueError("Materialized governance gitlink does not match the fixed SHA")
|
||||
|
||||
|
||||
try:
|
||||
root = Path.cwd()
|
||||
# 必须是干净的临时工作区;不复用既有 Git 状态。
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
url = f"{SERVER}/{TARGET}.git"
|
||||
git(root, "remote", "add", "origin", url)
|
||||
git(root, "fetch", "--depth=1", "origin", sha,
|
||||
extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token})
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
materialize_governance(root)
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit(f"Checkout refused: {error}")
|
||||
print("Checkout complete; credentials were not persisted in Git configuration")
|
||||
PY_CHECKOUT
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Validate agent execution configuration
|
||||
run: scripts/agent-exec validate --repo .
|
||||
- name: Governance check
|
||||
run: scripts/dm governance check --repo .
|
||||
@@ -0,0 +1,6 @@
|
||||
[submodule "deps/development-guidelines"]
|
||||
path = deps/development-guidelines
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
|
||||
[submodule "deps/development-manager"]
|
||||
path = deps/development-manager
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent-System-Frontend 采纳 development-governance-v2 治理套件 任务卡
|
||||
|
||||
任务主记录:本文件随 PR 描述维护(PR 链接见下方"结果与验证")。
|
||||
状态:进行中;owner:weichen。
|
||||
|
||||
## 基线与授权
|
||||
|
||||
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
|
||||
- branch_kind:initiative;initiative_id:governance-adoption-Agent-System-Frontend
|
||||
- R0(initial_main_sha):8b59998d3c5b0545f3bb0b3e670b22aa7fe2179b;工作区:/home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption(worktree);分支:feature/governance-adoption
|
||||
- 父任务信息(仅子任务填写):无
|
||||
- accepting_requirements:false(单仓采纳 initiative,范围固定为下列十步,不接收新需求)
|
||||
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=development-governance-v2 Phase 6 每个正式仓库独立 initiative;triage 收据=无(本仓采纳前尚无 triage 入口)
|
||||
- 授权与交付终点:owner 已授权 development-governance-v2 全部实施(Agent-System-Dev 主记录 docs/superpowers/plans/2026-10-16-development-governance-v2-master.md);终点为本 PR 合入 main 且采纳状态 active。分支保护与 tag 由用户执行。
|
||||
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
目标(Phase 6 十步):固定 Guidelines SHA(deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb);增加 AGENT-EXECUTION.md;增加共享执行器薄入口(deps/development-manager @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c + scripts/dm、scripts/agent-exec、scripts/task);四条路由(implementation/repair/test/review);任务卡模板与分支元数据;CI 中启用 schema 校验(.gitea/workflows/governance.yml:agent-exec validate + governance check);一次正例运行;负例(缺配置、坏收据、越界路径、未知副作用);独立 Review + CI;采纳状态 active。
|
||||
|
||||
非目标:不改业务代码与既有测试;不改分支保护(由用户执行);不发布 tag。
|
||||
|
||||
引导说明:AGENT-EXECUTION.md 由 host 以固定版本治理套件(dm-server governance init @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)生成并作为引导配置提交(执行器运行前必须存在有效配置;沿用 DM 采纳先例);其余文件由实施单元生成。
|
||||
|
||||
## Agent 实施收据
|
||||
|
||||
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| governance-adoption-Agent-System-Frontend-r1 | 13 个允许路径(治理文件 + 两个 gitlink) | 5670c68e | implementation | cursor→antigravity→pi | run e3fd063d(dm-server pinned,attempts=1) | cursor / not-observable | success | d64dbc3a15d2(apply 快进) | 否 | /tmp/gov-receipt-Agent-System-Frontend-r1.json |
|
||||
|
||||
## 同步记录
|
||||
|
||||
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|
||||
|---|---|---|---|---|
|
||||
| 启动 | 8b59998d3c5b0545f3bb0b3e670b22aa7fe2179b | 8b59998d3c5b0545f3bb0b3e670b22aa7fe2179b | 2026-10-17T06:53:57Z | 一致 |
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
|
||||
- 结果:NOT_RUN
|
||||
- 验证:见下表(命令输出由 /tmp/gov-neg/ev.sh 写入 .task/evidence/governance-adoption/,SELF 除非另注)。
|
||||
- 影响与剩余项:NOT_RUN
|
||||
|
||||
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 20261017T071544Z | `scripts/dm governance check --repo .` | d64dbc3a15d2 | 0 | SELF | ok fixtures;[输出](evidence/governance-adoption/00-20261017T071544Z.log) sha256:b4f09e161961151b |
|
||||
| 20261017T071544Z | `scripts/agent-exec validate --repo .` | d64dbc3a15d2 | 0 | SELF | validate: ok;[输出](evidence/governance-adoption/01-20261017T071544Z.log) sha256:06f07c50e89b4bbf |
|
||||
| 20261017T071544Z | `python3 /tmp/gov-neg/adopt_cases.py .` | d64dbc3a15d2 | 0 | SELF | [verdict] ALL PASS cases=5;[输出](evidence/governance-adoption/02-20261017T071544Z.log) sha256:f74ae904b59d73e4 |
|
||||
@@ -0,0 +1,45 @@
|
||||
# IAM U3 账号管理与本人恢复前端 任务卡
|
||||
|
||||
任务主记录:本文件随 PR 描述维护。
|
||||
状态:已完成(合入前闭合);owner:weichen。PR:[Frontend PR #86](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/86)
|
||||
|
||||
## 基线与授权
|
||||
|
||||
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
|
||||
- branch_kind:standalone;initiative_id:无(跨仓任务 Agent-System-Dev `feature/iam-u3-account-directory-recovery` 的前端子交付)
|
||||
- R0:710ef836(分支创建时的 origin/main;分支早于本仓治理接入,原无任务卡,本卡在收口时补建);工作区:/home/weichen/projects/Agent-System-IAM-U3-Frontend-Combo/frontend(worktree);分支:feature/iam-u3-account-ui
|
||||
- 父任务信息(仅子任务填写):无
|
||||
- accepting_requirements:false
|
||||
- 需求分流:由 Dev U3 主任务承接
|
||||
- 授权与交付终点:用户已批准 U3 阶段 4–6 与 Frontend 组件阶段 7(HEAD `93ee723`);U4 合入后用户选择 merge_then_accept:同步 main、重跑证据、独立 Review、当前 HEAD CI 通过后合入,用户在部署上验收,问题另立修复。终点为本 PR 合入 main,再由 RDM/Dev 组合固定。
|
||||
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
目标:把 U3 账号界面移植到含 U4 职位权限 UI、账号链路修复(#94/#95)、密码登录与管理员临时重置(#107)、RDM 成员检索(#108)的 main 上,每项能力只保留一套实现(以 main 为基础补入 U3 缺少的行为),并按 U3 RDM 目录消费的命令合同补回成员/职责候选选择器。
|
||||
|
||||
非目标:不恢复 U3 登录 Key 标签页(main 已改为纯密码登录);不恢复 main 已删除的 rdm.duty.get 编辑器;不改 Backend/RDM/UI。
|
||||
|
||||
## Agent 实施收据
|
||||
|
||||
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| iam-u3-frontend-onto-main-r2 | .gitea/workflows/ci.yml、src/、test/ | implementation | cursor→antigravity→pi | 1(e80375762b4e) | cursor / not-observable(新进程,fresh_session=true) | success | d47c493(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-onto-main-run2.json |
|
||||
| iam-u3-frontend-rdm-selectors-r1 | src/、test/ | implementation | cursor→antigravity→pi | 1(16e6bae28677) | cursor / not-observable(新进程,fresh_session=true) | success | 72c941c(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-rdm-selectors-run.json |
|
||||
| iam-u3-frontend-ci-ui-sha-r1 | .gitea/workflows/ci.yml | repair | cursor→antigravity→pi | 1(3f39344cbd2e) | cursor / not-observable(新进程,fresh_session=true) | success | afd9e75(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-ci-ui-sha-run.json |
|
||||
|
||||
首个单元 r1 因 U3 分支缺 AGENT-EXECUTION.md 返回 invalid-config(未启动 worker);改为在 main 上建同步分支移植。`e4041b3` 以 `-s ours` 记录原分支 `bc9b17a` 历史,树等于 `afd9e75`。
|
||||
|
||||
## 同步记录
|
||||
|
||||
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|
||||
|---|---|---|---|
|
||||
| U4 后再同步 | 1650b4fff0163e48a752ecbd86a3c4b18771e287 | e4041b36b97c1d3ac335584644d906b0e8adf55c | 2026-10-18 |
|
||||
| 合入前 pr-check | 1650b4fff0163e48a752ecbd86a3c4b18771e287 | 080c63081642e5670c8c7ff2646905f5b398c807 | 2026-10-19(PR_CHECK_OK,落后 main 0) |
|
||||
|
||||
## 结果与验证
|
||||
|
||||
- 结果:每个冲突文件的取舍(worker 记录):api.ts 以 main 为基础,删除 U3 重复方法,redeem 改走 `/api/auth/recovery-grants/redeem`;AccountUsersPanel 保留 main 组件并补入 U3 的写后重读、刷新失败提示、游标重置、409/启停提示与凭据状态列;adminSections 补入 U3 恢复提案幂等重试、创建后重读与终态提示、迟到响应丢弃;LoginPage/AdminZone/MyAccountSettings/SettingsModal 用 main;RDM 选择器按 RDM U3 合同(mode/searchFields/docId/cursor,成员添加带 userId,职责指派/预览带 accountIds)补回。
|
||||
- 验证:主机在 `72c941c`(配对 UI `5c2b103`)上 `npx tsc -b`、`npx vitest run`(48 文件 565 测试)、`npm run build`、`node scripts/check-knip.mjs` 通过,日志 `/tmp/cleanup-20261018/render/u3-fe-72c941c-verify.log`(SELF)。当前 HEAD `e4041b3` 的 CI `ci / test-and-build` 与 `governance / governance` 通过(GATE)。
|
||||
- Review:全新只读子代理会话审阅 `1650b4f..72c941c`,阻塞项为 ci.yml 的 UI_SHA 仍是旧 `7c35a78`(复现 tsc 失败),已由 `afd9e75` 改为 `5c2b103` 并经 CI 证明;非阻塞记录:`accountCandidatePager` 的 `issuedFor` 共享闭包在迟到响应下可能丢游标;adminSections 任何 409 都回落为临时重置(main 既有行为)。
|
||||
- 验收:按 merge_then_accept,用户在部署上验收,问题另立修复。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 法规结构化引用前端呈现(U7) 任务卡
|
||||
|
||||
任务主记录:本文件随 PR 描述维护(PR 链接见下方“结果与验证”)。
|
||||
状态:已完成(合入前闭合);owner:weichen。PR:[Frontend PR #109](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/109)
|
||||
|
||||
## 基线与授权
|
||||
|
||||
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
|
||||
- branch_kind:standalone;initiative_id:无(跨仓大任务 Agent-System-Dev `feature/regulations-kb` 的 U7 前端子交付,Dev PR #614 登记)
|
||||
- R0(initial_main_sha):0459567(origin/main);工作区:/home/weichen/projects/Agent-System-Frontend-wt-feature-regulations-kb-citations(worktree);分支:feature/regulations-kb-citations
|
||||
- 父任务信息(仅子任务填写):无
|
||||
- accepting_requirements:false
|
||||
- 需求分流:verdict=JOIN(由 Dev 大任务承接);selected_initiative=Agent-System-Dev feature/regulations-kb;理由=计划 U7 前端范围
|
||||
- 授权与交付终点:owner 以 /goal 授权按法规知识库计划全部实施;终点为本 PR 合入 main,再由 RDM/Dev 组合钉住。
|
||||
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
目标:按 Dev 规格 `docs/superpowers/specs/2026-10-18-regulations-citation-contract.md` 第 5、6 节,在审计工作区新增“法规核验”树(`rdm.inspect.tree kind=regs`)与批/引用详情;驾驶舱阅读器把正文中的 `[cit-…]` 渲染为编号引用标记并可展开元数据与原文片段;未核验、无效与待复核状态清晰区分。
|
||||
|
||||
非目标:不改 RDM 写路径与命令;不改设计令牌;不做部署。
|
||||
|
||||
## Agent 实施收据
|
||||
|
||||
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| frontend-u7-regulation-citations | src/、test/ | implementation | cursor→antigravity→pi | 1(35d799203497) | cursor / not-observable(新进程,fresh_session=true) | success | 6a3f244(apply 快进,run 4cf12d61) | 否 | /tmp/regkb-u7fe-receipt.json |
|
||||
|
||||
## 同步记录
|
||||
|
||||
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|
||||
|---|---|---|---|---|
|
||||
| 启动 | 0459567 | 0459567 | 2026-10-18 | 一致 |
|
||||
| 验收前 | 0459567(origin/main) | 6a3f244 | 2026-10-18 | 一致,无需同步 |
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
|
||||
- 结果:新增 `src/regCitations.ts`(契约类型、`[cit-…]` 标记解析与编号、状态/过期/省略原因文案、样式档、审计树请求参数)与 `src/components/RegCitation.tsx`(编号标记、引用卡片、引用清单,折叠用 @ui Collapsible,正文与片段仅作文本渲染);审计工作区新增“法规核验”树(证据批→引用)与批/引用详情(状态、原因、腿状态、封存、引用元数据、受披露约束的片段);驾驶舱阅读器正文渲染引用标记与引用清单。只有 verified 且未过期显示为有效依据,其余状态与无效引用使用明确的未核验/无效样式。样式只用令牌(`src/reg-citations.css`)。
|
||||
- 验证:host 在临时 Dev 组合布局(modules/ui c918747)重跑 `bash /tmp/regkb-fe-verify.sh all`(SELF):vitest 37 文件 345 用例通过(含 design-guard 与新增 reg-citations 测试),knip 未用导出 50 ≤ 51,`tsc -b` 与 `vite build` 通过,日志 /tmp/regkb-u7fe-verify.log;`git diff --check` 通过。权威结论以 PR HEAD CI 与 Dev 组合 `make test` 为准。
|
||||
- Review:host 独立审阅实施 diff(未复用实施会话):未使用 dangerouslySetInnerHTML 与 `<details>`,外链仅 http(s),字段名与 RDM U6 JSON 一致;无阻塞项。
|
||||
- 影响与剩余项:数据由 RDM U7(`rdm.inspect.tree kind=regs`、`rdm.inspect.object`、`rdm.cockpit.node` citations)提供;RDM 未提供时界面为空态,不影响既有标签页。Dev 组合 pin 在 U8 推进。
|
||||
@@ -0,0 +1,19 @@
|
||||
command: scripts/dm governance check --repo .
|
||||
cwd: /home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption
|
||||
head: d64dbc3a15d29520402f0c97199cb88b3e4aa9af
|
||||
exit: 0
|
||||
utc: 20261017T071544Z
|
||||
---
|
||||
ok gitlink deps/development-guidelines
|
||||
ok gitlink deps/development-manager
|
||||
ok agent-execution
|
||||
ok file AGENTS.md
|
||||
ok file PROJECT.md
|
||||
ok file policy/guidelines-adoption.md
|
||||
ok file .task/templates/task-card.md
|
||||
ok executable scripts/agent-exec
|
||||
ok executable scripts/hooks/pre-commit
|
||||
ok executable scripts/dm
|
||||
ok executable scripts/task
|
||||
ok adoption status
|
||||
ok fixtures
|
||||
@@ -0,0 +1,7 @@
|
||||
command: scripts/agent-exec validate --repo .
|
||||
cwd: /home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption
|
||||
head: d64dbc3a15d29520402f0c97199cb88b3e4aa9af
|
||||
exit: 0
|
||||
utc: 20261017T071544Z
|
||||
---
|
||||
validate: ok
|
||||
@@ -0,0 +1,13 @@
|
||||
command: python3 /tmp/gov-neg/adopt_cases.py .
|
||||
cwd: /home/weichen/projects/Agent-System-Frontend-wt-feature-governance-adoption
|
||||
head: d64dbc3a15d29520402f0c97199cb88b3e4aa9af
|
||||
exit: 0
|
||||
utc: 20261017T071544Z
|
||||
---
|
||||
[object] Agent-System-Frontend-wt-feature-governance-adoption HEAD=d64dbc3a15d29520402f0c97199cb88b3e4aa9af
|
||||
PASS positive-run-apply: run exit=0 terminal=success apply exit=0 head=6fa967fff457
|
||||
PASS missing-config: run exit=2 terminal=invalid-config validate exit=2
|
||||
PASS bad-receipt: apply exit=4 head_unchanged=True msg=apply: no local integrity record: changed paths do not match the transport diff
|
||||
PASS out-of-scope-path: run exit=5 terminal=scope-expansion transport=False
|
||||
PASS unknown-side-effect: run exit=4 terminal=failed-unknown transport=False
|
||||
[verdict] ALL PASS cases=5
|
||||
@@ -0,0 +1,66 @@
|
||||
# <任务标题> 任务卡
|
||||
|
||||
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
|
||||
状态:<进行中 / 待审核 / 已合入 / 已放弃>;owner:<填写>。
|
||||
|
||||
## 基线与授权
|
||||
|
||||
- 权威仓:<仓 URL 或逻辑 ID>;root_target:main;immediate_target:<main 或 feature/initiative>
|
||||
- branch_kind:<initiative | child | standalone>;initiative_id:<稳定 ID,普通任务为空>
|
||||
- R0(initial_main_sha):<完整 SHA>;工作区:<路径>(worktree / clone);分支:<feature|fix|hotfix/...>
|
||||
- 父任务信息(仅子任务填写):parent_branch=<父分支>;parent_head_sha=<SHA>;parent_pr=<父 PR>
|
||||
- accepting_requirements:<true | false>(仅 initiative 可为 true;冻结后为 false)
|
||||
- 需求分流:verdict=<JOIN | NEW>;selected_initiative=<分支或无>;候选=<分支@HEAD 列表或无>;理由=<不适配/适配依据>;triage 收据=<源码外路径与摘要>
|
||||
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
|
||||
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
<预期结果、受影响入口/模块/契约;明确不做什么。>
|
||||
|
||||
## Agent 实施收据
|
||||
|
||||
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。
|
||||
|
||||
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
|
||||
|
||||
## 复用材料(task-start 自动检索结果摘录)
|
||||
|
||||
- 相关近期提交:<git log -- <paths> 命中;无则写无>。
|
||||
- 相关经验条目:<检索命中;无则写无>。
|
||||
|
||||
## 同步记录
|
||||
|
||||
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|
||||
|---|---|---|---|---|
|
||||
| 启动 | <R0> | <HEAD> | <日期时间> | <一致/已同步> |
|
||||
| 验收前 | <填写> | <填写> | <填写> | <填写> |
|
||||
|
||||
字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。
|
||||
|
||||
## 交付调度(有多个就绪任务时)
|
||||
|
||||
- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。
|
||||
- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。
|
||||
- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。
|
||||
- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。
|
||||
|
||||
定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
|
||||
- 结果:<交付了什么;未发生写无>。
|
||||
- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。
|
||||
- 影响与剩余项:<影响面、遗留风险、后续任务>。
|
||||
|
||||
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|
||||
|---|---|---|---|---|---|
|
||||
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
|
||||
|
||||
## 回收(task-finish)
|
||||
|
||||
- 合入核对:<分支已包含于目标分支的核对输出>。
|
||||
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
|
||||
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
|
||||
@@ -0,0 +1,67 @@
|
||||
# Project Agent Execution Configuration
|
||||
# 项目工程 Agent 执行路由配置
|
||||
|
||||
适用范围:定义本项目中由 AI / Agent 主导的代码实施、修复、测试与审阅所调用的 Agent CLI 路由。
|
||||
解析器识别正文中唯一一个 ```agent-execution 代码块。正文外的说明文字可根据项目需要调整。
|
||||
|
||||
通用硬规则见 Development-Guidelines 的 [Agent 执行与协作契约](deps/development-guidelines/standards/agent-execution.md)。
|
||||
本配置为项目本地路由权威,声明具体的候选 profile、尝试次数上限、超时、集成入口与豁免路径;
|
||||
代码块须通过 `deps/development-guidelines/templates/schemas/agent-execution.schema.json` 校验后才能登记为 active。
|
||||
`fallback_to_host` 只能为 `never`;旧值 `all-unavailable` 仅供迁移工具读取,不能通过准入。
|
||||
下例的 CLI 名称、命令与路径均为占位,按项目实际执行器替换。
|
||||
|
||||
```agent-execution
|
||||
schema: agent-execution/v1
|
||||
|
||||
execution:
|
||||
fresh_process_per_unit: true
|
||||
permission: run-everything
|
||||
fallback_to_host: never
|
||||
max_parallel: 3
|
||||
timeout_seconds: 7200
|
||||
idle_timeout_seconds: 600
|
||||
summary_bytes: 16384
|
||||
raw_output_bytes: 10485760
|
||||
max_attempts_per_profile: 3
|
||||
emergency_profile: codex
|
||||
|
||||
roles:
|
||||
implementation: [cursor, antigravity, pi]
|
||||
repair: [cursor, antigravity, pi]
|
||||
test: [pi, antigravity, cursor]
|
||||
review: [pi, antigravity, cursor, codex]
|
||||
|
||||
profiles:
|
||||
cursor:
|
||||
adapter: cursor
|
||||
executable: "cursor-agent-stable"
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
antigravity:
|
||||
adapter: antigravity
|
||||
executable: agy
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
pi:
|
||||
adapter: pi
|
||||
executable: pi
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
codex:
|
||||
adapter: codex
|
||||
executable: codex
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
integration:
|
||||
exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"]
|
||||
transport:
|
||||
command: "scripts/agent-exec apply"
|
||||
admission:
|
||||
command: "scripts/agent-exec admit"
|
||||
cleanup:
|
||||
command: "scripts/agent-exec cleanup"
|
||||
```
|
||||
@@ -35,3 +35,21 @@
|
||||
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||
|
||||
## Agent 执行与新项目准入(强制)
|
||||
|
||||
1. 缺 Guidelines 固定版本(`deps/development-guidelines` gitlink 未就绪或 HEAD 不符)时,Agent 只能只读分析,不得实施。
|
||||
2. 缺有效 `AGENT-EXECUTION.md`(`scripts/dm governance check` 未通过)时禁止 AI 实施。
|
||||
3. 缺共享执行器(`scripts/agent-exec` 或 `deps/development-manager` 不可用)时,状态为 `blocked/unavailable`,不得改由 host 替代。
|
||||
4. 不允许 host 代写实施单元;实施、修复与测试必须经 `AGENT-EXECUTION.md` 登记的候选执行。
|
||||
5. 人类直接开发按其自身授权与项目规则进行,不受本条款约束。
|
||||
6. 分支保护与规范 tag 由用户负责设置与维护,Agent 不得代为设置。
|
||||
|
||||
入口:
|
||||
|
||||
- 需求分流:`scripts/task triage discover|decide|verify`
|
||||
- 任务启动:`scripts/task start …`
|
||||
- PR 检查:`scripts/task pr-check …`
|
||||
- Agent 执行:`scripts/agent-exec run|apply|admit|validate`
|
||||
|
||||
AI 实施单元仅通过 `scripts/agent-exec run` 运行(依据 AGENT-EXECUTION.md),并经 `scripts/agent-exec apply` 集成。
|
||||
|
||||
@@ -10,4 +10,5 @@
|
||||
- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/(Dev Makefile 拷贝至 backend web/dist 内嵌)。
|
||||
- `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。
|
||||
- 开发:`npm run dev`(Vite 代理 /api → 127.0.0.1:8787)。
|
||||
- 治理:`scripts/agent-exec validate --repo .` — 校验 AGENT-EXECUTION.md 执行路由配置;`scripts/dm governance check --repo .` — 校验治理套件(Guidelines 固定版本、采纳记录与执行配置)。
|
||||
- 依赖刻意最小:react + marked + dompurify(模型输出与文档预览先清洗再写入页面);无 UI 框架(设计令牌手写,见 styles.css)。
|
||||
|
||||
+1
Submodule deps/development-guidelines added at 2688fd2be6
+1
Submodule deps/development-manager added at bd3a7de57a
@@ -66,10 +66,8 @@ test('管理员建号:UI 请求新契约并给出待初始化提示', async ({
|
||||
const body = await createBody
|
||||
expect(body).toEqual({ owner: newOwner, profile: { name: '组合验收' } })
|
||||
|
||||
// 契约断言二:成功提示逐字含「待初始化」与 CLI 发放指引。
|
||||
await expect(page.locator('.ad-notice')).toHaveText(
|
||||
`已创建 ${newOwner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`,
|
||||
)
|
||||
// 契约断言二:页面创建后账号可直接使用,不再提示 CLI 初始化。
|
||||
await expect(page.locator('.ad-notice')).toContainText(`已创建 ${newOwner}`)
|
||||
|
||||
// 契约断言三:用户表出现该账号,状态为未设密码(HTTP 建号不能设密码/授权限)。
|
||||
const row = page.locator('.ad-table tbody tr', { hasText: newOwner })
|
||||
@@ -78,6 +76,6 @@ test('管理员建号:UI 请求新契约并给出待初始化提示', async ({
|
||||
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')).toContainText('user initialize-account')
|
||||
await expect(page.locator('.ad-create .ad-hint')).toContainText('初始密码')
|
||||
await expect(page.locator('.ad-create .ad-hint')).not.toContainText('user initialize-account')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
// 统一渲染 §11 浏览器验收:真实产品(RDM 二进制 auth on + dist preview)走通九项。
|
||||
// 驱动方:/tmp/renderer-browser-acceptance/browser-acceptance.sh(种子写 $RBA_OUT/seed.env);
|
||||
// 未提供 PLAYWRIGHT_BASE_URL 或 RBA_OUT 时整组跳过。
|
||||
// 产品缺能力处用 expect.soft('GAP[仓] …') 记失败并继续取证;浏览器做不到、只能以本人会话
|
||||
// 直调命令的步骤记为 API_ASSISTED。跨用例状态写 $RBA_OUT/state.json(失败会重启 worker)。
|
||||
import { test, expect, type Browser, type Locator, type Page } from '@playwright/test'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const env = (k: string) => process.env[k] ?? ''
|
||||
const OUT = env('RBA_OUT')
|
||||
const EVID = OUT ? path.join(OUT, 'evidence') : ''
|
||||
const RUN = path.basename(OUT || 'run')
|
||||
const PW = env('RBA_PASSWORD')
|
||||
const U = { admin: env('RBA_ADMIN'), author: env('RBA_AUTHOR'), reviewer: env('RBA_REVIEWER'), approver: env('RBA_APPROVER'), outsider: env('RBA_OUTSIDER') }
|
||||
const A = { name: env('RBA_PROJECT_A'), pid: env('RBA_PID_A'), host: env('RBA_HOST_A'), doc: env('RBA_DOC_A'), title: env('RBA_DOC_A_TITLE') }
|
||||
const B = { name: env('RBA_PROJECT_B'), pid: env('RBA_PID_B'), host: env('RBA_HOST_B'), doc: env('RBA_DOC_B') }
|
||||
const UNCTRL = { doc: env('RBA_UNCTRL_DOC'), title: env('RBA_UNCTRL_TITLE') }
|
||||
const GENERAL = { id: env('RBA_GENERAL_PROFILE'), version: env('RBA_GENERAL_VERSION') }
|
||||
const TEMPLATE_PROFILE = `company.${env('RBA_TEMPLATE')}`
|
||||
const ANNOTATION = `§11 批注 ${RUN}:请补充目标量化指标`
|
||||
const RANGE_ANNOTATION = `§11 范围批注 ${RUN}:「目标」需可量化`
|
||||
const REVISION_AFTER = `§11 修订 ${RUN}:目标改为三项可量化指标`
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL || !OUT, 'set PLAYWRIGHT_BASE_URL and RBA_OUT (browser-acceptance.sh drives this suite)')
|
||||
test.describe.configure({ mode: 'default', timeout: 240_000 })
|
||||
|
||||
// ---- 证据与跨用例状态 ----
|
||||
|
||||
const STATE = () => path.join(OUT, 'state.json')
|
||||
const readState = (): Record<string, any> => { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')) } catch { return {} } }
|
||||
const writeState = (patch: Record<string, unknown>) => fs.writeFileSync(STATE(), JSON.stringify({ ...readState(), ...patch }, null, 2))
|
||||
|
||||
let evidence: string[] = []
|
||||
let notes: string[] = []
|
||||
let openSessions = new Set<() => Promise<void>>()
|
||||
const note = (s: string) => { notes.push(s) }
|
||||
|
||||
test.beforeEach(() => { evidence = []; notes = []; openSessions = new Set() })
|
||||
test.afterEach(async ({}, info) => {
|
||||
for (const close of [...openSessions]) await close().catch(() => {})
|
||||
const items = info.annotations.filter(a => a.type === 'item').map(a => a.description)
|
||||
const dir = path.join(EVID, 'results')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
const errors = info.errors.map(e => (e.message ?? String(e)).replace(/\u001b\[[0-9;]*m/g, '').slice(0, 1500))
|
||||
fs.writeFileSync(path.join(dir, `${info.testId}.json`), JSON.stringify({
|
||||
title: info.title, items, status: info.status, errors, evidence, notes,
|
||||
apiAssisted: info.annotations.filter(a => a.type === 'API_ASSISTED').map(a => a.description),
|
||||
durationMs: info.duration,
|
||||
}, null, 2))
|
||||
})
|
||||
|
||||
const items = (...ids: number[]) => { for (const id of ids) test.info().annotations.push({ type: 'item', description: String(id) }) }
|
||||
const apiAssisted = (what: string) => test.info().annotations.push({ type: 'API_ASSISTED', description: what })
|
||||
|
||||
async function shot(page: Page, name: string, target?: Locator) {
|
||||
const file = path.join(EVID, 'screens', `${name}.png`)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
if (target) await target.screenshot({ path: file })
|
||||
else await page.screenshot({ path: file, fullPage: false })
|
||||
evidence.push(file)
|
||||
}
|
||||
|
||||
interface NetEntry { url: string; method: string; status: number; request: string; body: string }
|
||||
|
||||
/** 每个会话一个上下文:主题经真实持久化入口 localStorage('as.ui') 注入;记录全部 /api 响应体。 */
|
||||
async function session(browser: Browser, owner: string, tag: string, opts: { theme?: 'light' | 'dark'; width?: number; height?: number } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width: opts.width ?? 1440, height: opts.height ?? 900 }, acceptDownloads: true, locale: 'zh-CN' })
|
||||
if (opts.theme) {
|
||||
await ctx.addInitScript(theme => {
|
||||
const prev = (() => { try { return JSON.parse(localStorage.getItem('as.ui') || '{}') } catch { return {} } })()
|
||||
localStorage.setItem('as.ui', JSON.stringify({ ...prev, theme }))
|
||||
}, opts.theme)
|
||||
}
|
||||
const page = await ctx.newPage()
|
||||
const net: NetEntry[] = []
|
||||
const pending: Promise<void>[] = []
|
||||
page.on('response', res => {
|
||||
const url = res.url()
|
||||
if (!url.includes('/api/') || (res.headers()['content-type'] ?? '').includes('event-stream')) return
|
||||
pending.push(Promise.race([res.text(), new Promise<string>(r => setTimeout(() => r('<<body timeout>>'), 5_000))])
|
||||
.catch(() => '<<no body>>')
|
||||
.then(body => { net.push({ url, method: res.request().method(), status: res.status(), request: res.request().postData() ?? '', body }) }))
|
||||
})
|
||||
const close = async () => {
|
||||
openSessions.delete(close)
|
||||
await Promise.all(pending)
|
||||
const file = path.join(EVID, 'network', `${tag}.jsonl`)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, net.map(n => JSON.stringify(n)).join('\n') + '\n')
|
||||
evidence.push(file)
|
||||
await ctx.close()
|
||||
}
|
||||
openSessions.add(close)
|
||||
await login(page, owner)
|
||||
return { page, net, pending, close }
|
||||
}
|
||||
|
||||
async function login(page: Page, owner: string) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(owner)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PW)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await page.locator('.tsb-tool', { hasText: '追溯审计' }).waitFor({ state: 'attached', timeout: 20_000 })
|
||||
}
|
||||
|
||||
/** 窄屏下侧栏收进「上下文」面板:先经工作台导航打开,再点审计入口。 */
|
||||
async function clickAuditEntry(page: Page) {
|
||||
const entry = page.locator('.tsb-tool', { hasText: '追溯审计' })
|
||||
if (!(await entry.isVisible())) await page.getByRole('button', { name: '打开上下文' }).click()
|
||||
await entry.click()
|
||||
}
|
||||
|
||||
async function openAudit(page: Page, projectName: string) {
|
||||
await clickAuditEntry(page)
|
||||
await expect(page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 })
|
||||
await page.locator('.rdm-audit-project').selectOption({ label: projectName })
|
||||
}
|
||||
|
||||
async function openDoc(page: Page, title: string) {
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
const open = page.getByRole('button', { name: `阅读与交付:${title}`, exact: true })
|
||||
for (let i = 0; i < 5 && !(await open.isVisible().catch(() => false)); i++) {
|
||||
const more = page.locator('.rdm-audit-more')
|
||||
if (await more.isVisible().catch(() => false)) await more.click()
|
||||
else await page.waitForTimeout(500)
|
||||
}
|
||||
await open.click()
|
||||
await expect(page.locator('.drw')).toBeVisible()
|
||||
await expect(page.locator('.drw-canvas-node').first()).toBeVisible({ timeout: 20_000 })
|
||||
}
|
||||
|
||||
const panel = (page: Page, label: string) =>
|
||||
page.locator('.drw-panel').filter({ has: page.locator('.ui-seclabel', { hasText: new RegExp(`^${label}$`) }) })
|
||||
|
||||
const deliveryOf = (page: Page, mode: '预览' | '审阅' | '批准') =>
|
||||
page.locator('.drw-delivery').filter({ has: page.locator('.ui-chip', { hasText: new RegExp(`^${mode}$`) }) }).first()
|
||||
|
||||
/** 以本人浏览器会话直调命令(与前端同一 /api/commands 通道、同一 cookie)。 */
|
||||
async function command(page: Page, id: string, params: Record<string, unknown>, source?: string) {
|
||||
const [conversationId, anchorMessageId] = (source ?? '').split(' ')
|
||||
const res = await page.request.post(`/api/commands/${id}`, {
|
||||
data: { params, ...(source ? { entrance: 'api', conversationId, anchorMessageId } : {}) },
|
||||
})
|
||||
const text = await res.text()
|
||||
let body: any = null
|
||||
try { body = JSON.parse(text) } catch { /* 非 JSON 原样保留 */ }
|
||||
const file = path.join(EVID, 'api', `${test.info().testId}-${id}.json`)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, JSON.stringify({ id, params, status: res.status(), body: body ?? text }, null, 2))
|
||||
evidence.push(file)
|
||||
return { status: res.status(), text, body }
|
||||
}
|
||||
|
||||
async function download(page: Page, delivery: Locator, label: string) {
|
||||
const button = delivery.getByRole('button', { name: label, exact: true })
|
||||
await expect(button, `下载入口「${label}」`).toBeVisible()
|
||||
const [dl] = await Promise.all([page.waitForEvent('download', { timeout: 30_000 }), button.click()])
|
||||
const name = dl.suggestedFilename()
|
||||
const file = path.join(EVID, 'downloads', name)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
await dl.saveAs(file)
|
||||
evidence.push(file)
|
||||
return { name, file, size: fs.statSync(file).size }
|
||||
}
|
||||
|
||||
async function expectReadableFailures(page: Page) {
|
||||
for (const msg of await page.locator('.drw-failure-msg').allTextContents()) {
|
||||
expect(msg.trim().startsWith('{'), `失败文案不得是原始 JSON:${msg}`).toBe(false)
|
||||
}
|
||||
}
|
||||
|
||||
const parseJson = (text: string): any => { try { return JSON.parse(text) } catch { return null } }
|
||||
|
||||
/**
|
||||
* 工作区整文决定的取证:先有同会话的已提交来源消息(POST /messages 响应),命令再以它为顶层锚点分发,
|
||||
* params 不含来源字段,入口为页面动作;回执必须 committed。
|
||||
*/
|
||||
async function expectCommittedDecision(s: { net: NetEntry[] }, command: string, label: string) {
|
||||
const cmdUrl = `/api/commands/${command}`
|
||||
await expect.poll(() => s.net.some(n => n.url.includes(cmdUrl)), { message: `应分发 ${command}`, timeout: 15_000 }).toBe(true)
|
||||
const dispatch = s.net.filter(n => n.url.includes(cmdUrl)).at(-1)!
|
||||
const body = parseJson(dispatch.request)
|
||||
expect(body.params, '命令参数不携带来源字段').not.toHaveProperty('anchorMessageId')
|
||||
expect(body.entrance, '工作区动作入口').toBe('button')
|
||||
const findSource = () => s.net.find(n => n.method === 'POST'
|
||||
&& n.url.includes(`/api/conversations/${body.conversationId}/messages`)
|
||||
&& parseJson(n.body)?.message?.id === body.anchorMessageId)
|
||||
await expect.poll(() => findSource() !== undefined, { message: '锚点必须是同会话已提交的来源消息', timeout: 15_000 }).toBe(true)
|
||||
expect(parseJson(findSource()!.request)?.text, '来源消息写明中文审签决定').toContain(`审签决定:${label}`)
|
||||
expect(parseJson(dispatch.body)?.receipt?.state, `${command} 回执 committed`).toBe('committed')
|
||||
}
|
||||
|
||||
// ---- §11 ----
|
||||
|
||||
test('§11-1 文档树 → 结构画布与阅读视图(共用选中与过滤)', async ({ browser }) => {
|
||||
items(1)
|
||||
const s = await session(browser, U.author, 'item1-author')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
const docRow = page.locator('.rdm-audit-tree-row').filter({ hasText: A.title })
|
||||
await expect(docRow).toBeVisible({ timeout: 15_000 })
|
||||
await docRow.locator('.rdm-audit-tree-node').click()
|
||||
await expect(page.locator('.rdm-audit-tree-children').first()).toBeVisible({ timeout: 15_000 })
|
||||
await shot(page, 'item1-doc-tree')
|
||||
await openDoc(page, A.title)
|
||||
|
||||
const nodes = page.locator('.drw-canvas-node')
|
||||
const total = await nodes.count()
|
||||
note(`结构画布节点数 ${total}`)
|
||||
expect(total, '受控文档应展开为多节点结构').toBeGreaterThan(10)
|
||||
const blank = (await page.locator('.drw-canvas-title').allTextContents()).filter(t => !t.trim()).length
|
||||
expect(blank, '画布不得出现无标题的空白节点').toBe(0)
|
||||
await shot(page, 'item1-canvas')
|
||||
const target = page.locator('.drw-canvas-node[title="项目内容、目标及意义"]').first()
|
||||
await target.click()
|
||||
await expect(target).toHaveAttribute('aria-selected', 'true')
|
||||
await page.getByLabel('过滤节点').fill('目标')
|
||||
await page.getByRole('tab', { name: '阅读视图' }).click()
|
||||
await expect(page.locator('.drw-reader')).toBeVisible()
|
||||
await expect(page.locator('.drw-reader-title[aria-current="true"]')).toHaveText('项目内容、目标及意义')
|
||||
await expect(page.getByLabel('过滤节点')).toHaveValue('目标')
|
||||
await shot(page, 'item1-reader-filtered')
|
||||
await page.getByLabel('过滤节点').fill('')
|
||||
await page.getByRole('tab', { name: '结构画布' }).click()
|
||||
await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toHaveAttribute('title', '项目内容、目标及意义')
|
||||
// 键盘导航
|
||||
await page.locator('.drw-canvas-node[aria-selected="true"]').focus()
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toBeFocused()
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-2b 规则绑定:项目设置权限可绑定与解绑(非受控文档)', async ({ browser }) => {
|
||||
items(2)
|
||||
const s = await session(browser, U.admin, 'item2b-admin')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, UNCTRL.title)
|
||||
const binding = panel(page, '规则绑定')
|
||||
await expect(binding, '具备项目设置权限的用户可见规则绑定区').toBeVisible({ timeout: 20_000 })
|
||||
await page.locator('.drw-bind-rule', { hasText: `${GENERAL.id}@${GENERAL.version}` }).locator('input').check()
|
||||
await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '绑定所选规则', exact: true }).click()
|
||||
await expect(panel(page, '规则绑定').getByText('绑定规则已完成')).toBeVisible({ timeout: 30_000 })
|
||||
await expectReadableFailures(page)
|
||||
await expect(panel(page, '渲染规则').locator('.drw-profile-source', { hasText: '文档规则' }), '绑定后规则列表刷新出文档规则').toBeVisible({ timeout: 15_000 })
|
||||
await shot(page, 'item2b-bound', binding)
|
||||
await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '解绑', exact: true }).click()
|
||||
await expect(panel(page, '规则绑定').getByText('解绑规则已完成')).toBeVisible({ timeout: 30_000 })
|
||||
await expectReadableFailures(page)
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-3b 受控文档拒绝绑定通用规则,失败以可读文案显示', async ({ browser }) => {
|
||||
items(3)
|
||||
const s = await session(browser, U.admin, 'item3b-admin')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const binding = panel(page, '规则绑定')
|
||||
await expect(binding, '具备项目设置权限的用户可见规则绑定区').toBeVisible({ timeout: 20_000 })
|
||||
await page.locator('.drw-bind-rule', { hasText: `${GENERAL.id}@${GENERAL.version}` }).locator('input').check()
|
||||
await page.locator('.drw-bind-row', { hasText: '本文档' }).getByRole('button', { name: '绑定所选规则', exact: true }).click()
|
||||
await expect(binding.locator('.drw-failure-msg')).toContainText('受控文档', { timeout: 30_000 })
|
||||
await expect(binding.locator('.drw-failure-msg')).not.toContainText('controlled_requires_exact_profile')
|
||||
await shot(page, 'item3b-controlled-refusal', binding)
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-3 受控文档:规则来源、版本与不回退说明', async ({ browser }) => {
|
||||
items(3)
|
||||
const s = await session(browser, U.author, 'item3-author')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const rules = panel(page, '渲染规则')
|
||||
const option = rules.locator('.drw-profile').first()
|
||||
await expect(option).toBeVisible({ timeout: 15_000 })
|
||||
await expect(option.locator('.drw-profile-source')).toHaveText('模板规则')
|
||||
await expect(option.locator('.drw-profile-id')).toHaveText(TEMPLATE_PROFILE)
|
||||
await expect(option.locator('.drw-profile-ver')).toHaveText(`v${env('RBA_TEMPLATE_VERSION')}`)
|
||||
// 44 个 DHF 专用规则均已发布(state=published、publicationAllowed=true):状态为已发布,可正式交付,无未发布原因。
|
||||
await expect(option.locator('.ui-chip').first()).toHaveText('已发布')
|
||||
await expect(option.locator('.ui-chip', { hasText: '可正式交付' })).toBeVisible()
|
||||
await expect(option.locator('input[type="radio"]')).toBeEnabled()
|
||||
await expect(option.locator('.drw-profile-reason')).toHaveCount(0)
|
||||
await expect(rules.locator('.drw-notice')).toContainText('不会回退到项目或公司默认规则')
|
||||
await expect(rules.locator('.drw-failure-msg')).toContainText('不得回退到项目或公司默认规则')
|
||||
await expectReadableFailures(page)
|
||||
await shot(page, 'item3-rules-controlled', rules)
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-2 专用规则与公司通用规则可选', async ({ browser }) => {
|
||||
items(2)
|
||||
const s = await session(browser, U.author, 'item2-author')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const rules = panel(page, '渲染规则')
|
||||
await expect(rules.locator('.drw-profile').first()).toBeVisible({ timeout: 15_000 })
|
||||
const sources = await rules.locator('.drw-profile-source').allTextContents()
|
||||
note(`受控文档可选规则来源:${sources.join('、')}`)
|
||||
expect(sources.some(x => x === '模板规则' || x === '文档类型规则' || x === '文档规则'), '应提供专用(模板/文档类型)规则').toBe(true)
|
||||
await shot(page, 'item2-controlled-rules', rules)
|
||||
|
||||
await page.getByRole('button', { name: '← 返回文档树' }).click()
|
||||
await openDoc(page, UNCTRL.title)
|
||||
const rulesU = panel(page, '渲染规则')
|
||||
await expect(rulesU.locator('.drw-profile').or(rulesU.locator('.drw-failure')).or(rulesU.getByText('没有可用的渲染规则')).first()).toBeVisible({ timeout: 15_000 })
|
||||
const uSources = await rulesU.locator('.drw-profile-source').allTextContents()
|
||||
note(`非受控文档可选规则来源:${uSources.join('、') || '(无)'};拒绝:${(await rulesU.locator('.drw-failure-msg').allTextContents()).join(' | ')}`)
|
||||
await shot(page, 'item2-uncontrolled-rules', rulesU)
|
||||
expect.soft(uSources.includes('公司默认规则'),
|
||||
`GAP[RDM] 已发布的公司通用规则 ${GENERAL.id}@${GENERAL.version} 不出现在非受控文档的规则列表:rdm.render.profiles 只读 rdm_render_profile_bindings,而产品无任何写绑定命令(organization-default/project-default/document-type 绑定不可达)`).toBe(true)
|
||||
expect.soft(sources.length + uSources.length >= 2 && uSources.length >= 1,
|
||||
'GAP[RDM] 浏览器内无法在同一文档上于专用规则与公司通用规则之间选择(专用规则只到 validated;通用规则无绑定)').toBe(true)
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-4 生成预览并把诊断定位到节点', async ({ browser }) => {
|
||||
items(4)
|
||||
const s = await session(browser, U.author, 'item4-author')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const generate = page.getByRole('button', { name: '生成预览', exact: true })
|
||||
await expect(generate).toBeEnabled({ timeout: 15_000 })
|
||||
await generate.click()
|
||||
const preview = panel(page, '预览与提交')
|
||||
await expect(preview.locator('.drw-receipt').or(preview.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 90_000 })
|
||||
await expectReadableFailures(page)
|
||||
await shot(page, 'item4-preview-receipt')
|
||||
await expect(preview.locator('.drw-op .drw-failure'), '预览不应失败').toHaveCount(0)
|
||||
const receipt = preview.locator('.drw-receipt')
|
||||
await expect(receipt.locator('.ui-chip').first()).toHaveText('已生成', { timeout: 60_000 })
|
||||
await expect(receipt.getByText('诊断读取中')).toHaveCount(0, { timeout: 60_000 })
|
||||
await expect(receipt.locator('.drw-diags').or(receipt.getByText('没有诊断问题')).or(receipt.locator('.drw-failure')).first()).toBeVisible()
|
||||
await expectReadableFailures(page)
|
||||
const summary = await receipt.locator('.drw-muted').first().textContent()
|
||||
const located = receipt.locator('button.drw-diag-node')
|
||||
const locatedCount = await located.count()
|
||||
const unlocated = await receipt.locator('.drw-diag-node.unlocated').count()
|
||||
note(`诊断摘要:${summary};可定位 ${locatedCount} 条(首屏),未定位 ${unlocated} 条`)
|
||||
await shot(page, 'item4-diagnostics', preview)
|
||||
await expect(deliveryOf(page, '预览')).toBeVisible({ timeout: 15_000 })
|
||||
expect.soft(locatedCount,
|
||||
'GAP[Renderer] 渲染器生产代码从不产出 warning/info 诊断、也不填 Diagnostic.NodeID(仅失败时产出无节点的 error);GAP[RDM] 受控 DHF 实例 172 个节点全为空/未开始,预览 diagnostics 为 null——无可定位到节点的诊断').toBeGreaterThan(0)
|
||||
if (!locatedCount) { await s.close(); return }
|
||||
const first = located.first()
|
||||
const title = (await first.getAttribute('title')) ?? ''
|
||||
await first.click()
|
||||
await expect(page.locator('.drw-canvas-node[aria-selected="true"]')).toHaveAttribute('title', title)
|
||||
const counted = receipt.locator('.drw-diag.sev-error button.drw-diag-node, .drw-diag.sev-warning button.drw-diag-node')
|
||||
if (await counted.count()) {
|
||||
const t = (await counted.first().getAttribute('title')) ?? ''
|
||||
await expect(page.locator(`.drw-canvas-node[title="${t.replace(/"/g, '\\"')}"] .drw-badge`).first()).toBeVisible()
|
||||
}
|
||||
await shot(page, 'item4-diagnostic-located-canvas')
|
||||
await page.getByRole('tab', { name: '阅读视图' }).click()
|
||||
const section = page.locator('section.drw-reader-node.sel')
|
||||
await expect(section.locator('.drw-reader-title')).toHaveText(title)
|
||||
await expect(section.locator('a.drw-anchor')).toHaveAttribute('href', `#${await section.getAttribute('id')}`)
|
||||
await shot(page, 'item4-diagnostic-located-reader')
|
||||
await expect(deliveryOf(page, '预览')).toBeVisible({ timeout: 15_000 })
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-5a 编制人提交审阅', async ({ browser }) => {
|
||||
items(5)
|
||||
const s = await session(browser, U.author, 'item5a-author')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const submit = page.getByRole('button', { name: '提交审阅', exact: true })
|
||||
await expect(submit).toBeVisible({ timeout: 15_000 })
|
||||
await shot(page, 'item5a-submit-state', panel(page, '预览与提交'))
|
||||
const uiSubmit = await submit.isEnabled()
|
||||
note(`浏览器「提交审阅」可用:${uiSubmit};原因:${await submit.getAttribute('title')}`)
|
||||
expect.soft(uiSubmit,
|
||||
'GAP[RDM] 受控文档的专用模板规则只能经 rdm.render.validate 到 validated,产品无发布路径(company.* 为保留前缀,candidate.publish 不绑定作用域),rdm.render.submit 要求 published → 浏览器无法提交审阅').toBe(true)
|
||||
let submissionId = ''
|
||||
let round = 0
|
||||
if (uiSubmit) {
|
||||
await submit.click()
|
||||
await expect(panel(page, '预览与提交').getByText('提交审阅已完成')).toBeVisible({ timeout: 60_000 })
|
||||
} else {
|
||||
apiAssisted(`rdm.render.submit 以编制人会话显式指定已发布的公司通用规则 ${GENERAL.id}@${GENERAL.version}`)
|
||||
const r = await command(page, 'rdm.render.submit', {
|
||||
projectId: A.pid, docId: A.doc, operationId: `rba-submit-${RUN}`, profileId: GENERAL.id, profileVersion: GENERAL.version,
|
||||
})
|
||||
note(`API 提交回执:${r.text.slice(0, 300)}`)
|
||||
expect.soft(r.body?.receipt?.state,
|
||||
`BLOCKER[RDM] rdm.render.submit 被拒(${r.body?.receipt?.reason ?? r.status}):trustedRenderActor → LookupAccountDirectoryOwner(purpose=document-duties) 需要 RDM 注册 AccountDirectoryScopeResolver,而 RDM d63f1db 从未调用 RegisterAccountDirectoryScopeResolver,Backend 恒返回 ErrAccountForbidden;submit/annotate/revise 在真实产品中全部不可用`).toBe('committed')
|
||||
}
|
||||
const status = await command(page, 'rdm.review.doc.status', { projectId: A.pid, docId: A.doc })
|
||||
submissionId = status.body?.payload?.submission?.submissionId ?? ''
|
||||
round = status.body?.payload?.submission?.round ?? 0
|
||||
writeState({ submissionId, round })
|
||||
expect(submissionId, '提交后 rdm.review.doc.status 返回提交编号').not.toBe('')
|
||||
await page.reload()
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
await expect(panel(page, '审阅').locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 15_000 })
|
||||
await shot(page, 'item5a-submitted')
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-5b 审核人添加批注与修订', async ({ browser }) => {
|
||||
items(5)
|
||||
const { submissionId } = readState()
|
||||
const s = await session(browser, U.reviewer, 'item5b-reviewer')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const review = panel(page, '审阅')
|
||||
if (!submissionId) {
|
||||
await expect(review.getByText('暂无审阅提交')).toBeVisible({ timeout: 20_000 })
|
||||
await shot(page, 'item5b-reviewer-no-submission')
|
||||
expect(submissionId, 'BLOCKER[RDM] 无渲染审阅提交(见 §11-5a),审核人无法添加批注与修订').not.toBe('')
|
||||
}
|
||||
await expect(review.locator('.drw-mono'), '审核人在新会话中看到编制人的提交').toHaveAttribute('title', submissionId, { timeout: 20_000 })
|
||||
await page.locator('.drw-canvas-node[title="项目内容、目标及意义"]').first().click()
|
||||
await expect(review.locator('.drw-composer-node')).toHaveText('项目内容、目标及意义')
|
||||
await review.getByLabel('批注内容').fill(ANNOTATION)
|
||||
await review.getByRole('button', { name: '添加批注' }).click()
|
||||
await expect(review.locator('.drw-anno-text', { hasText: ANNOTATION })).toBeVisible({ timeout: 30_000 })
|
||||
await review.getByLabel('审阅动作').selectOption('revise')
|
||||
await review.getByLabel('修订类型').selectOption('replace')
|
||||
await review.getByLabel('修订前').fill('项目目标')
|
||||
await review.getByLabel('修订后').fill(REVISION_AFTER)
|
||||
await review.getByRole('button', { name: '提交修订' }).click()
|
||||
await expect(review.locator('.drw-diff-add', { hasText: REVISION_AFTER })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(review.locator('.drw-anno-group .drw-anno-node').first()).toHaveText('项目内容、目标及意义')
|
||||
await expect(page.locator('.drw-canvas-node[title="项目内容、目标及意义"] .drw-badge.review').first()).toBeVisible()
|
||||
|
||||
// 范围批注(06 §2):阅读视图选中节点正文「目标」(码点 5..7),载荷带偏移与摘录,不带 textHash/range。
|
||||
await page.getByRole('tab', { name: '阅读视图' }).click()
|
||||
const nodeText = page.locator('section.drw-reader-node.sel [data-anchor-node]')
|
||||
await expect(nodeText).toHaveText('项目内容、目标及意义')
|
||||
await nodeText.evaluate(el => {
|
||||
const range = document.createRange()
|
||||
range.setStart(el.firstChild!, 5)
|
||||
range.setEnd(el.firstChild!, 7)
|
||||
const sel = window.getSelection()!
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
||||
})
|
||||
await expect(review.locator('.drw-composer-quote')).toHaveText('「目标」')
|
||||
await review.getByLabel('批注内容').fill(RANGE_ANNOTATION)
|
||||
await review.getByRole('button', { name: '添加批注' }).click()
|
||||
const annotateCalls = () => s.net.filter(n => n.url.includes('/api/commands/rdm.render.review.annotate') && parseJson(n.request)?.params?.text === RANGE_ANNOTATION)
|
||||
await expect.poll(() => annotateCalls().length, { message: '应分发范围批注', timeout: 30_000 }).toBeGreaterThan(0)
|
||||
const rangeCall = annotateCalls().at(-1)!
|
||||
const rangeParams = parseJson(rangeCall.request)?.params
|
||||
expect(rangeParams, '范围批注载荷').toMatchObject({ textStart: 5, textEnd: 7, quote: '目标' })
|
||||
expect(rangeParams).not.toHaveProperty('textHash')
|
||||
expect(rangeParams).not.toHaveProperty('range')
|
||||
note(`范围批注回执:${rangeCall.body.slice(0, 300)}`)
|
||||
const rangeItem = review.locator('.drw-anno', { hasText: RANGE_ANNOTATION })
|
||||
const anchored = await rangeItem.locator('.drw-anno-quote').waitFor({ timeout: 30_000 }).then(() => true, () => false)
|
||||
if (anchored) await expect(rangeItem.locator('.drw-anno-quote')).toHaveText('目标')
|
||||
expect.soft(anchored, `GAP[RDM] 范围批注未按所选文字保存(回执:${rangeCall.body.slice(0, 200)})`).toBe(true)
|
||||
await page.getByRole('tab', { name: '结构画布' }).click()
|
||||
|
||||
await expectReadableFailures(page)
|
||||
await shot(page, 'item5b-review-annotations')
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-5c 审核通过与批准(签署)', async ({ browser }) => {
|
||||
items(5)
|
||||
const { submissionId } = readState()
|
||||
const r = await session(browser, U.reviewer, 'item5c-reviewer')
|
||||
await openAudit(r.page, A.name)
|
||||
await openDoc(r.page, A.title)
|
||||
const reviewPanel = panel(r.page, '审阅')
|
||||
if (!submissionId) {
|
||||
await expect(reviewPanel.getByText('暂无审阅提交')).toBeVisible({ timeout: 20_000 })
|
||||
expect.soft(await r.page.getByRole('button', { name: /审核通过|驳回|批准|签署|定稿/ }).count(),
|
||||
'GAP[Frontend] 文档工作区没有审核通过/驳回/批准入口(rdm.review.doc.approve/final 需会话来源锚点,只能经对话代理)').toBeGreaterThan(0)
|
||||
await shot(r.page, 'item5c-reviewer-no-submission', reviewPanel)
|
||||
expect(submissionId, 'BLOCKER[RDM] 无渲染审阅提交(见 §11-5a),审核与批准签署无从进行').not.toBe('')
|
||||
}
|
||||
await expect(reviewPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
|
||||
// 整文审签只走工作区入口:审核人待审时必须出现「审核通过」;成功与可读失败都必须可见。
|
||||
const decisionPanel = panel(r.page, '整文审签')
|
||||
const uiApprove = decisionPanel.getByRole('button', { name: '审核通过', exact: true })
|
||||
await expect(uiApprove, 'GAP[Frontend] 审核人在待审状态下工作区没有「审核通过」入口').toBeVisible({ timeout: 20_000 })
|
||||
await uiApprove.click()
|
||||
const done = decisionPanel.getByText('整文审核通过已完成')
|
||||
const failed = decisionPanel.locator('.drw-failure-msg')
|
||||
await expect(done.or(failed).first()).toBeVisible({ timeout: 60_000 })
|
||||
expect.soft(await done.count(), `审核通过应成功:${(await failed.allTextContents()).join(' | ')}`).toBe(1)
|
||||
await expectReadableFailures(r.page)
|
||||
await expectCommittedDecision(r, 'rdm.review.doc.approve', '审核通过')
|
||||
await shot(r.page, 'item5c-reviewer-approved', decisionPanel)
|
||||
await r.close()
|
||||
|
||||
const a = await session(browser, U.approver, 'item5c-approver')
|
||||
await openAudit(a.page, A.name)
|
||||
await openDoc(a.page, A.title)
|
||||
const approverPanel = panel(a.page, '审阅')
|
||||
await expect(approverPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
|
||||
// 终批入口只在批准人且全员已审核通过时出现;载荷绑定界面所示轮次。
|
||||
const finalPanel = panel(a.page, '整文审签')
|
||||
const uiFinal = finalPanel.getByRole('button', { name: '终批', exact: true })
|
||||
await expect(uiFinal, 'GAP[Frontend] 批准人在全员审核通过后工作区没有「终批」入口').toBeVisible({ timeout: 20_000 })
|
||||
await uiFinal.click()
|
||||
const finalDone = finalPanel.getByText('整文终批已完成')
|
||||
const finalFailed = finalPanel.locator('.drw-failure-msg')
|
||||
await expect(finalDone.or(finalFailed).first()).toBeVisible({ timeout: 60_000 })
|
||||
expect.soft(await finalDone.count(), `终批应成功:${(await finalFailed.allTextContents()).join(' | ')}`).toBe(1)
|
||||
await expectReadableFailures(a.page)
|
||||
await expectCommittedDecision(a, 'rdm.review.doc.final', '终批')
|
||||
await a.page.reload()
|
||||
await openAudit(a.page, A.name)
|
||||
await openDoc(a.page, A.title)
|
||||
const decisions = panel(a.page, '审阅').locator('.drw-decision')
|
||||
await expect(decisions.first()).toBeVisible({ timeout: 20_000 })
|
||||
const decisionTexts = await decisions.allTextContents()
|
||||
note(`签署记录:${decisionTexts.join(' | ')}`)
|
||||
expect(decisionTexts.length, '审阅面板显示审核与批准两条签署').toBeGreaterThanOrEqual(2)
|
||||
await shot(a.page, 'item5c-decisions', panel(a.page, '审阅'))
|
||||
await a.close()
|
||||
})
|
||||
|
||||
test('§11-6 下载 Markdown、批准稿、审阅留痕稿与完整包', async ({ browser }) => {
|
||||
items(6)
|
||||
const { submissionId } = readState()
|
||||
if (!submissionId) {
|
||||
// 无渲染提交:只能取证编制人预览交付的 Markdown/DOCX/清单下载,批准稿与留痕稿记为阻断
|
||||
const s = await session(browser, U.author, 'item6-author-preview')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const preview = deliveryOf(page, '预览')
|
||||
await expect(preview).toBeVisible({ timeout: 20_000 })
|
||||
const md = await download(page, preview, 'Markdown')
|
||||
expect(md.name).toMatch(/\.md$/)
|
||||
const docx = await download(page, preview, '预览 DOCX')
|
||||
expect(fs.readFileSync(docx.file).subarray(0, 2).toString()).toBe('PK')
|
||||
const manifest = await download(page, preview, '清单(manifest)')
|
||||
note(`预览交付下载:${[md, docx, manifest].map(f => `${f.name}(${f.size} B)`).join(';')}`)
|
||||
writeState({ downloads: [md, docx, manifest].map(f => f.name) })
|
||||
await shot(page, 'item6-preview-downloads', panel(page, '交付记录'))
|
||||
expect.soft(await deliveryOf(page, '批准').count(), 'BLOCKER[RDM] 无渲染提交 → 无批准交付,批准稿不可下载').toBeGreaterThan(0)
|
||||
expect.soft(await page.getByRole('button', { name: '审阅留痕 DOCX', exact: true }).count(), 'BLOCKER[RDM] 无渲染提交 → 无审阅交付,审阅留痕稿不可下载').toBeGreaterThan(0)
|
||||
const pkg = await download(page, preview, '完整交付包(ZIP)')
|
||||
expect(pkg.name).toMatch(/\.zip$/)
|
||||
expect(fs.readFileSync(pkg.file).subarray(0, 2).toString()).toBe('PK')
|
||||
await s.close()
|
||||
return
|
||||
}
|
||||
const s = await session(browser, U.approver, 'item6-approver')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
const reviewPanel = panel(page, '审阅')
|
||||
await expect(reviewPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
|
||||
const ok = panel(page, '预览与提交')
|
||||
await reviewPanel.getByRole('button', { name: '生成批准稿' }).click()
|
||||
await expect(ok.getByText('生成批准稿已完成').or(ok.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 120_000 })
|
||||
await expectReadableFailures(page)
|
||||
await expect(ok.locator('.drw-op .drw-failure'), '生成批准稿不应失败').toHaveCount(0)
|
||||
await reviewPanel.getByRole('button', { name: '生成留痕稿' }).click()
|
||||
await expect(ok.getByText('生成留痕稿已完成').or(ok.locator('.drw-op .drw-failure')).first()).toBeVisible({ timeout: 120_000 })
|
||||
await expect(ok.locator('.drw-op .drw-failure'), '生成留痕稿不应失败').toHaveCount(0)
|
||||
const approved = deliveryOf(page, '批准')
|
||||
const reviewed = deliveryOf(page, '审阅')
|
||||
await expect(approved).toBeVisible({ timeout: 20_000 })
|
||||
await expect(reviewed).toBeVisible({ timeout: 20_000 })
|
||||
await shot(page, 'item6-deliveries', panel(page, '交付记录'))
|
||||
const kinds = { approved: await approved.locator('.drw-download').allTextContents(), review: await reviewed.locator('.drw-download').allTextContents() }
|
||||
note(`批准交付可下载:${kinds.approved.join('、')};审阅交付可下载:${kinds.review.join('、')}`)
|
||||
writeState({ deliveryKinds: kinds })
|
||||
|
||||
const md = await download(page, approved, 'Markdown')
|
||||
expect(md.name).toMatch(/\.md$/)
|
||||
expect(fs.readFileSync(md.file, 'utf8').length).toBeGreaterThan(50)
|
||||
const docx = await download(page, approved, '批准稿 DOCX')
|
||||
expect(docx.name).toMatch(/\.docx$/)
|
||||
expect(fs.readFileSync(docx.file).subarray(0, 2).toString()).toBe('PK')
|
||||
const traceHolder = (await reviewed.getByRole('button', { name: '审阅留痕 DOCX', exact: true }).count()) ? reviewed : approved
|
||||
const trace = await download(page, traceHolder, '审阅留痕 DOCX')
|
||||
expect(trace.name).toMatch(/\.docx$/)
|
||||
expect(fs.readFileSync(trace.file).subarray(0, 2).toString()).toBe('PK')
|
||||
const manifest = await download(page, approved, '清单(manifest)')
|
||||
note(`下载:${[md, docx, trace, manifest].map(f => `${f.name}(${f.size} B)`).join(';')}`)
|
||||
// 长文件名:显示截断,title 保留完整文件名(§11-9 一并取证)
|
||||
const lastName = panel(page, '交付记录').locator('.drw-filename').first()
|
||||
await expect(lastName).toHaveAttribute('title', manifest.name)
|
||||
const shown = (await lastName.textContent()) ?? ''
|
||||
note(`文件名显示「${shown}」,完整「${manifest.name}」`)
|
||||
if (manifest.name.length > 36) expect(shown).toContain('…')
|
||||
await shot(page, 'item6-downloaded-longname', panel(page, '交付记录'))
|
||||
writeState({ downloads: [md, docx, trace, manifest].map(f => f.name) })
|
||||
// 完整包:rdm.render.download kind=package,服务端确定性 ZIP;与单件下载同一鉴权路径。
|
||||
const pkg = await download(page, approved, '完整交付包(ZIP)')
|
||||
expect(pkg.name).toMatch(/\.zip$/)
|
||||
expect(fs.readFileSync(pkg.file).subarray(0, 2).toString()).toBe('PK')
|
||||
note(`完整交付包:${pkg.name}(${pkg.size} B)`)
|
||||
writeState({ downloads: [md, docx, trace, manifest, pkg].map(f => f.name) })
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-7 追溯网显示来源、规则、修订、签署与交付物', async ({ browser }) => {
|
||||
items(7)
|
||||
const s = await session(browser, U.admin, 'item7-admin')
|
||||
const { page } = s
|
||||
await openAudit(page, A.name)
|
||||
await page.getByRole('tab', { name: '关联追溯' }).click()
|
||||
const pick = page.locator('.rdm-audit-pick button', { hasText: /^项目内容、目标及意义$/ }).first()
|
||||
await expect(pick).toBeVisible({ timeout: 60_000 })
|
||||
await pick.click()
|
||||
await expect(page.locator('.rdm-audit-trace-head')).toBeVisible()
|
||||
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 20_000 })
|
||||
await shot(page, 'item7-trace-section')
|
||||
await Promise.all(s.pending)
|
||||
const relationBodies = s.net.filter(n => n.url.includes('rdm.inspect.relations')).map(n => n.body).join('\n')
|
||||
const text = (await page.locator('.rdm-audit-body').textContent()) ?? ''
|
||||
note(`关联追溯页面文本(截断):${text.slice(0, 300)}`)
|
||||
const has = (re: RegExp) => re.test(text) || re.test(relationBodies)
|
||||
expect.soft(has(/source|来源依据([1-9]/), 'GAP[RDM] 追溯网未给出该章节的来源依据(rdm.inspect.relations 只含通用网/合规边)').toBe(true)
|
||||
expect.soft(has(/profile|渲染规则|org\.general|company\.dhf/), 'GAP[RDM] 追溯网不含渲染规则(profile)节点或边').toBe(true)
|
||||
expect.soft(has(/revision|修订/), 'GAP[RDM] 追溯网不含审阅修订').toBe(true)
|
||||
expect.soft(has(/decision|approve|final|签署|批准/), 'GAP[RDM] 追溯网不含审核/批准签署').toBe(true)
|
||||
expect.soft(has(/delivery|交付|deliveryId/), 'GAP[RDM] 追溯网不含渲染交付物(preview/review/approved delivery)').toBe(true)
|
||||
// 补充取证:操作记录是否留下渲染与签署事件(不计入追溯网判定)
|
||||
await page.getByRole('button', { name: '← 返回' }).click()
|
||||
await page.getByRole('tab', { name: '操作记录' }).click()
|
||||
await page.waitForTimeout(2_000)
|
||||
await shot(page, 'item7-timeline')
|
||||
const timeline = (await page.locator('.rdm-audit-body').textContent()) ?? ''
|
||||
note(`操作记录含 render:${/render/.test(timeline)};含 review:${/review/.test(timeline)}`)
|
||||
await s.close()
|
||||
})
|
||||
|
||||
test('§11-8 无权用户的页面与网络响应不含受限内容', async ({ browser }) => {
|
||||
items(8)
|
||||
const st = readState()
|
||||
const s = await session(browser, U.outsider, 'item8-outsider')
|
||||
const { page } = s
|
||||
await clickAuditEntry(page)
|
||||
await expect(page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 })
|
||||
const options = await page.locator('.rdm-audit-project option').allTextContents()
|
||||
note(`无权用户项目列表:${options.join('、')}`)
|
||||
expect(options).not.toContain(A.name)
|
||||
expect(options).toContain(B.name)
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
await expect(page.locator('.rdm-audit-tree-row').first()).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByRole('tab', { name: '关联追溯' }).click()
|
||||
await page.waitForTimeout(1_500)
|
||||
await page.getByRole('tab', { name: '操作记录' }).click()
|
||||
await page.waitForTimeout(1_500)
|
||||
await shot(page, 'item8-outsider-audit')
|
||||
const pageText = (await page.locator('body').textContent()) ?? ''
|
||||
for (const m of [A.name, A.title, UNCTRL.title, ANNOTATION, REVISION_AFTER]) expect(pageText, `页面不得出现受限内容「${m}」`).not.toContain(m)
|
||||
const passiveCount = s.net.length
|
||||
|
||||
// 主动探测:以无权用户会话直调 A 的读取与下载命令(请求中自带的 id 不算泄露)
|
||||
const probes: [string, Record<string, unknown>][] = [
|
||||
['rdm.inspect.summary', { projectId: A.pid }],
|
||||
['rdm.inspect.tree', { projectId: A.pid, kind: 'docs' }],
|
||||
['rdm.inspect.tree', { projectId: A.pid, kind: 'docs', parent: A.doc }],
|
||||
['rdm.inspect.relations', { projectId: A.pid, kind: 'doc', id: A.doc, depth: 1 }],
|
||||
['rdm.render.profiles', { projectId: A.pid, docId: A.doc }],
|
||||
['rdm.render.deliveries', { projectId: A.pid, docId: A.doc, limit: 20 }],
|
||||
['rdm.review.doc.status', { projectId: A.pid, docId: A.doc }],
|
||||
...(st.submissionId ? [['rdm.render.review.view', { projectId: A.pid, submissionId: st.submissionId }] as [string, Record<string, unknown>]] : []),
|
||||
]
|
||||
const probeResults: { id: string; status: number; state: string; hasPayload: boolean }[] = []
|
||||
const probeBodies: { url: string; body: string }[] = []
|
||||
for (const [id, params] of probes) {
|
||||
const r = await command(page, id, params)
|
||||
probeBodies.push({ url: `probe:${id}`, body: r.text })
|
||||
const state = r.body?.receipt?.state ?? ''
|
||||
const payload = r.body?.payload
|
||||
const hasPayload = payload !== undefined && payload !== null && !(typeof payload === 'object' && Object.keys(payload).length === 0)
|
||||
probeResults.push({ id, status: r.status, state, hasPayload })
|
||||
expect.soft(r.status >= 400 || state === 'rejected', `${id} 对无权用户应被拒绝:HTTP ${r.status} ${r.text.slice(0, 200)}`).toBe(true)
|
||||
expect.soft(hasPayload, `${id} 不得返回受限载荷`).toBe(false)
|
||||
}
|
||||
for (const kind of ['markdown', 'docx', 'manifest']) {
|
||||
const r = await command(page, 'rdm.render.download', { projectId: A.pid, deliveryId: 'probe', kind })
|
||||
probeBodies.push({ url: `probe:rdm.render.download:${kind}`, body: r.text })
|
||||
probeResults.push({ id: `rdm.render.download:${kind}`, status: r.status, state: r.body?.receipt?.state ?? '', hasPayload: !!r.body?.payload })
|
||||
expect.soft(!r.body?.payload?.dataBase64, 'rdm.render.download 不得返回文件内容').toBe(true)
|
||||
}
|
||||
const hostProjects = await page.request.get('/api/projects')
|
||||
const hostText = await hostProjects.text()
|
||||
expect.soft(hostText.includes(A.host), '宿主项目列表不得含项目 A').toBe(false)
|
||||
const hostConv = await page.request.get(`/api/projects/${A.host}/conversations`)
|
||||
const hostConvText = await hostConv.text()
|
||||
probeBodies.push({ url: '/api/projects', body: hostText }, { url: `/api/projects/${A.host}/conversations`, body: hostConvText })
|
||||
note(`宿主 A 会话列表 HTTP ${hostConv.status()}`)
|
||||
expect.soft(hostConv.status() >= 400 || !hostConvText.includes('§11'), '宿主 A 会话不得可见').toBe(true)
|
||||
note(`主动探测:${JSON.stringify(probeResults)}`)
|
||||
await Promise.all(s.pending)
|
||||
|
||||
// 全部网络响应体扫描:内容标记任何响应都不得出现;A 的标识只在被动浏览响应中检查
|
||||
const contentMarkers = [A.name, A.title, UNCTRL.title, ANNOTATION, REVISION_AFTER, ...(st.downloads ?? [])]
|
||||
const idMarkers = [A.pid, A.host, st.submissionId].filter(Boolean)
|
||||
const leaks: string[] = []
|
||||
s.net.forEach((n, i) => {
|
||||
for (const m of contentMarkers) if (m && n.body.includes(m)) leaks.push(`${n.url} 含「${m}」`)
|
||||
if (i < passiveCount) for (const m of idMarkers) if (n.body.includes(m)) leaks.push(`${n.url} 含标识 ${m}`)
|
||||
})
|
||||
for (const p of probeBodies) for (const m of contentMarkers) if (m && p.body.includes(m)) leaks.push(`${p.url} 含「${m}」`)
|
||||
const scanned = path.join(EVID, 'network', 'item8-scan.json')
|
||||
fs.mkdirSync(path.dirname(scanned), { recursive: true })
|
||||
fs.writeFileSync(scanned, JSON.stringify({ pageResponses: s.net.length, passive: passiveCount, probeResponses: probeBodies.length, contentMarkers, idMarkers, leaks, probeResults }, null, 2))
|
||||
evidence.push(scanned)
|
||||
expect(leaks, `受限内容泄露:${leaks.join(';')}`).toEqual([])
|
||||
await s.close()
|
||||
|
||||
// 反向:A 的审核人不是 B 成员,看不到 B
|
||||
const r = await session(browser, U.reviewer, 'item8-reviewer-vs-B')
|
||||
await clickAuditEntry(r.page)
|
||||
await expect(r.page.locator('.rdm-audit-project')).toBeVisible({ timeout: 15_000 })
|
||||
expect(await r.page.locator('.rdm-audit-project option').allTextContents()).not.toContain(B.name)
|
||||
const bProbe = await command(r.page, 'rdm.render.profiles', { projectId: B.pid, docId: B.doc })
|
||||
expect.soft(bProbe.status >= 400 || bProbe.body?.receipt?.state === 'rejected', `审核人读 B 应被拒绝:${bProbe.text.slice(0, 200)}`).toBe(true)
|
||||
await r.close()
|
||||
})
|
||||
|
||||
test('§11-9 浅色/深色/窄屏/长文档/长文件名可用', async ({ browser }) => {
|
||||
items(9)
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
for (const [w, h, tag] of [[1440, 900, 'wide'], [390, 844, 'narrow']] as const) {
|
||||
const s = await session(browser, U.author, `item9-${theme}-${tag}`, { theme, width: w, height: h })
|
||||
const { page } = s
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe(theme)
|
||||
await openAudit(page, A.name)
|
||||
await openDoc(page, A.title)
|
||||
await expect(panel(page, '渲染规则').locator('.drw-profile').first()).toBeVisible({ timeout: 15_000 })
|
||||
const overflow = await page.locator('.drw').evaluate(el => el.scrollWidth - el.clientWidth)
|
||||
note(`${theme}/${tag}:.drw 横向溢出 ${overflow}px`)
|
||||
expect.soft(overflow, `${theme}/${tag} 工作区不得横向溢出`).toBeLessThanOrEqual(1)
|
||||
const title = page.locator('.drw-title')
|
||||
await expect(title).toHaveAttribute('title', A.title)
|
||||
const clipped = await title.evaluate(el => el.scrollWidth > el.clientWidth)
|
||||
note(`${theme}/${tag}:${A.title.length} 字长标题截断显示 ${clipped}`)
|
||||
expect(clipped, '长标题在页头截断显示(完整标题保留在 title)').toBe(true)
|
||||
expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis')
|
||||
const bodyOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
expect.soft(bodyOverflow, `${theme}/${tag} 页面不得横向滚动`).toBeLessThanOrEqual(1)
|
||||
await shot(page, `item9-${theme}-${tag}-canvas`)
|
||||
if (tag === 'wide') {
|
||||
const delivery = page.locator('.drw-delivery').first()
|
||||
await expect(delivery, '长文件名取证需要至少一条交付记录').toBeVisible({ timeout: 15_000 })
|
||||
const file = await download(page, delivery, 'Markdown')
|
||||
const name = panel(page, '交付记录').locator('.drw-filename').first()
|
||||
await expect(name).toHaveAttribute('title', file.name)
|
||||
const shown = (await name.textContent()) ?? ''
|
||||
note(`${theme}:下载文件名「${file.name}」(${file.name.length} 字)显示为「${shown}」——RDM 交付文件名恒为 <docId>.<ext>,产品不产生长文件名;长名称以 ${A.title.length} 字文档标题检验`)
|
||||
expect(shown.length).toBeLessThanOrEqual(36)
|
||||
await shot(page, `item9-${theme}-filename`, panel(page, '交付记录'))
|
||||
}
|
||||
await page.getByRole('tab', { name: '阅读视图' }).click()
|
||||
const sections = await page.locator('section.drw-reader-node').count()
|
||||
expect(sections, '长文档阅读视图完整列出节点').toBeGreaterThan(10)
|
||||
await page.locator('section.drw-reader-node').last().scrollIntoViewIfNeeded()
|
||||
await expect(page.locator('section.drw-reader-node').last()).toBeInViewport()
|
||||
await shot(page, `item9-${theme}-${tag}-reader-end`)
|
||||
await page.locator('.drw-side').scrollIntoViewIfNeeded()
|
||||
await shot(page, `item9-${theme}-${tag}-side`, page.locator('.drw-side'))
|
||||
await page.getByRole('button', { name: '← 返回文档树' }).click()
|
||||
await expect(page.getByRole('button', { name: `阅读与交付:${A.title}`, exact: true })).toBeVisible()
|
||||
await s.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,419 @@
|
||||
// permux 活体浏览器验收:真实 Backend + 真实 Frontend dist(vite preview,/api 代理到被测 Backend)。
|
||||
// 由外部 harness 注入 PLAYWRIGHT_BASE_URL、PERMUX_LIVE=1、PERMUX_OWNER/PERMUX_PASSWORD(已设密管理员);
|
||||
// 全程不拦截任何路由,服务端事实一律经同一 baseURL 的 API 核对。
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { test, expect, type APIRequestContext, type APIResponse, type BrowserContext, type Page } from '@playwright/test'
|
||||
|
||||
interface Perm { actionId: string; label: string; description: string; group: string; category?: 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; stateVersion: number; profile: { name: string } }
|
||||
interface UserRoleView { binding?: { roleId: string; active: boolean }; role?: Role }
|
||||
interface Me { owner: string; isAdmin?: boolean; linkedCapabilities?: { projectCreate: boolean; source?: string[]; linkedActions?: string[] } }
|
||||
|
||||
const LIVE = process.env.PERMUX_LIVE === '1' && !!process.env.PLAYWRIGHT_BASE_URL
|
||||
const SUFFIX = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
const DOC_CATEGORY = '文档工作'
|
||||
const PROJECT_CREATE = 'project.create'
|
||||
const LINKED = ['project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign']
|
||||
const LINKED_NOTE = '创建项目时自动具备 · 仅本人创建的项目'
|
||||
|
||||
/** 与 rolePermissions.permissionCategory 同义:缺分类的非保护系统动作归入「项目与协作」。 */
|
||||
const categoryOf = (item: Perm) => item.category || (item.scope === 'system' ? '项目与协作' : item.group)
|
||||
const permKey = (role: Role) => role.permissions.filter(item => item.allowed || item.capAllowed)
|
||||
.map(item => `${item.actionId}=${item.allowed}/${item.capAllowed}`).sort()
|
||||
const rowOf = (role: Role, actionId: string) => role.permissions.find(item => item.actionId === actionId)
|
||||
|
||||
async function ok(res: APIResponse) {
|
||||
expect(res.ok(), `${res.url()} → ${res.status()} ${await res.text().catch(() => '')}`).toBe(true)
|
||||
return res
|
||||
}
|
||||
const getJSON = async <T>(api: APIRequestContext, path: string) => (await ok(await api.get(path))).json() as Promise<T>
|
||||
|
||||
async function roleByName(api: APIRequestContext, name: string): Promise<Role> {
|
||||
const page = await getJSON<{ roles: Role[] }>(api, `/api/admin/roles?query=${encodeURIComponent(name)}&mode=exact&status=all&limit=50`)
|
||||
const role = (page.roles ?? []).find(item => item.name === name)
|
||||
expect(role, `角色 ${name} 应存在`).toBeTruthy()
|
||||
return role!
|
||||
}
|
||||
const getRole = (api: APIRequestContext, roleId: string) => getJSON<Role>(api, `/api/admin/roles/${encodeURIComponent(roleId)}`)
|
||||
const getUser = (api: APIRequestContext, userId: string) => getJSON<Account>(api, `/api/admin/users/${encodeURIComponent(userId)}`)
|
||||
const userRoleId = async (api: APIRequestContext, userId: string) =>
|
||||
(await getJSON<UserRoleView>(api, `/api/admin/users/${encodeURIComponent(userId)}/role`)).role?.roleId ?? ''
|
||||
|
||||
async function userByOwner(api: APIRequestContext, owner: string): Promise<Account> {
|
||||
const page = await getJSON<{ accounts: Account[] }>(api,
|
||||
`/api/admin/users/search?query=${encodeURIComponent(owner)}&mode=exact&status=all&fields=owner&limit=20`)
|
||||
const account = (page.accounts ?? []).find(item => item.owner === owner)
|
||||
expect(account, `用户 ${owner} 应存在`).toBeTruthy()
|
||||
return account!
|
||||
}
|
||||
|
||||
async function assignRoleApi(api: APIRequestContext, userId: string, roleId: string) {
|
||||
const { stateVersion } = await getUser(api, userId)
|
||||
await ok(await api.put(`/api/admin/users/${encodeURIComponent(userId)}/role`, {
|
||||
data: { roleId, expectedStateVersion: stateVersion, idempotencyKey: randomUUID() },
|
||||
}))
|
||||
}
|
||||
|
||||
/** API 登录;首登受限会话先改密再以新密码重登,返回可用的完整会话。 */
|
||||
async function apiLogin(api: APIRequestContext, owner: string, password: string, nextPassword: string) {
|
||||
const first = await (await ok(await api.post('/api/auth/login', { data: { owner, password } }))).json() as { mustChangePassword?: boolean; sessionScope?: string }
|
||||
if (!first.mustChangePassword && first.sessionScope !== 'set_password_only') return
|
||||
await ok(await api.post('/api/auth/password', { data: { current: password, next: nextPassword } }))
|
||||
await ok(await api.post('/api/auth/login', { data: { owner, password: nextPassword } }))
|
||||
}
|
||||
|
||||
async function expand(page: Page, trigger: string) {
|
||||
const button = page.getByRole('button', { name: trigger, exact: true })
|
||||
await expect(button).toBeVisible({ timeout: 15_000 })
|
||||
if (await button.getAttribute('aria-expanded') !== 'true') await button.click()
|
||||
await expect(button).toHaveAttribute('aria-expanded', 'true')
|
||||
}
|
||||
|
||||
test.describe.serial('permux 活体主流程(真实 Backend + 真实 Frontend dist)', () => {
|
||||
test.skip(!LIVE, 'set PERMUX_LIVE=1 and PLAYWRIGHT_BASE_URL (vite preview proxied to the backend under test)')
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
let context: BrowserContext
|
||||
let page: Page
|
||||
let api: APIRequestContext
|
||||
let catalog: Perm[] = []
|
||||
let actA: Perm, actB: Perm, keep: Perm, projectCreate: Perm
|
||||
let linked: Perm[] = []
|
||||
let roleA: Role, roleB: Role, roleC: Role
|
||||
const names = {
|
||||
roleA: `权限甲-${SUFFIX}`, roleB: `权限乙-${SUFFIX}`, roleC: `权限丙-${SUFFIX}`, roleD: `权限丁-${SUFFIX}`,
|
||||
sameName: `同名测试·${SUFFIX}`, owner1: `permux-u1-${SUFFIX}`, owner2: `permux-u2-${SUFFIX}`, linkedOwner: `permux-linked-${SUFFIX}`,
|
||||
}
|
||||
const label1 = () => `${names.sameName}(${names.owner1})`
|
||||
const label2 = () => `${names.sameName}(${names.owner2})`
|
||||
|
||||
const status = (text: string) => page.getByRole('status').filter({ hasText: text })
|
||||
const savebar = () => page.getByRole('region', { name: '未保存的权限修改' })
|
||||
const previewPanel = () => page.getByRole('region', { name: '保存前预览' })
|
||||
const cell = (role: Role, item: Perm) => page.getByLabel(`${role.name} ${item.label}`, { exact: true })
|
||||
const category = (name: string) => page.getByRole('group', { name: '权限分类' }).getByRole('button', { name, exact: true })
|
||||
const matrixRow = (role: Role) => page.locator('tr').filter({ has: page.getByRole('button', { name: role.name, exact: true }) })
|
||||
const userCheckbox = (label: string) => page.getByRole('checkbox', { name: `选择 ${label}`, exact: true })
|
||||
const userRow = (label: string) => page.locator('tr').filter({ has: userCheckbox(label) })
|
||||
const selectedBar = () => page.getByRole('region', { name: '已选用户' })
|
||||
|
||||
async function openSection(name: '角色与权限' | '用户管理') {
|
||||
const zone = page.getByRole('dialog', { name: '系统管理' })
|
||||
if (!await zone.isVisible()) {
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
await expect(zone).toBeVisible()
|
||||
}
|
||||
await zone.getByRole('button', { name, exact: true }).click()
|
||||
}
|
||||
|
||||
/** 角色面板:矩阵视图 + 搜索本轮后缀,只呈现本轮创建的角色。 */
|
||||
async function rolesMatrix(query = SUFFIX) {
|
||||
await openSection('角色与权限')
|
||||
await expect(page.getByRole('group', { name: '权限分类' })).toBeVisible({ timeout: 15_000 })
|
||||
const matrixButton = page.getByRole('button', { name: '角色矩阵', exact: true })
|
||||
if (await matrixButton.getAttribute('aria-pressed') !== 'true') await matrixButton.click()
|
||||
await expand(page, '搜索与过滤')
|
||||
await page.getByLabel('搜索角色').fill(query)
|
||||
}
|
||||
|
||||
async function openPreview() {
|
||||
await savebar().getByRole('button', { name: '保存', exact: true }).click()
|
||||
await expect(previewPanel()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(previewPanel()).toContainText('请确认以下变化后保存')
|
||||
}
|
||||
|
||||
async function confirmSave(roles: number) {
|
||||
const confirm = savebar().getByRole('button', { name: '确认保存', exact: true })
|
||||
await expect(confirm).toBeEnabled()
|
||||
await confirm.click()
|
||||
await expect(status(`已保存 ${roles} 个角色的权限设置`)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(savebar()).toHaveCount(0)
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
if (!LIVE) return
|
||||
context = await browser.newContext({ baseURL: process.env.PLAYWRIGHT_BASE_URL, locale: 'zh-CN' })
|
||||
page = await context.newPage()
|
||||
api = context.request
|
||||
})
|
||||
|
||||
test.afterAll(async () => { await context?.close() })
|
||||
|
||||
test('登录:管理员经登录表单进入工作台,/api/me 为管理员', async () => {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(process.env.PERMUX_OWNER ?? '')
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(process.env.PERMUX_PASSWORD ?? '')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
|
||||
const res = await api.get('/api/me')
|
||||
expect(res.status()).toBe(200)
|
||||
const me = await res.json() as Me
|
||||
expect(me.isAdmin).toBe(true)
|
||||
expect(me.owner).toBe(process.env.PERMUX_OWNER)
|
||||
|
||||
catalog = (await getJSON<{ permissions: Perm[] }>(api, '/api/admin/permissions/catalog')).permissions ?? []
|
||||
const editable = catalog.filter(item => !item.protected)
|
||||
const docActions = editable.filter(item => categoryOf(item) === DOC_CATEGORY && item.scope === 'project' && !LINKED.includes(item.actionId))
|
||||
expect(docActions.length, '「文档工作」至少需要两个可编辑项目动作').toBeGreaterThanOrEqual(2)
|
||||
actA = docActions[0]
|
||||
actB = docActions[1]
|
||||
keep = docActions[2] ?? editable.find(item => item.scope === 'project' && !LINKED.includes(item.actionId)
|
||||
&& item.actionId !== actA.actionId && item.actionId !== actB.actionId)!
|
||||
expect(keep, '需要第三个可编辑项目动作用于验证未提及行保留').toBeTruthy()
|
||||
projectCreate = editable.find(item => item.actionId === PROJECT_CREATE)!
|
||||
expect(projectCreate, '目录应含 project.create').toBeTruthy()
|
||||
linked = LINKED.map(id => editable.find(item => item.actionId === id)!)
|
||||
expect(linked.every(Boolean), '目录应含四个创建联动动作').toBe(true)
|
||||
})
|
||||
|
||||
test('C01+C02 多角色矩阵编辑:跨分类与搜索保留草稿,预览后一次提交', async () => {
|
||||
await rolesMatrix()
|
||||
for (const name of [names.roleA, names.roleB]) {
|
||||
await page.getByLabel('新角色名称').fill(name)
|
||||
await page.getByLabel('新角色描述').fill('permux 活体验收')
|
||||
await page.getByRole('button', { name: '创建角色', exact: true }).click()
|
||||
await expect(status(`已创建角色 ${name}`)).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
roleA = await roleByName(api, names.roleA)
|
||||
roleB = await roleByName(api, names.roleB)
|
||||
expect(roleA.enabled && roleB.enabled).toBe(true)
|
||||
|
||||
// 预置第三动作行(默认允许):界面提交未提及它,提交后必须原样保留。
|
||||
await ok(await api.post('/api/admin/roles/permissions/batch', { data: { idempotencyKey: randomUUID(), roles: [roleA, roleB].map(role => ({
|
||||
roleId: role.roleId, expectedRoleVersion: role.roleVersion, changes: [{ actionId: keep.actionId, allowed: true, capAllowed: true }],
|
||||
})) } }))
|
||||
// 切走再回到分区使面板重挂载,草稿基线取预置后的最新版本。
|
||||
await openSection('用户管理')
|
||||
await rolesMatrix()
|
||||
|
||||
await category(DOC_CATEGORY).click()
|
||||
await expect(category(DOC_CATEGORY)).toHaveAttribute('aria-pressed', 'true')
|
||||
await cell(roleA, actA).selectOption('default')
|
||||
await cell(roleB, actB).selectOption('project')
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
|
||||
const other = (await page.getByRole('group', { name: '权限分类' }).getByRole('button').allInnerTexts()).find(text => text.trim() !== DOC_CATEGORY)
|
||||
expect(other, '应存在第二个权限分类').toBeTruthy()
|
||||
await category(other!.trim()).click()
|
||||
await expect(cell(roleA, actA)).toHaveCount(0)
|
||||
await category(DOC_CATEGORY).click()
|
||||
await expect(cell(roleA, actA)).toHaveValue('default')
|
||||
await expect(cell(roleB, actB)).toHaveValue('project')
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
|
||||
await page.getByLabel('搜索角色').fill(roleA.name)
|
||||
await expect(cell(roleB, actB)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
await page.getByLabel('搜索角色').fill('')
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
await page.getByLabel('搜索角色').fill(SUFFIX)
|
||||
await expect(cell(roleA, actA)).toHaveValue('default', { timeout: 15_000 })
|
||||
await expect(cell(roleB, actB)).toHaveValue('project')
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
|
||||
await openPreview()
|
||||
const preview = previewPanel()
|
||||
for (const text of [roleA.name, roleB.name, actA.label, actB.label, '不允许', '默认允许', '按项目授权']) await expect(preview).toContainText(text)
|
||||
await confirmSave(2)
|
||||
|
||||
const freshA = await getRole(api, roleA.roleId)
|
||||
const freshB = await getRole(api, roleB.roleId)
|
||||
expect(rowOf(freshA, actA.actionId)).toMatchObject({ allowed: true, capAllowed: true })
|
||||
expect(rowOf(freshB, actB.actionId)).toMatchObject({ allowed: false, capAllowed: true })
|
||||
expect(rowOf(freshA, actB.actionId)?.capAllowed ?? false).toBe(false)
|
||||
expect(rowOf(freshB, actA.actionId)?.capAllowed ?? false).toBe(false)
|
||||
for (const role of [freshA, freshB]) expect(rowOf(role, keep.actionId)).toMatchObject({ allowed: true, capAllowed: true })
|
||||
})
|
||||
|
||||
test('C03 创建联动:开启创建项目自动补齐四项按项目授权,运行时 /api/me 联动生效', async ({ request }) => {
|
||||
await rolesMatrix()
|
||||
await category(categoryOf(projectCreate)).click()
|
||||
await cell(roleA, projectCreate).selectOption(projectCreate.scope === 'system' ? 'on' : 'default')
|
||||
await expect(savebar()).toContainText('已修改 1 个角色、1 项设置')
|
||||
await category(categoryOf(linked[0])).click()
|
||||
await expect(matrixRow(roleA).getByText(LINKED_NOTE).first()).toBeVisible()
|
||||
await expect(matrixRow(roleB).getByText(LINKED_NOTE)).toHaveCount(0)
|
||||
|
||||
await openPreview()
|
||||
const expansion = previewPanel().locator('.rp-linked')
|
||||
await expect(expansion).toContainText('系统将自动补齐')
|
||||
for (const item of linked) await expect(expansion).toContainText(item.label)
|
||||
await confirmSave(1)
|
||||
|
||||
const fresh = await getRole(api, roleA.roleId)
|
||||
expect(rowOf(fresh, PROJECT_CREATE)).toMatchObject({ allowed: true })
|
||||
for (const id of LINKED) expect(rowOf(fresh, id), id).toMatchObject({ allowed: false, capAllowed: true })
|
||||
|
||||
const password = `Permux!${SUFFIX}Aa9`
|
||||
const nextPassword = `Permux!${SUFFIX}Bb8`
|
||||
const created = await (await ok(await api.post('/api/admin/users', {
|
||||
data: { owner: names.linkedOwner, profile: { name: '联动验证' }, password },
|
||||
}))).json() as Account
|
||||
await assignRoleApi(api, created.userId, roleA.roleId)
|
||||
expect(await userRoleId(api, created.userId)).toBe(roleA.roleId)
|
||||
|
||||
await apiLogin(request, names.linkedOwner, password, nextPassword)
|
||||
const meRes = await request.get('/api/me')
|
||||
expect(meRes.status()).toBe(200)
|
||||
const me = await meRes.json() as Me
|
||||
expect(me.owner).toBe(names.linkedOwner)
|
||||
expect(me.linkedCapabilities?.projectCreate).toBe(true)
|
||||
for (const id of LINKED) expect(me.linkedCapabilities?.linkedActions ?? []).toContain(id)
|
||||
})
|
||||
|
||||
test('C04 断链拒绝:创建项目开启时联动项不允许不可保存,恢复按项目授权后保存成功', async () => {
|
||||
const before = await getRole(api, roleA.roleId)
|
||||
const broken = linked[0]
|
||||
await rolesMatrix()
|
||||
await category(DOC_CATEGORY).click()
|
||||
await cell(roleA, actB).selectOption('project')
|
||||
await category(categoryOf(broken)).click()
|
||||
await expect(cell(roleA, broken)).toHaveValue('project')
|
||||
await cell(roleA, broken).selectOption('denied')
|
||||
await expect(savebar()).toContainText('已修改 1 个角色、2 项设置')
|
||||
|
||||
await openPreview()
|
||||
const confirm = savebar().getByRole('button', { name: '确认保存', exact: true })
|
||||
const blockedNote = previewPanel().getByRole('alert')
|
||||
if (await blockedNote.isVisible()) {
|
||||
await expect(blockedNote).toContainText('存在冲突或错误,调整后才能保存')
|
||||
await expect(previewPanel()).toContainText(broken.label)
|
||||
await expect(confirm).toBeDisabled()
|
||||
} else {
|
||||
await expect(confirm).toBeEnabled()
|
||||
await confirm.click()
|
||||
await expect(page.locator('.rp-status').getByRole('alert')).toContainText(broken.label, { timeout: 15_000 })
|
||||
await expect(savebar()).toContainText('已修改 1 个角色、2 项设置')
|
||||
}
|
||||
const unchanged = await getRole(api, roleA.roleId)
|
||||
expect(unchanged.roleVersion).toBe(before.roleVersion)
|
||||
expect(permKey(unchanged)).toEqual(permKey(before))
|
||||
|
||||
await cell(roleA, broken).selectOption('project')
|
||||
await expect(savebar()).toContainText('已修改 1 个角色、1 项设置')
|
||||
await openPreview()
|
||||
await confirmSave(1)
|
||||
const fresh = await getRole(api, roleA.roleId)
|
||||
expect(rowOf(fresh, actB.actionId)).toMatchObject({ allowed: false, capAllowed: true })
|
||||
expect(rowOf(fresh, PROJECT_CREATE)).toMatchObject({ allowed: true })
|
||||
for (const id of LINKED) expect(rowOf(fresh, id), id).toMatchObject({ allowed: false, capAllowed: true })
|
||||
})
|
||||
|
||||
test('C07 同名辨识:两名同名用户按账号辨识,勾选互不串行', async () => {
|
||||
await openSection('用户管理')
|
||||
for (const owner of [names.owner1, names.owner2]) {
|
||||
await page.getByLabel('新用户登录名').fill(owner)
|
||||
await expand(page, '可选资料:姓名、电话、邮箱、工号、部门、备注')
|
||||
await page.locator('#new-user-name').fill(names.sameName)
|
||||
await page.getByRole('button', { name: '创建用户', exact: true }).click()
|
||||
await expect(status(`已创建 ${owner}`)).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
}
|
||||
await page.getByLabel('搜索用户').fill(names.sameName)
|
||||
await expect(userRow(label1())).toBeVisible({ timeout: 15_000 })
|
||||
await expect(userRow(label2())).toBeVisible()
|
||||
await expect(page.getByText(`存在同名用户「${names.sameName}」`)).toBeVisible()
|
||||
for (const [label, owner] of [[label1(), names.owner1], [label2(), names.owner2]]) {
|
||||
await expect(userRow(label)).toContainText(names.sameName)
|
||||
await expect(userRow(label)).toContainText(owner)
|
||||
await expect(userRow(label)).toContainText('同名 · 按账号辨识')
|
||||
}
|
||||
|
||||
await userCheckbox(label1()).check()
|
||||
await expect(userCheckbox(label1())).toBeChecked()
|
||||
await expect(userCheckbox(label2())).not.toBeChecked()
|
||||
await expect(userRow(label2())).toContainText(names.owner2)
|
||||
await expect(userRow(label2())).not.toContainText(names.owner1)
|
||||
await expect(selectedBar()).toContainText('已选 1 人')
|
||||
})
|
||||
|
||||
test('C08 批量分配:两人预览 未分配 → 角色乙 后一次提交', async () => {
|
||||
await userCheckbox(label2()).check()
|
||||
await expect(selectedBar()).toContainText('已选 2 人')
|
||||
await selectedBar().getByRole('button', { name: '为选中的 2 人分配角色', exact: true }).click()
|
||||
const batch = page.getByRole('region', { name: '为选中的 2 人分配角色' })
|
||||
await batch.getByLabel('搜索可分配角色').fill(roleB.name)
|
||||
const target = batch.getByLabel('批量分配的目标角色')
|
||||
await expect(target.locator('option', { hasText: roleB.name })).toHaveCount(1, { timeout: 15_000 })
|
||||
await target.selectOption({ label: roleB.name })
|
||||
for (const owner of [names.owner1, names.owner2]) {
|
||||
await expect(batch.locator('li', { hasText: owner })).toContainText(`未分配 → ${roleB.name}`, { timeout: 15_000 })
|
||||
}
|
||||
await batch.getByRole('button', { name: '确认分配', exact: true }).click()
|
||||
await expect(status(`已为 2 人分配角色「${roleB.name}」`)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
for (const owner of [names.owner1, names.owner2]) {
|
||||
const account = await userByOwner(api, owner)
|
||||
expect(await userRoleId(api, account.userId), owner).toBe(roleB.roleId)
|
||||
}
|
||||
await expect(userRow(label1())).toContainText(roleB.name)
|
||||
await expect(userRow(label2())).toContainText(roleB.name)
|
||||
})
|
||||
|
||||
test('C09 冲突整批不写:确认前他人改动一人角色,409 后整批未写入且保留选择', async () => {
|
||||
roleC = await (await ok(await api.post('/api/admin/roles', { data: { name: names.roleC, description: 'permux 并发改动' } }))).json() as Role
|
||||
const user1 = await userByOwner(api, names.owner1)
|
||||
const user2 = await userByOwner(api, names.owner2)
|
||||
|
||||
await userCheckbox(label1()).check()
|
||||
await userCheckbox(label2()).check()
|
||||
await expect(selectedBar()).toContainText('已选 2 人')
|
||||
await selectedBar().getByRole('button', { name: '为选中的 2 人分配角色', exact: true }).click()
|
||||
const batch = page.getByRole('region', { name: '为选中的 2 人分配角色' })
|
||||
await batch.getByLabel('搜索可分配角色').fill(roleA.name)
|
||||
const target = batch.getByLabel('批量分配的目标角色')
|
||||
await expect(target.locator('option', { hasText: roleA.name })).toHaveCount(1, { timeout: 15_000 })
|
||||
await target.selectOption({ label: roleA.name })
|
||||
for (const owner of [names.owner1, names.owner2]) {
|
||||
await expect(batch.locator('li', { hasText: owner })).toContainText(`${roleB.name} → ${roleA.name}`, { timeout: 15_000 })
|
||||
}
|
||||
const confirm = batch.getByRole('button', { name: '确认分配', exact: true })
|
||||
await expect(confirm).toBeEnabled()
|
||||
|
||||
await assignRoleApi(api, user2.userId, roleC.roleId)
|
||||
await confirm.click()
|
||||
const alert = page.getByRole('alert').filter({ hasText: '整批均未写入' })
|
||||
await expect(alert).toBeVisible({ timeout: 15_000 })
|
||||
await expect(alert).toContainText('请重新预览')
|
||||
await expect(userCheckbox(label1())).toBeChecked()
|
||||
await expect(userCheckbox(label2())).toBeChecked()
|
||||
await expect(selectedBar()).toContainText('已选 2 人')
|
||||
|
||||
expect(await userRoleId(api, user1.userId)).toBe(roleB.roleId)
|
||||
expect(await userRoleId(api, user2.userId)).toBe(roleC.roleId)
|
||||
})
|
||||
|
||||
test('C10 复制并分配:源角色权限复制到新角色并换绑,新角色独立维护', async () => {
|
||||
await selectedBar().getByRole('button', { name: '清空选择', exact: true }).click()
|
||||
await expect(selectedBar()).toHaveCount(0)
|
||||
const user1 = await userByOwner(api, names.owner1)
|
||||
const sourceBefore = await getRole(api, roleB.roleId)
|
||||
|
||||
await userRow(label1()).getByRole('button', { name: '查看与管理', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeVisible()
|
||||
await expect(page.getByText(`当前角色 ${roleB.name}`)).toBeVisible({ timeout: 15_000 })
|
||||
await expand(page, '复制角色并分配…')
|
||||
await page.getByLabel('新角色名称(必填)').fill(names.roleD)
|
||||
await page.getByLabel('新角色描述').fill('permux 复制并分配')
|
||||
await expect(page.getByText(`相对「${roleB.name}」调整 0 项`)).toBeVisible()
|
||||
await page.getByRole('button', { name: '确认复制并分配', exact: true }).click()
|
||||
const done = status('新角色独立维护')
|
||||
await expect(done).toBeVisible({ timeout: 15_000 })
|
||||
await expect(done).toContainText(names.roleD)
|
||||
await expect(page.getByText(`当前角色 ${names.roleD}`)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
const copied = await roleByName(api, names.roleD)
|
||||
expect(copied.roleId).not.toBe(roleB.roleId)
|
||||
expect(copied.description).toBe('permux 复制并分配')
|
||||
expect(permKey(copied)).toEqual(permKey(sourceBefore))
|
||||
expect(await userRoleId(api, user1.userId)).toBe(copied.roleId)
|
||||
const sourceAfter = await getRole(api, roleB.roleId)
|
||||
expect(sourceAfter.roleVersion).toBe(sourceBefore.roleVersion)
|
||||
expect(permKey(sourceAfter)).toEqual(permKey(sourceBefore))
|
||||
// 源角色仅失去被换绑的这一人,其余绑定不受影响。
|
||||
expect(sourceAfter.bindingCount).toBe(sourceBefore.bindingCount - 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,623 @@
|
||||
// IAM U4 阶段 7 确定性浏览器切片(DR6–DR10):真实 React/Picker 交互 +
|
||||
// 完全受控的 HTTP 应答,不依赖任何账号或数据库。活体隔离产品层由
|
||||
// Agent-System-RDM scripts/iam-u4-role-acceptance.sh 以真实 Backend/RDM
|
||||
// 授权路径复用同一场景(live 模式不拦截路由)。
|
||||
import { test, expect, type Page, type Route } from '@playwright/test'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>(ok => { resolve = ok })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; systemActions?: string[] }
|
||||
interface Perm { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
|
||||
interface RolePerm { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
|
||||
interface Role { roleId: string; name: string; description: string; enabled: boolean; roleVersion: number; bindingCount: number; permissions: RolePerm[] }
|
||||
interface Account {
|
||||
userId: string; owner: string; profile: { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||||
profileVersion: number; enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
|
||||
initializationState: string; initializationGeneration: number
|
||||
}
|
||||
|
||||
const FULL_ACTIONS = ['system.users.view', 'system.users.create', 'system.users.edit', 'system.users.disable',
|
||||
'system.roles.view', 'system.roles.manage', 'system.roles.assign', 'system.permissions.view', 'system.permissions.manage']
|
||||
|
||||
function perm(actionId: string, label: string, group: string, scope: 'system' | 'project', extra: Partial<Perm> = {}): Perm {
|
||||
return { actionId, label, description: `${label}说明`, group, scope, protected: false, ...extra }
|
||||
}
|
||||
|
||||
const CATALOG: Perm[] = [
|
||||
perm('project.create', '创建项目', '项目入口', 'system'),
|
||||
perm('project.member.manage', '管理项目成员', '项目入口', 'project'),
|
||||
perm('project.override.set', '设置项目覆盖', '项目入口', 'project'),
|
||||
perm('document.read', '读取文档', '文档动作', 'project'),
|
||||
perm('document.author', '编制文档', '文档动作', 'project'),
|
||||
perm('document.approve', '批准文档', '文档动作', 'project'),
|
||||
perm('export.create', '创建导出', '平台动作', 'project'),
|
||||
perm('audit.read', '读取审计', '平台动作', 'project'),
|
||||
perm('system.users.manage', '管理用户账号', '系统动作', 'system', { protected: true }),
|
||||
]
|
||||
|
||||
function emptyProfile(name = '') {
|
||||
return { name, phone: '', email: '', staffNo: '', department: '', notes: '' }
|
||||
}
|
||||
|
||||
function account(userId: string, owner: string, profile = emptyProfile(), overrides: Partial<Account> = {}): Account {
|
||||
return { userId, owner, profile, profileVersion: 1, enabled: true, stateVersion: 3, isAdmin: false, passwordSet: true, initializationState: 'done', initializationGeneration: 0, ...overrides }
|
||||
}
|
||||
|
||||
function rp(actionId: string, allowed = false, capAllowed = false): RolePerm {
|
||||
return { actionId, allowed, capAllowed, permissionVersion: 1 }
|
||||
}
|
||||
|
||||
/** 可变角色/绑定状态:一个 fixture 贯穿用例内全部请求,支持版本化 409。 */
|
||||
function world(me: Me) {
|
||||
const roles: Role[] = [
|
||||
{ roleId: 'role_eng', name: '软件工程师', description: '研发职位', enabled: true, roleVersion: 3, bindingCount: 2, permissions: [rp('project.create', true, true), rp('document.read', true, true)] },
|
||||
{ roleId: 'role_ops', name: '运维工程师', description: '运维职位', enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] },
|
||||
{ roleId: 'role_review', name: '审核工程师', description: '质量职位', enabled: false, roleVersion: 2, bindingCount: 1, permissions: [rp('document.read', true, true), rp('document.approve', false, true)] },
|
||||
]
|
||||
const bindings: Record<string, string> = { u1: 'role_eng', u3: 'role_eng' }
|
||||
const users: Account[] = [
|
||||
account('u1', 'alice', { ...emptyProfile('张三'), phone: '13800000001', staffNo: 'A001' }),
|
||||
account('u2', 'bob'),
|
||||
account('u3', 'carol', { ...emptyProfile('李四'), email: 'carol@example.com' }),
|
||||
]
|
||||
const grants: Record<string, { actions: string[]; authorityEpoch: number }> = {}
|
||||
const calls: { method: string; path: string; query: Record<string, string>; body?: Record<string, unknown> }[] = []
|
||||
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
|
||||
const failNext = new Map<string, number>()
|
||||
let grantEpoch = 5
|
||||
const PAGE = 2
|
||||
|
||||
const visible = (q: URLSearchParams): Role[] => roles.filter(role => {
|
||||
const status = q.get('status') ?? 'all'
|
||||
if (status === 'enabled' && !role.enabled) return false
|
||||
if (status === 'disabled' && role.enabled) return false
|
||||
const action = q.get('action') ?? ''
|
||||
if (action && !role.permissions.some(item => item.actionId === action && item.allowed)) return false
|
||||
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
|
||||
if (!query) return true
|
||||
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
|
||||
return (q.get('mode') ?? 'fuzzy') === 'exact' ? fields.some(value => value === query) : fields.some(value => value.includes(query))
|
||||
})
|
||||
|
||||
const recount = () => {
|
||||
for (const role of roles) role.bindingCount = Object.values(bindings).filter(roleId => roleId === role.roleId).length
|
||||
}
|
||||
|
||||
const notFound = () => ({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'not found' }) })
|
||||
const deny = (route: Route, status: number) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ error: `code:${status}` }) })
|
||||
const ok = (route: Route, payload: unknown) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(payload) })
|
||||
|
||||
return {
|
||||
roles, bindings, users, grants, calls,
|
||||
hold(key: string) {
|
||||
const pending = { started: deferred(), release: deferred() }
|
||||
holds.set(key, pending)
|
||||
return pending
|
||||
},
|
||||
failNextTimes(path: string, times: number) { failNext.set(path, times) },
|
||||
async route(route: Route) {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const path = decodeURIComponent(url.pathname)
|
||||
const method = request.method()
|
||||
const raw = request.postData()
|
||||
calls.push({ method, path, query: Object.fromEntries(url.searchParams), body: raw ? JSON.parse(raw) as Record<string, unknown> : undefined })
|
||||
const hold = holds.get(`${method} ${path}`)
|
||||
if (hold) { holds.delete(`${method} ${path}`); hold.started.resolve(); await hold.release.promise }
|
||||
const fails = failNext.get(path) ?? 0
|
||||
if (fails > 0) { failNext.set(path, fails - 1); return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'boom' }) }) }
|
||||
|
||||
if (path === '/api/health') return ok(route, { ok: true })
|
||||
if (path === '/api/me') return ok(route, me)
|
||||
if (path === '/api/projects') return ok(route, [])
|
||||
if (path === '/api/settings') return ok(route, { schema: [], defaults: {}, user: {}, project: {} })
|
||||
if (path === '/api/commands') return ok(route, [])
|
||||
if (path === '/api/admin-zone/sections') return ok(route, [])
|
||||
if (path.endsWith('/cards')) return ok(route, { cards: [] })
|
||||
if (path === '/api/admin/permissions/catalog') return ok(route, { permissions: CATALOG })
|
||||
if (path === '/api/admin/roles' && method === 'GET') {
|
||||
const list = visible(url.searchParams)
|
||||
const start = Number(url.searchParams.get('cursor') ?? 0) || 0
|
||||
const page = list.slice(start, start + PAGE)
|
||||
return ok(route, { roles: page, nextCursor: start + PAGE < list.length ? String(start + PAGE) : '' })
|
||||
}
|
||||
if (path === '/api/admin/roles' && method === 'POST') {
|
||||
const body = request.postDataJSON() as { name: string; description: string }
|
||||
const role: Role = { roleId: `role_${body.name}`, name: body.name, description: body.description, enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] }
|
||||
roles.unshift(role)
|
||||
return ok(route, role)
|
||||
}
|
||||
const roleMatch = path.match(/^\/api\/admin\/roles\/([^/]+)(\/permissions)?$/)
|
||||
if (roleMatch) {
|
||||
const role = roles.find(item => item.roleId === roleMatch[1])
|
||||
if (!role) return route.fulfill(notFound())
|
||||
if (!roleMatch[2]) {
|
||||
if (method === 'GET') return ok(route, role)
|
||||
const body = request.postDataJSON() as { expectedRoleVersion: number; name?: string; description?: string; enabled?: boolean }
|
||||
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
|
||||
if (body.name !== undefined) role.name = body.name
|
||||
if (body.description !== undefined) role.description = body.description
|
||||
if (body.enabled !== undefined) role.enabled = body.enabled
|
||||
role.roleVersion += 1
|
||||
return ok(route, role)
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
const body = request.postDataJSON() as { expectedRoleVersion: number; changes: { actionId: string; allowed: boolean; capAllowed: boolean }[] }
|
||||
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
|
||||
role.permissions = body.changes.map(change => ({ ...change, permissionVersion: 1 }))
|
||||
role.roleVersion += 1
|
||||
return ok(route, { receiptId: 'r1', roleVersion: role.roleVersion, authorityEpoch: 6 })
|
||||
}
|
||||
}
|
||||
if (path === '/api/admin/users/search' && method === 'GET') {
|
||||
const q = url.searchParams
|
||||
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
|
||||
const fields = (q.get('fields') ?? 'owner,name,userId').split(',')
|
||||
const status = q.get('status') ?? 'all'
|
||||
const list = users.filter(item => {
|
||||
if (status === 'enabled' && !item.enabled) return false
|
||||
if (status === 'disabled' && item.enabled) return false
|
||||
if (!query) return true
|
||||
const values = (fields.includes('default') ? ['owner', 'name', 'userId'] : fields)
|
||||
.map(field => field === 'userId' ? item.userId : field === 'owner' ? item.owner : item.profile[field as 'name'] ?? '')
|
||||
return (q.get('mode') ?? 'fuzzy') === 'exact' ? values.some(value => value.toLowerCase() === query) : values.some(value => value.toLowerCase().includes(query))
|
||||
})
|
||||
return ok(route, { accounts: list, nextCursor: '', directoryVersion: 'd1' })
|
||||
}
|
||||
if (path === '/api/admin/users' && method === 'POST') {
|
||||
const body = request.postDataJSON() as { owner: string; profile: Account['profile'] }
|
||||
if (users.some(item => item.owner === body.owner)) return deny(route, 409)
|
||||
const created = account(`u-${body.owner}`, body.owner, { ...emptyProfile(), ...body.profile })
|
||||
users.push(created)
|
||||
return ok(route, created)
|
||||
}
|
||||
const userMatch = path.match(/^\/api\/admin\/users\/([^/]+)(\/role|\/profile|\/state|\/system-grants)?$/)
|
||||
if (userMatch) {
|
||||
const user = users.find(item => item.userId === userMatch[1])
|
||||
if (!user) return route.fulfill(notFound())
|
||||
const sub = userMatch[2] ?? ''
|
||||
if (sub === '' && method === 'GET') return ok(route, user)
|
||||
if (sub === '/profile' && method === 'PATCH') {
|
||||
const body = request.postDataJSON() as { expectedProfileVersion: number; patch: Record<string, string> }
|
||||
if (body.expectedProfileVersion !== user.profileVersion) return deny(route, 409)
|
||||
user.profile = { ...user.profile, ...body.patch }
|
||||
user.profileVersion += 1
|
||||
return ok(route, user)
|
||||
}
|
||||
if (sub === '/state' && method === 'PUT') {
|
||||
const body = request.postDataJSON() as { expectedStateVersion: number; enabled: boolean }
|
||||
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
|
||||
user.enabled = body.enabled
|
||||
user.stateVersion += 1
|
||||
return ok(route, user)
|
||||
}
|
||||
if (sub === '/role' && method === 'GET') {
|
||||
const role = roles.find(item => item.roleId === bindings[user.userId])
|
||||
return ok(route, role ? {
|
||||
binding: { bindingId: `b-${user.userId}`, userId: user.userId, roleId: role.roleId, active: true, bindingVersion: 1, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch },
|
||||
role,
|
||||
} : {})
|
||||
}
|
||||
if (sub === '/role' && method === 'PUT') {
|
||||
const body = request.postDataJSON() as { roleId: string; expectedStateVersion: number }
|
||||
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
|
||||
bindings[user.userId] = body.roleId
|
||||
recount()
|
||||
return ok(route, { bindingId: `b-${user.userId}`, userId: user.userId, roleId: body.roleId, active: true, bindingVersion: 2, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch })
|
||||
}
|
||||
if (sub === '/system-grants' && method === 'GET') {
|
||||
return ok(route, grants[user.userId] ?? { actions: [], authorityEpoch: grantEpoch })
|
||||
}
|
||||
if (sub === '/system-grants' && method === 'PUT') {
|
||||
const body = request.postDataJSON() as { actions: string[]; expectedAuthorityEpoch: number }
|
||||
if (body.expectedAuthorityEpoch !== (grants[user.userId]?.authorityEpoch ?? grantEpoch)) return deny(route, 409)
|
||||
const hadManage = (grants[user.userId]?.actions ?? []).includes('system.permissions.manage')
|
||||
if (hadManage && !body.actions.includes('system.permissions.manage')) {
|
||||
return route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: 'cannot remove the final permission manager' }) })
|
||||
}
|
||||
grants[user.userId] = { actions: body.actions, authorityEpoch: ++grantEpoch }
|
||||
return ok(route, { receiptId: 'g1', authorityEpoch: grantEpoch })
|
||||
}
|
||||
}
|
||||
// 未点名端点(profiles/plugins/conversations/workspaces 等启动探测)按空列表兜底。
|
||||
return route.fulfill({ contentType: 'application/json', body: '[]' })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type World = ReturnType<typeof world>
|
||||
|
||||
async function openAdmin(page: Page, me: Me): Promise<World> {
|
||||
const w = world(me)
|
||||
await page.route('**/api/**', route => { void w.route(route) })
|
||||
await page.goto('/')
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
|
||||
return w
|
||||
}
|
||||
|
||||
const openAdminAsAdmin = (page: Page) => openAdmin(page, { owner: 'root', name: '管理员', auth: true, isAdmin: true, systemActions: [...FULL_ACTIONS] })
|
||||
|
||||
async function openRolesSection(page: Page) {
|
||||
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
|
||||
// 面板加载带 180ms 防抖,等待首笔角色清单返回后再交互。
|
||||
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '搜索与过滤' }).click()
|
||||
}
|
||||
|
||||
async function openUsersSection(page: Page) {
|
||||
await page.getByRole('button', { name: '用户管理', exact: true }).click()
|
||||
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
const engRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '软件工程师' })
|
||||
const reviewRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '审核工程师' })
|
||||
|
||||
test('DR10 细粒度管理员:仅用户查看权时看不到角色分区,也不发出角色请求', async ({ page }) => {
|
||||
const w = await openAdmin(page, { owner: 'mgr', name: '用户管理员', auth: true, isAdmin: false, systemActions: ['system.users.view'] })
|
||||
await expect(page.getByRole('button', { name: '用户管理', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '角色与权限', exact: true })).toHaveCount(0)
|
||||
await openUsersSection(page)
|
||||
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
|
||||
await expect(page.getByText(/当前角色 无角色查看权限/)).toBeVisible()
|
||||
await expect(page.getByLabel('用户生效角色')).toHaveCount(0)
|
||||
expect(w.calls.filter(call => call.path.endsWith('/role'))).toEqual([])
|
||||
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
|
||||
})
|
||||
|
||||
test('DR10 权限管理者无查看权:出现守护提示且不拉取目录', async ({ page }) => {
|
||||
const w = await openAdmin(page, { owner: 'mgr', name: '权限管理者', auth: true, isAdmin: false, systemActions: ['system.users.view', 'system.permissions.manage'] })
|
||||
await openUsersSection(page)
|
||||
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
|
||||
await expect(page.getByText('需要同时具备系统权限查看权,才能安全编辑现有授权')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '明确系统权限' })).toHaveCount(0)
|
||||
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
|
||||
})
|
||||
|
||||
test('DR6 创建带描述的角色,后续修改保持稳定 roleId', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await page.getByLabel('新角色名称').fill('数据工程师')
|
||||
await page.getByLabel('新角色描述').fill('数据管道职位')
|
||||
await page.getByRole('button', { name: '创建角色', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师')
|
||||
await page.getByLabel('角色描述', { exact: true }).fill('数据管道与质量职位')
|
||||
await page.getByRole('button', { name: '保存资料', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('角色资料已保存')
|
||||
expect(w.calls.filter(call => call.method === 'POST' && call.path === '/api/admin/roles')).toHaveLength(1)
|
||||
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path.startsWith('/api/admin/roles/'))
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0].path).toBe('/api/admin/roles/role_数据工程师')
|
||||
})
|
||||
|
||||
test('DR6 停用角色需显式确认;取消不发出任何请求', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await engRow(page).click()
|
||||
await page.getByRole('button', { name: '停用角色', exact: true }).click()
|
||||
const dialog = page.getByRole('alertdialog', { name: '确认停用角色 软件工程师' })
|
||||
await expect(dialog).toContainText('软件工程师')
|
||||
await expect(dialog).toContainText('2 个用户绑定')
|
||||
await expect(dialog).toContainText('不再授予权限')
|
||||
await dialog.getByRole('button', { name: '取消', exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
expect(w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')).toEqual([])
|
||||
await page.getByRole('button', { name: '停用角色', exact: true }).click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限')
|
||||
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3, enabled: false })
|
||||
})
|
||||
|
||||
test('DR6 停用版本冲突:刷新最新状态并保留重试上下文', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await engRow(page).click()
|
||||
// 并发方先推进版本,首次确认停用必然 409。
|
||||
w.roles.find(role => role.roleId === 'role_eng')!.roleVersion = 4
|
||||
await page.getByRole('button', { name: '停用角色', exact: true }).click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
|
||||
await expect(page.getByRole('alert')).toContainText('角色状态版本冲突')
|
||||
await page.getByRole('button', { name: '停用角色', exact: true }).click()
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('角色已停用')
|
||||
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
|
||||
expect(patches).toHaveLength(2)
|
||||
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3 })
|
||||
expect(patches[1].body).toMatchObject({ expectedRoleVersion: 4 })
|
||||
expect(w.calls.some(call => call.method === 'GET' && call.path === '/api/admin/roles/role_eng')).toBe(true)
|
||||
})
|
||||
|
||||
test('DR9 过滤、翻页与视图切换按 roleId 保留草稿且永不串角色', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await engRow(page).click()
|
||||
await page.getByLabel('编制文档 默认允许').check()
|
||||
await expect(page.getByText('变更预览(1)')).toBeVisible()
|
||||
// 切到第二页的审核工程师:不带走软件工程师的草稿。
|
||||
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
|
||||
await reviewRow(page).click()
|
||||
await expect(page.getByText('变更预览(0)')).toBeVisible()
|
||||
await expect(page.getByLabel('编制文档 默认允许')).not.toBeChecked()
|
||||
// 切回软件工程师:草稿原样保留。
|
||||
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
|
||||
await engRow(page).click()
|
||||
await expect(page.getByText('变更预览(1)')).toBeVisible()
|
||||
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
|
||||
// 状态过滤触发重载:草稿仍在。
|
||||
await page.getByLabel('角色状态').selectOption('disabled')
|
||||
await expect(reviewRow(page)).toBeVisible()
|
||||
await page.getByLabel('角色状态').selectOption('all')
|
||||
await engRow(page).click()
|
||||
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
|
||||
// 单角色 ↔ 矩阵切换保留;矩阵中当前角色的勾选状态一致。
|
||||
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
|
||||
await expect(page.getByLabel('软件工程师 编制文档 默认允许')).toBeChecked()
|
||||
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
|
||||
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
|
||||
expect(w.calls.filter(call => call.path === '/api/admin/roles/role_ops')).toEqual([])
|
||||
})
|
||||
|
||||
test('DR9 离开管理区须显式决定;确认离开后草稿被放弃', async ({ page }) => {
|
||||
await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await engRow(page).click()
|
||||
await page.getByLabel('编制文档 默认允许').check()
|
||||
const dialogs: string[] = []
|
||||
let leave = false
|
||||
page.on('dialog', async dialog => {
|
||||
dialogs.push(dialog.message())
|
||||
if (dialog.type() === 'confirm') leave ? await dialog.accept() : await dialog.dismiss()
|
||||
else await dialog.accept()
|
||||
})
|
||||
await page.getByRole('button', { name: '返回工作台' }).click()
|
||||
expect(dialogs).toHaveLength(1)
|
||||
expect(dialogs[0]).toContain('软件工程师')
|
||||
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
|
||||
leave = true
|
||||
await page.getByRole('button', { name: '返回工作台' }).click()
|
||||
await expect(page.getByRole('dialog', { name: '系统管理' })).toHaveCount(0)
|
||||
// 重新进入后草稿不复存在(离开即放弃)。
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
await openRolesSection(page)
|
||||
await engRow(page).click()
|
||||
await expect(page.getByText('变更预览(0)')).toBeVisible()
|
||||
})
|
||||
|
||||
test('DR9 409 只重放同一角色草稿;另一角色草稿不受影响', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await engRow(page).click()
|
||||
await page.getByLabel('编制文档 默认允许').check()
|
||||
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
|
||||
await reviewRow(page).click()
|
||||
await page.getByLabel('批准文档 默认允许').check()
|
||||
// 切回软件工程师再保存:并发方已推进版本并改变服务端基线,保存触发 409 rebase。
|
||||
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
|
||||
await engRow(page).click()
|
||||
const eng = w.roles.find(role => role.roleId === 'role_eng')!
|
||||
eng.roleVersion = 4
|
||||
eng.permissions = [rp('project.create', false, false), rp('document.read', true, true)]
|
||||
await page.getByRole('button', { name: '保存权限', exact: true }).click()
|
||||
await expect(page.getByRole('alert')).toContainText('权限版本冲突;已基于最新版本重放本地草稿')
|
||||
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/roles/role_review')).toEqual([])
|
||||
// 重放后:本地对编制文档的修改保留;再次保存基于最新版本成功。
|
||||
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
|
||||
await page.getByRole('button', { name: '保存权限', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('角色权限已保存')
|
||||
const puts = w.calls.filter(call => call.method === 'PUT' && call.path === '/api/admin/roles/role_eng/permissions')
|
||||
expect(puts).toHaveLength(2)
|
||||
expect(puts[0].body).toMatchObject({ expectedRoleVersion: 3 })
|
||||
expect(puts[1].body).toMatchObject({ expectedRoleVersion: 4 })
|
||||
expect((puts[1].body!.changes as { actionId: string; allowed: boolean }[]).find(change => change.actionId === 'document.author')).toMatchObject({ allowed: true, capAllowed: true })
|
||||
// 审核工程师的草稿未被动过:切回后仍在。
|
||||
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
|
||||
await reviewRow(page).click()
|
||||
await expect(page.getByLabel('批准文档 默认允许')).toBeChecked()
|
||||
})
|
||||
|
||||
test('DR9 矩阵批量编辑只作用于当前可见页', async ({ page }) => {
|
||||
await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
|
||||
await page.locator('.rp-matrix button', { hasText: '软件工程师' }).click()
|
||||
await page.getByRole('button', { name: '当前页默认全选', exact: true }).click()
|
||||
for (const label of ['创建项目', '管理项目成员', '设置项目覆盖', '读取文档', '编制文档', '批准文档']) {
|
||||
await expect(page.getByLabel(`软件工程师 ${label} 默认允许`)).toBeChecked()
|
||||
}
|
||||
await page.getByRole('button', { name: '下一组', exact: true }).click()
|
||||
await expect(page.getByLabel('软件工程师 创建导出 默认允许')).not.toBeChecked()
|
||||
await page.getByRole('button', { name: '上一组', exact: true }).click()
|
||||
await page.getByRole('button', { name: '当前页上限关闭', exact: true }).click()
|
||||
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).not.toBeChecked()
|
||||
// 变更预览在单角色视图:批量编辑按 roleId 保留,切回可见 2 项相对基线变化。
|
||||
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
|
||||
await expect(page.getByText('变更预览(2)')).toBeVisible()
|
||||
})
|
||||
|
||||
test('DR10 角色清单失败不拖垮目录加载,可经检索重试恢复', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
w.failNextTimes('/api/admin/roles', 1)
|
||||
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
|
||||
await expect(page.getByRole('alert')).toContainText('500')
|
||||
// 目录请求独立发出并成功(互不拖垮)。
|
||||
expect(w.calls.some(call => call.path === '/api/admin/permissions/catalog')).toBe(true)
|
||||
await page.getByRole('button', { name: '搜索与过滤' }).click()
|
||||
await page.getByLabel('搜索角色').fill('软件')
|
||||
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByRole('alert')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('DR10 权限目录失败不影响角色列表呈现', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
w.failNextTimes('/api/admin/permissions/catalog', 1)
|
||||
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
|
||||
await expect(page.getByRole('alert')).toContainText('权限目录加载失败')
|
||||
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible()
|
||||
expect(w.calls.some(call => call.path === '/api/admin/roles')).toBe(true)
|
||||
})
|
||||
|
||||
test('DR10 键盘完成核心流程,保存后焦点稳定且状态可被播报', async ({ page }) => {
|
||||
await openAdminAsAdmin(page)
|
||||
await openRolesSection(page)
|
||||
await engRow(page).focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByLabel('角色名称', { exact: true })).toHaveValue('软件工程师')
|
||||
await page.getByLabel('编制文档 默认允许').focus()
|
||||
await page.keyboard.press('Space')
|
||||
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
|
||||
await page.getByRole('button', { name: '保存权限', exact: true }).focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByRole('status')).toContainText('角色权限已保存')
|
||||
const focused = await page.evaluate(() => (document.activeElement as HTMLElement | null)?.textContent ?? '')
|
||||
expect(focused).toContain('保存权限')
|
||||
})
|
||||
|
||||
test('DR10 窄屏矩阵横向可导航且可操作', async ({ page }) => {
|
||||
await openAdminAsAdmin(page)
|
||||
await page.setViewportSize({ width: 480, height: 800 })
|
||||
await openRolesSection(page)
|
||||
// 窄屏下视图切换按钮存在持续的布局微抖动,Playwright 稳定性检查无法通过;
|
||||
// 用 DOM 事件切换视图,随后的横向滚动与勾选仍是真实交互。
|
||||
await page.getByRole('button', { name: '角色矩阵', exact: true }).dispatchEvent('click')
|
||||
const wrap = page.locator('.rp-matrix-wrap')
|
||||
await expect(wrap).toBeVisible()
|
||||
const scroll = await wrap.evaluate(el => ({ sw: el.scrollWidth, cw: el.clientWidth }))
|
||||
expect(scroll.sw).toBeGreaterThan(scroll.cw)
|
||||
await page.getByLabel('软件工程师 创建项目 默认允许').check()
|
||||
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).toBeChecked()
|
||||
})
|
||||
|
||||
test('DR7 两人共享同一角色;换绑后仅一个生效角色且计数随之变化', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openUsersSection(page)
|
||||
const openUser = async (owner: string) => {
|
||||
await page.locator('tr', { hasText: owner }).getByRole('button', { name: '查看与管理' }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表' })).toBeVisible()
|
||||
}
|
||||
await openUser('bob')
|
||||
await page.getByLabel('用户生效角色').selectOption({ label: '软件工程师' })
|
||||
await page.getByRole('button', { name: '替换角色', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('用户角色已替换')
|
||||
expect(w.bindings.u2).toBe('role_eng')
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
await openUser('alice')
|
||||
await expect(page.getByText(/当前角色 软件工程师/)).toBeVisible()
|
||||
await page.getByLabel('用户生效角色').selectOption({ label: '运维工程师' })
|
||||
await page.getByRole('button', { name: '替换角色', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('用户角色已替换')
|
||||
expect(w.bindings.u1).toBe('role_ops')
|
||||
expect(w.roles.find(role => role.roleId === 'role_eng')!.bindingCount).toBe(2) // bob + carol
|
||||
// 用户角色视图始终单角色:GET /role 只返回一个 binding。
|
||||
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/u1/role').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('DR8 资料可全空创建,并支持按电话等字段检索', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await openUsersSection(page)
|
||||
await page.getByLabel('新用户登录名').fill('dave')
|
||||
await page.getByRole('button', { name: '创建用户', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('已创建 dave')
|
||||
const created = w.calls.find(call => call.method === 'POST' && call.path === '/api/admin/users')
|
||||
expect(created!.body!.profile).toEqual(emptyProfile())
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
await page.getByLabel('用户搜索字段').selectOption({ label: '电话' })
|
||||
await page.getByLabel('搜索用户').fill('13800000001')
|
||||
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('tr', { hasText: 'carol' })).toHaveCount(0)
|
||||
const searches = w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/search')
|
||||
expect(searches.at(-1)!.query.fields).toContain('phone')
|
||||
})
|
||||
|
||||
test('DR10 加载与空态:加载期间无结果无报错,过滤为空给出选择提示', async ({ page }) => {
|
||||
const w = await openAdminAsAdmin(page)
|
||||
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
|
||||
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '搜索与过滤' }).click()
|
||||
const pending = w.hold('GET /api/admin/roles')
|
||||
await page.getByLabel('搜索角色').fill('不存在的角色')
|
||||
await pending.started.promise
|
||||
await expect(page.locator('.rp-role-list button').first()).toBeDisabled()
|
||||
await expect(page.getByRole('alert')).toHaveCount(0)
|
||||
pending.release.resolve()
|
||||
await expect(page.locator('.rp-role-list button')).toHaveCount(0)
|
||||
await expect(page.getByText('请选择角色')).toBeVisible()
|
||||
})
|
||||
|
||||
// ── 活体层(IAM_E2E_LIVE=1):同一文件在真实 Backend/RDM 组合上运行真实授权路径。
|
||||
// 由 Agent-System-RDM scripts/iam-u4-role-acceptance.sh 物化冻结候选源码、
|
||||
// 构建内嵌产品、全新 SQLite 数据根与随机回环端口后驱动;不拦截任何路由。
|
||||
const LIVE = process.env.IAM_E2E_LIVE === '1'
|
||||
|
||||
async function liveLogin(page: Page) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(process.env.IAM_E2E_OWNER ?? '')
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(process.env.IAM_E2E_PASSWORD ?? '')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
async function openAdminZoneLive(page: Page) {
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
|
||||
}
|
||||
|
||||
test('LIVE 真实登录加载真实权限目录,创建、配置并停用角色', async ({ page }) => {
|
||||
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
|
||||
test.setTimeout(90_000)
|
||||
await liveLogin(page)
|
||||
await openAdminZoneLive(page)
|
||||
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
|
||||
await page.getByRole('button', { name: '搜索与过滤' }).click()
|
||||
await expect(page.getByLabel('新角色名称')).toBeVisible({ timeout: 15_000 })
|
||||
// 真实空库无角色:先经真实 Backend 创建,选中后权限表才渲染真实目录。
|
||||
await page.getByLabel('新角色名称').fill('数据工程师')
|
||||
await page.getByLabel('新角色描述').fill('活体验收职位')
|
||||
await page.getByRole('button', { name: '创建角色', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师', { timeout: 15_000 })
|
||||
// 真实目录的首页可能全是受保护系统授权(复选框禁用):逐组翻到可编辑动作。
|
||||
const firstDefault = page.locator('input[aria-label$="默认允许"]:not([disabled])').first()
|
||||
for (let pageIdx = 0; (await firstDefault.count()) === 0 && pageIdx < 15; pageIdx++) {
|
||||
await page.getByRole('button', { name: '下一组', exact: true }).click()
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
await expect(firstDefault).toBeVisible({ timeout: 15_000 })
|
||||
await firstDefault.check()
|
||||
await page.getByRole('button', { name: '保存权限', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('角色权限已保存', { timeout: 15_000 })
|
||||
await page.getByRole('button', { name: '停用角色', exact: true }).click()
|
||||
await expect(page.getByRole('alertdialog')).toContainText('数据工程师')
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限', { timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('LIVE DR13 非成员账号被拒读取项目业务内容;创建者放行', async ({ request }) => {
|
||||
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
|
||||
const base = process.env.PLAYWRIGHT_BASE_URL!
|
||||
const projectId = process.env.IAM_E2E_PROJECT_ID
|
||||
test.expect(projectId).toBeTruthy()
|
||||
const memberList = async (owner: string, password: string) => {
|
||||
await request.post(`${base}/api/auth/login`, { data: { owner, password } })
|
||||
const res = await request.post(`${base}/api/commands/rdm.project.member.list`, {
|
||||
data: { params: { projectId } },
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
return { ok: res.ok(), body }
|
||||
}
|
||||
const observer = await memberList(process.env.IAM_E2E_OBSERVER_OWNER ?? '', process.env.IAM_E2E_OBSERVER_PASSWORD ?? '')
|
||||
const denied = !observer.ok || observer.body?.receipt?.state === 'rejected'
|
||||
expect(denied, `observer 应被拒绝:${JSON.stringify(observer.body).slice(0, 240)}`).toBe(true)
|
||||
const creator = await memberList(process.env.IAM_E2E_OWNER ?? '', process.env.IAM_E2E_PASSWORD ?? '')
|
||||
const allowed = creator.ok && creator.body?.receipt?.state !== 'rejected'
|
||||
expect(allowed, `创建者应放行:${JSON.stringify(creator.body).slice(0, 240)}`).toBe(true)
|
||||
})
|
||||
+21
-30
@@ -1,7 +1,5 @@
|
||||
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
|
||||
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
|
||||
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
|
||||
// 新密码登录进工作台;重复兑换同一 Key 被拒。
|
||||
// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密;
|
||||
// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。
|
||||
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
||||
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
||||
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
||||
@@ -53,23 +51,21 @@ async function adminLogin(page: Page) {
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
|
||||
test('管理员随机重置:临时密码登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
||||
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
||||
test.setTimeout(120_000)
|
||||
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
|
||||
// 1) 管理员进入用户管理,为目标用户生成随机临时密码(明文仅显示一次)。
|
||||
await adminLogin(page)
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
const zone = page.getByRole('dialog', { name: '系统管理' })
|
||||
await expect(zone).toBeVisible()
|
||||
// 每个用户行都有同款按钮:限定目标用户所在行。
|
||||
await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click()
|
||||
await zone.getByRole('button', { name: '生成 Key', exact: true }).click()
|
||||
const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]')
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 })
|
||||
const key = await keyInput.inputValue()
|
||||
expect(key).toMatch(/^alk_/)
|
||||
await expect(zone.getByText(/单次有效/)).toBeVisible()
|
||||
const row = zone.locator('tr', { hasText: USER })
|
||||
await row.getByRole('button', { name: '重置临时密码' }).click()
|
||||
const temporaryPassword = zone.locator('input[aria-label="一次性密码"]')
|
||||
await expect(temporaryPassword).toBeVisible({ timeout: 10_000 })
|
||||
const password = await temporaryPassword.inputValue()
|
||||
expect(password.length).toBeGreaterThan(0)
|
||||
|
||||
// 2) 管理员退出系统管理与登录态,回到登录页。
|
||||
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
|
||||
@@ -78,35 +74,30 @@ test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。
|
||||
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
|
||||
await page.getByPlaceholder('alk_…').fill(key)
|
||||
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
|
||||
|
||||
// 4) 受限设密门:服务端确认用户名;无当前密码输入框。
|
||||
// 3) 用户使用临时密码登录后进入强制改密门。
|
||||
await passwordLogin(page, USER, password)
|
||||
const gate = page.locator('.lg-card')
|
||||
await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
|
||||
await expect(gate.getByText(`账号 ${USER}`)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(1)
|
||||
const pw = gate.locator('input[type="password"]')
|
||||
await pw.nth(0).fill(USER_PW)
|
||||
await pw.nth(0).fill(password)
|
||||
await pw.nth(1).fill(USER_PW)
|
||||
await pw.nth(2).fill(USER_PW)
|
||||
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
|
||||
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||
// 4) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
|
||||
|
||||
// 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。
|
||||
// 5) 新密码登录进工作台。
|
||||
await passwordLogin(page, USER, USER_PW)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
|
||||
// 6) 登录页不再提供一次性 Key 登录入口。
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
|
||||
await page.getByPlaceholder('alk_…').fill(key)
|
||||
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
|
||||
await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByRole('tab', { name: '一次性登录 Key' })).toHaveCount(0)
|
||||
await expect(page.getByPlaceholder('alk_…')).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -71,7 +71,7 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
await page.locator('.rdm-audit-tree-node').first().click()
|
||||
const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node')
|
||||
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: 'section' }) }).first()
|
||||
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: '章节' }) }).first()
|
||||
await expect(sectionNode).toBeVisible()
|
||||
await sectionNode.click()
|
||||
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
|
||||
|
||||
@@ -21,9 +21,15 @@ async function login(page: Page, user: string, password: string) {
|
||||
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)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '文档驾驶舱' })).toBeVisible({ timeout: 15_000 })
|
||||
// 登录后身份纪元与能力探针仍可能各跳一次;应用在身份变更时会关闭已开
|
||||
// 工作区——等落定再点入口,避免 overlay 被身份刷新关掉(竞态实测)。
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
@@ -31,7 +37,11 @@ async function loginOwner(page: Page) {
|
||||
}
|
||||
|
||||
async function openCockpit(page: Page) {
|
||||
await page.locator('.tsb-tool', { hasText: '文档驾驶舱' }).click()
|
||||
// 窄宽是单窗格布局:登录后默认「对话」窗格,先切到「上下文」窗格。
|
||||
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 })
|
||||
@@ -111,16 +121,17 @@ test.describe.serial('I4 文档驾驶舱组合验收', () => {
|
||||
test('4 一致性:API 采纳新版本后,刷新入口与各投影同步', async ({ page }) => {
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
await page.locator('.cockpit-tree-caret').first().click()
|
||||
await page.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
|
||||
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 采纳一个新提案(同源会话;与浏览器渲染解耦)。
|
||||
await page.request.post('/api/commands/rdm.draft.propose', {
|
||||
data: { params: { projectId: (await page.locator('.cockpit-project option:checked').textContent()) || '', sectionId: '', content: '一致性探针内容——不应出现' }, meta: {} },
|
||||
// 通过应用自身 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,
|
||||
})
|
||||
// 上面缺 sectionId 必然 400:真正的驱动是 e2e.sh 早已留好的第二个提案
|
||||
// (种子阶段 propose 未采纳的「一致性探针」),这里经面板刷新读取。
|
||||
await page.getByRole('button', { name: '刷新' }).click()
|
||||
await expect(page.locator('.cockpit-snap')).not.toHaveText(snapBefore)
|
||||
// 版本块前进:出现新版本条目。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 通用规范采纳记录
|
||||
|
||||
- 项目/负责人:Agent-System-Frontend / weichen
|
||||
- 状态:active
|
||||
- 规范位置:`deps/development-guidelines`
|
||||
- 规范性文档目录:`policy/`(项目规则与制度放此处;入口为 `policy/README.md`)
|
||||
- 过程文档位置:任务卡在 `.task/`(如 `.task/feature-governance-adoption.md`);任务主记录为对应 PR 描述
|
||||
- 版本权威:本仓 gitlink `deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`;可读标签:无(候选 `0.1.0-draft.12`,尚未发布)
|
||||
- 批准/生效依据:`feature/governance-adoption PR(Agent-System-Frontend)`
|
||||
- 替代范围和迁移说明:无替代既有采纳记录。遗留:`policy/README.md` 与 `PROJECT.md` 的采纳基线行仍写有旧路径 `development/guidelines`(指向 Dev 仓),本次未改,后续单独修正为本仓 gitlink。
|
||||
|
||||
| 规范/工作流 | 适用条件与范围 | 采纳或不适用理由 | 执行方式/证据 |
|
||||
|---|---|---|---|
|
||||
| core/baseline.md | 日常工作 | 采纳:所有任务的硬边界、授权与完成声明基线 | manual |
|
||||
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 采纳:本仓日常交付、Bug 排障与会话交接均按此执行,任务卡与 PR 为记录载体 | manual |
|
||||
| standards/collaboration.md | 并行共同起点、两级分支、节点同步与陈旧候选拒绝 | 采纳:存在 feature 分支并行开发,合入前需同步最新目标分支 | manual;有真实平台负例再登记 enforced |
|
||||
| standards/merge-queue.md | 多个就绪任务的最终同步、CI、Review 与合入调度 | 不适用:单 owner、单仓串行合入,无共享 CI 预算或多任务合入窗口 | 不适用 |
|
||||
| standards/agent-execution.md | 工程 Agent CLI 执行路由、会话隔离与受控集成 | 采纳:允许 AI 协助实施,配置见 AGENT-EXECUTION.md | AGENT-EXECUTION.md + scripts/agent-exec(validate / admit);证据:governance check + 正负例(`.task/feature-governance-adoption.md`) |
|
||||
| standards/verification.md | 验证与结论 | 采纳:功能与修复的验收须附实际命令输出 | manual;本地命令见 PROJECT.md「验证」节(CI 仅运行治理检查) |
|
||||
| standards/module-contracts.md | 模块边界、公开接口与契约测试 | 采纳:`src/` 模块与 Core 契约形状测试(vitest)遵循该契约 | manual |
|
||||
| workflows/design-verification.md | 功能增量的设计与验收对应 | 采纳:新功能与跨模块变更须写明验收与验证范围 | manual |
|
||||
| standards/testability.md | 模块测试、mock 边界与可独立验证的设计 | 采纳:单元与契约测试依赖保持最小,真实适配器单独验证 | manual |
|
||||
| standards/failure-containment.md | 故障影响控制、运行配额与故障注入 | 不适用:本仓为前端 SPA(构建产物由后端内嵌),无自有服务进程、队列或持久化,故障影响控制由后端仓承担 | 不适用 |
|
||||
|
||||
## 项目差异
|
||||
|
||||
| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 |
|
||||
|---|---|---|---|
|
||||
| 无 | 无 | 无 | 无 |
|
||||
|
||||
默认做法差异说明理由;硬边界冲突须有明确有效决策,不能用此表自授权。建议不采用无需登记。
|
||||
|
||||
## 本次升级或首次接入验证
|
||||
|
||||
- 入口:`AGENTS.md`(治理入口,合并 Guidelines 短入口与本仓「Agent 执行与新项目准入」)、`AGENT-EXECUTION.md`、`PROJECT.md`、本文件;相对链接指向 `deps/development-guidelines/...`,目标文件已在固定 SHA 中核对存在。
|
||||
- 占位符:本文件与 `AGENTS.md` 中的模板占位符已全部替换。
|
||||
- 版本获取:`governance init --adopt`(dm-server bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)以固定 SHA 检出 `deps/development-guidelines`(2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb)与 `deps/development-manager`(bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)。
|
||||
- 冲突检查:既有 `AGENTS.md`、`PROJECT.md`、`AGENT-EXECUTION.md` 原样保留;本仓无其他采纳记录(如 `policy/adoption.md`),无需引用。
|
||||
- 正负例与 Review/CI 证据:见 `.task/feature-governance-adoption.md`「结果与验证」节。
|
||||
- 回退:删除 `.gitmodules` 中两个治理条目及 `deps/` gitlink,并移除本次生成的治理脚本与 `.gitea/workflows/governance.yml`;`AGENTS.md`、`PROJECT.md` 恢复到采纳前版本。
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# 共享 Agent 执行器薄入口:转调 scripts/dm 的 agent-exec 子命令(validate / run / apply / admit / cleanup)。
|
||||
set -euo pipefail
|
||||
exec "$(dirname "$0")/dm" agent-exec "$@"
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# 共享 dm-server 薄入口:读取 deps/development-manager 的 gitlink 钉住提交,
|
||||
# 按该提交构建并缓存 dm-server,再 exec 其子命令(agent-exec、task、governance)。执行语义与退出码以该二进制为准。
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
dm="$root/deps/development-manager"
|
||||
|
||||
die() { echo "dm: $*" >&2; exit 2; }
|
||||
|
||||
# git 钩子会导出仓库本地变量(GIT_DIR、GIT_INDEX_FILE 等),它们会让 `git -C "$dm"` 解析到外层仓库。
|
||||
# 所有针对 DM 检出的 git 与 go 调用都在清除这些变量后执行;读取外层 gitlink 与最终 exec 仍保留继承环境。
|
||||
dm_local_env="GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_GRAFT_FILE GIT_NAMESPACE GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT GIT_SHALLOW_FILE GIT_IMPLICIT_WORK_TREE $(git rev-parse --local-env-vars 2>/dev/null || true)"
|
||||
dm_clean_env() { for v in $dm_local_env; do unset "$v"; done; }
|
||||
dmgit() ( dm_clean_env; git -C "$dm" "$@" )
|
||||
|
||||
# 1. 钉住版本只来自索引中的 gitlink(mode 160000),不读取工作区文件。
|
||||
entry=$(git -C "$root" ls-files -s -- deps/development-manager 2>/dev/null || true)
|
||||
[ -n "$entry" ] || die "索引中缺少 deps/development-manager 的 gitlink,无法确定钉住的 DM 版本"
|
||||
read -r mode pin _stage _path <<<"$entry"
|
||||
[ "$mode" = 160000 ] || die "deps/development-manager 不是 gitlink(mode=$mode)"
|
||||
[[ "$pin" =~ ^[0-9a-f]{40}$ ]] || die "gitlink 提交格式无效:$pin"
|
||||
|
||||
# 2. 检出必须存在且确为 DM 独立仓库(避免 git -C 误解析到外层仓库),HEAD 必须等于钉住提交。
|
||||
[ -e "$dm/.git" ] || die "deps/development-manager 未检出;请按钉住提交 $pin 检出该 gitlink"
|
||||
top=$(dmgit rev-parse --show-toplevel 2>/dev/null || true)
|
||||
[ -n "$top" ] && [ "$top" = "$(cd "$dm" && pwd -P)" ] \
|
||||
|| die "deps/development-manager 不是独立检出;请按钉住提交 $pin 检出该 gitlink"
|
||||
head=$(dmgit rev-parse HEAD 2>/dev/null || true)
|
||||
[ "$head" = "$pin" ] \
|
||||
|| die "deps/development-manager HEAD(${head:-未知})与钉住提交 $pin 不一致;请按钉住提交检出"
|
||||
|
||||
# 3. 检出工作树必须干净(忽略嵌套 submodule 状态)。
|
||||
dirty=$(dmgit status --porcelain --ignore-submodules=all) \
|
||||
|| die "无法读取 deps/development-manager 的工作树状态"
|
||||
[ -z "$dirty" ] || die "deps/development-manager 存在未提交改动,拒绝运行(请先清理该检出)"
|
||||
|
||||
# 4. 二进制按钉住提交缓存;缺失时在缓存目录内临时构建,完成后原子改名,避免留下半成品。
|
||||
cache="${DM_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/development-manager/dm-server}"
|
||||
bin="$cache/dm-server-$pin"
|
||||
if [ ! -x "$bin" ]; then
|
||||
command -v go >/dev/null 2>&1 || die "缺少 go,无法构建 dm-server($pin)"
|
||||
mkdir -p "$cache"
|
||||
cache=$(cd "$cache" && pwd -P)
|
||||
bin="$cache/dm-server-$pin"
|
||||
work=$(mktemp -d "$cache/build-$pin.XXXXXX")
|
||||
if ! (dm_clean_env; cd "$dm" && GOWORK=off go build -trimpath -o "$work/dm-server" ./cmd/dm-server); then
|
||||
rm -rf "$work"
|
||||
die "构建 dm-server 失败($pin)"
|
||||
fi
|
||||
mv -f "$work/dm-server" "$bin"
|
||||
rm -rf "$work"
|
||||
fi
|
||||
|
||||
exec "$bin" "$@"
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
case "$branch" in
|
||||
main|master)
|
||||
echo "pre-commit: 拒绝——集成分支 ${branch} 不得直接提交,走 feature 分支 + PR(collaboration.md)" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
|
||||
echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/<slug>,当前:${branch}" >&2
|
||||
exit 1
|
||||
}
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子,
|
||||
# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。
|
||||
"$root/scripts/agent-exec" admit --repo "$root"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# 通用任务入口:转调 scripts/dm 的 task 子命令(triage / start / pr-check)。
|
||||
set -euo pipefail
|
||||
exec "$(dirname "$0")/dm" task "$@"
|
||||
+135
-10
@@ -1,7 +1,7 @@
|
||||
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
|
||||
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
|
||||
import { auth } from './auth'
|
||||
import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement'
|
||||
import { commandRequestBody, rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
||||
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
||||
@@ -14,12 +14,45 @@ const BASE = ''
|
||||
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
|
||||
* 自身命令面 fail-closed。 */
|
||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||
/** category:角色矩阵业务分类(项目与协作/文档工作/项目内容);受保护系统授权为空。 */
|
||||
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; category?: string; scope: 'system' | 'project'; protected: boolean }
|
||||
export interface RolePermissionRecord { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
|
||||
export interface RolePermissionChangeInput { actionId: string; allowed: boolean; capAllowed: boolean }
|
||||
/** changes 为显式 patch:只含相对基线变化的动作,服务端合并保留未提及行。 */
|
||||
export interface RolePermissionPatchEntry { roleId: string; expectedRoleVersion: number; changes: RolePermissionChangeInput[] }
|
||||
/** 预览模式:缺省为服务端默认 replace;显式 patch 提交须以 patch 预览。 */
|
||||
export type RolePermissionPreviewMode = 'replace' | 'patch'
|
||||
/** before/after 为中文状态名:不允许/按项目授权/默认允许。 */
|
||||
export interface RolePermissionStateChange { actionId: string; label: string; before: string; after: string }
|
||||
export interface RolePermissionPreview {
|
||||
roleId: string; roleName: string; expectedRoleVersion: number; currentRoleVersion: number; versionMatches: boolean
|
||||
changes: RolePermissionStateChange[]; linkedExpansion: string[]; linkedExpansionState?: string; keptLinkedActions: string[]
|
||||
migrationNote?: string; conflicts: { actionId: string; reason: string }[]; error?: string; errorActionId?: string
|
||||
}
|
||||
export interface RolePermissionBatchReceipt {
|
||||
roleId: string; previousRoleVersion: number; roleVersion: number; linkedExpansion?: string[]; keptLinkedActions: string[]
|
||||
migrationNote?: string; changes: RolePermissionStateChange[]
|
||||
}
|
||||
export interface RolePermissionBatchResult { receiptId: string; authorityEpoch: number; receipts: RolePermissionBatchReceipt[] }
|
||||
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 免当前密码,
|
||||
* current_required 必须校验当前密码。 */
|
||||
export type PasswordChangeMode = 'grant_setup' | 'current_required'
|
||||
|
||||
/** /api/me 联动资格(L1 联动后由服务端给出;旧服务无此字段)。actions/conflicts 为动作 id。 */
|
||||
export interface LinkedCapabilities { projectCreate: boolean; source?: string; actions: string[]; conflicts?: string[] }
|
||||
|
||||
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
|
||||
export interface RecoveryProposal {
|
||||
id: string; userId: string; owner: string
|
||||
@@ -33,8 +66,36 @@ export interface AccountProfile { name: string; phone: string; email: string; st
|
||||
export interface AccountRecord {
|
||||
userId: string; owner: string; profile: AccountProfile; profileVersion: number
|
||||
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
|
||||
initializationState?: string; initializationGeneration?: number
|
||||
initializationState?: string; initializationGeneration?: number; createdAt?: string
|
||||
}
|
||||
/** 用户目录角色投影:actor 具备 system.roles.view 时返回(无绑定为 null);无查看权时字段缺省。 */
|
||||
export interface AccountRoleSummary { roleId: string; name: string; enabled: boolean }
|
||||
/** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */
|
||||
export interface ManagedAccountRecord extends AccountRecord {
|
||||
enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
|
||||
initializationState: string; initializationGeneration: number
|
||||
role?: AccountRoleSummary | null
|
||||
}
|
||||
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
|
||||
/** 批量角色分配条目:一切以 userId/roleId 为稳定标识。 */
|
||||
export interface UserRoleAssignmentInput { userId: string; roleId: string; expectedStateVersion: number }
|
||||
/** error 为服务端中文原因(停用/版本变化/角色无效);任何一项有 error 或 stateMatches=false 都不得提交。 */
|
||||
export interface UserRoleAssignmentPreview {
|
||||
userId: string; owner: string; name: string; currentRoleId: string; currentRoleName: string
|
||||
targetRoleId: string; targetRoleName: string; stateMatches: boolean; error?: string
|
||||
}
|
||||
interface UserRoleAssignmentResult { userId: string; bindingId: string; roleId: string; previousRoleId: string; userStateVersion: number }
|
||||
interface UserRoleAssignmentBatch { receiptId?: string; authorityEpoch?: number; bindings: UserRoleAssignmentResult[] }
|
||||
export interface RoleMember {
|
||||
userId: string; owner: string; name: string; enabled: boolean; stateVersion: number; assignedAt: string; bindingVersion: number
|
||||
}
|
||||
interface RoleMemberPage { members: RoleMember[]; nextCursor?: string }
|
||||
/** 复制角色并分配:新角色尚不存在,分配条目只带 userId 与状态版本(服务端严格拒绝未知字段)。 */
|
||||
export interface RoleCopyAssignRequest {
|
||||
idempotencyKey: string; source: { roleId: string; expectedRoleVersion: number }; name: string; description: string
|
||||
overrides?: RolePermissionChangeInput[]; assignments: { userId: string; expectedStateVersion: number }[]
|
||||
}
|
||||
interface RoleCopyAssignResult { receiptId?: string; authorityEpoch?: number; role: RoleRecord; bindings: UserRoleAssignmentResult[] }
|
||||
|
||||
export interface ExecEnvView {
|
||||
configured: boolean
|
||||
@@ -60,6 +121,59 @@ async function json<T>(res: Response): 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)),
|
||||
previewRolePermissions: (roles: RolePermissionPatchEntry[], options: { mode?: RolePermissionPreviewMode } = {}) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/permissions/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ roles: options.mode ? roles.map(role => ({ ...role, mode: options.mode })) : roles }) }).then(r => json<{ previews: RolePermissionPreview[] }>(r)).then(d => d.previews ?? []),
|
||||
batchPatchRolePermissions: (roles: RolePermissionPatchEntry[], idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/permissions/batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ idempotencyKey, roles }) }).then(r => json<RolePermissionBatchResult>(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)),
|
||||
previewUserRoleAssignments: (assignments: UserRoleAssignmentInput[]) =>
|
||||
auth.fetch(`${BASE}/api/admin/users/role-assignments/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ assignments }) }).then(r => json<{ previews: UserRoleAssignmentPreview[] }>(r)).then(d => d.previews ?? []),
|
||||
assignUserRolesBatch: (assignments: UserRoleAssignmentInput[], idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/users/role-assignments`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ idempotencyKey, assignments }) }).then(r => json<UserRoleAssignmentBatch>(r)),
|
||||
listRoleMembers: (roleId: string, request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; 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.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/${encodeURIComponent(roleId)}/members${query ? `?${query}` : ''}`).then(r => json<RoleMemberPage>(r))
|
||||
},
|
||||
createRoleCopyAndAssign: (request: RoleCopyAssignRequest) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/copy-assign`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request) }).then(r => json<RoleCopyAssignResult>(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)),
|
||||
@@ -79,19 +193,19 @@ 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; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
|
||||
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
|
||||
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
|
||||
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/recovery-grants/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
@@ -105,8 +219,19 @@ export const api = {
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
}),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||
// 建号只创建待初始化账号(无密码、无权限位);初始凭证由服务器本地 CLI 发放激活 Key。
|
||||
createAdminUser: (owner: string, name: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name } }) }).then(r => json<{ owner: string; initializationState: string }>(r)),
|
||||
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
|
||||
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||||
adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(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>) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).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)),
|
||||
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
|
||||
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
|
||||
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
|
||||
@@ -232,10 +357,10 @@ export const api = {
|
||||
if (error) throw new Error(error)
|
||||
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
|
||||
},
|
||||
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
|
||||
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}, source?: CommandSource): Promise<T> =>
|
||||
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ params }),
|
||||
body: JSON.stringify(commandRequestBody(params, source)),
|
||||
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
|
||||
.then(out => {
|
||||
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleAssignmentPreview, type UserRoleView,
|
||||
} from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { cellKind, hasSystemAction } from './RolePermissionsPanel'
|
||||
import {
|
||||
ROLE_PERMISSION_STATES, categoryActions, permissionCategories, stateToValue, valueToState,
|
||||
type RolePermissionStateName, type RolePermissionValue,
|
||||
} from '../rolePermissions'
|
||||
import {
|
||||
accountLabel, accountName, clearSelection, copyAssignRequest, createSelection, duplicateNames, isSelected, memberAssignments,
|
||||
pageSelectionState, previewAllowsAssign, previewTransition, refreshStateVersions, roleColumnLabel, roleSourceValues, sameNameHint,
|
||||
selectPage, selectionCounts, selectionSummary, staleUserIds, toggleOne, type MemberSelection,
|
||||
} from '../roleMembers'
|
||||
|
||||
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.initializationState === 'pending' ? '待可信 owner 初始化' : 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 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 canCopyRole = canAssignRoles && canManageGrants && allowed('system.roles.manage')
|
||||
const [accounts, setAccounts] = useState<ManagedAccountRecord[] | null>(null)
|
||||
const [selection, setSelection] = useState<MemberSelection>(createSelection)
|
||||
const [known, setKnown] = useState<Record<string, ManagedAccountRecord>>({})
|
||||
const [batchOpen, setBatchOpen] = useState(false)
|
||||
const [batchRoleId, setBatchRoleId] = useState('')
|
||||
const [batchPreview, setBatchPreview] = useState<UserRoleAssignmentPreview[] | null>(null)
|
||||
const [copyName, setCopyName] = useState('')
|
||||
const [copyDescription, setCopyDescription] = useState('')
|
||||
const [copyValues, setCopyValues] = useState<Record<string, RolePermissionValue>>({})
|
||||
const [selected, setSelected] = useState<ManagedAccountRecord | null>(null)
|
||||
const [roleView, setRoleView] = useState<UserRoleView | null>(null)
|
||||
const [fullSourceRole, setFullSourceRole] = useState<RoleRecord | null>(null)
|
||||
const [sourceRoleError, setSourceRoleError] = useState('')
|
||||
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 [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 batchSequence = useRef(0)
|
||||
const pageIds = (accounts ?? []).map(account => account.userId)
|
||||
const pageState = pageSelectionState(selection, pageIds)
|
||||
const counts = selectionCounts(selection, pageIds)
|
||||
const duplicates = duplicateNames(accounts ?? [])
|
||||
const batchReady = !!batchPreview && previewAllowsAssign(batchPreview, selection.ids)
|
||||
const batchStale = batchPreview ? staleUserIds(batchPreview) : []
|
||||
|
||||
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 ?? '')
|
||||
setKnown(current => ({ ...current, ...Object.fromEntries((page.accounts ?? []).map(account => [account.userId, account])) }))
|
||||
} 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('')
|
||||
setCopyName(''); setCopyDescription(''); setCopyValues({}); setFullSourceRole(null); setSourceRoleError('')
|
||||
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 ?? '')
|
||||
if (canCopyRole && currentRole.role) {
|
||||
void api.getRole(currentRole.role.roleId).then(role => {
|
||||
if (request !== sequence.current) return
|
||||
if (Array.isArray(role?.permissions)) setFullSourceRole(role)
|
||||
else setSourceRoleError('源角色权限读取不完整,暂不能复制,请重新打开用户')
|
||||
}).catch(error => { if (request === sequence.current) setSourceRoleError(`读取源角色权限失败:${String(error)}`) })
|
||||
}
|
||||
} 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 && !batchOpen) || !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, batchOpen, 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)
|
||||
setNewOwner(''); setNewProfile(emptyProfile())
|
||||
await open(account)
|
||||
setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`)
|
||||
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 changeSelection = (next: MemberSelection) => {
|
||||
batchSequence.current++
|
||||
setSelection(next); setBatchPreview(null)
|
||||
}
|
||||
|
||||
const openBatch = () => {
|
||||
roleSequence.current++
|
||||
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor('')
|
||||
setBatchRoleId(''); setBatchPreview(null); setBatchOpen(true); setErr(''); setNotice('')
|
||||
}
|
||||
|
||||
const closeBatch = () => {
|
||||
batchSequence.current++
|
||||
setBatchOpen(false); setBatchRoleId(''); setBatchPreview(null)
|
||||
}
|
||||
|
||||
const previewBatch = async (roleId: string, versions = known) => {
|
||||
const request = ++batchSequence.current
|
||||
setBatchRoleId(roleId); setBatchPreview(null); setErr(''); setNotice('')
|
||||
if (!roleId) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const assignments = memberAssignments(selection.ids, roleId,
|
||||
Object.fromEntries(Object.values(versions).map(account => [account.userId, account.stateVersion])))
|
||||
const previews = await api.previewUserRoleAssignments(assignments)
|
||||
if (request === batchSequence.current) setBatchPreview(previews)
|
||||
} catch (error) { if (request === batchSequence.current) setErr(error instanceof Error ? error.message : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const refreshBatchVersions = async () => {
|
||||
if (!batchPreview) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
const fresh = { ...known, ...await refreshStateVersions(api, staleUserIds(batchPreview)) }
|
||||
setKnown(fresh)
|
||||
await previewBatch(batchRoleId, fresh)
|
||||
} catch (error) { setErr(String(error)); setBusy(false) }
|
||||
}
|
||||
|
||||
const confirmBatch = async () => {
|
||||
if (!batchPreview || !previewAllowsAssign(batchPreview, selection.ids)) return
|
||||
const roleName = batchPreview[0]?.targetRoleName ?? ''
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const assignments = memberAssignments(selection.ids, batchRoleId,
|
||||
Object.fromEntries(Object.values(known).map(account => [account.userId, account.stateVersion])))
|
||||
const result = await api.assignUserRolesBatch(assignments, crypto.randomUUID())
|
||||
changeSelection(clearSelection()); closeBatch()
|
||||
await refreshFirst()
|
||||
setNotice(`已为 ${result.bindings?.length ?? assignments.length} 人分配角色「${roleName}」`)
|
||||
} catch (error) {
|
||||
setBatchPreview(null)
|
||||
setErr(String(error).includes('409') ? '部分用户状态或目标角色已变化,整批均未写入;选择已保留,请重新预览后再确认' : `整批均未写入:${String(error)}`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const copyAndAssign = async () => {
|
||||
const source = fullSourceRole
|
||||
if (!selected || !source) return
|
||||
setErr(''); setNotice('')
|
||||
let request
|
||||
try {
|
||||
request = copyAssignRequest({ idempotencyKey: crypto.randomUUID(), source, name: copyName, description: copyDescription, catalog,
|
||||
values: { ...roleSourceValues(source, catalog), ...copyValues }, users: [{ userId: selected.userId, stateVersion: selected.stateVersion }] })
|
||||
} catch (error) { setErr(error instanceof Error ? error.message : String(error)); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
const result = await api.createRoleCopyAndAssign(request)
|
||||
await open(selected); await refreshFirst()
|
||||
setNotice(`已创建新角色「${result.role.name}」并分配给 ${accountLabel(selected)};新角色独立维护,不再随原角色「${source.name}」更新`)
|
||||
} catch (error) {
|
||||
setErr(String(error).includes('409') ? '新角色名称已存在,或源角色/用户状态已变化;草稿已保留,请重新打开用户核对后重试' : String(error))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const roleSearch = () => <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>
|
||||
|
||||
const copyForm = (source: RoleRecord) => {
|
||||
const baseline = roleSourceValues(source, catalog)
|
||||
const valueOf = (actionId: string) => copyValues[actionId] ?? baseline[actionId]
|
||||
const adjusted = Object.keys(baseline).filter(actionId => {
|
||||
const value = valueOf(actionId)
|
||||
return value.allowed !== baseline[actionId].allowed || value.capAllowed !== baseline[actionId].capAllowed
|
||||
}).length
|
||||
const setValue = (actionId: string, value: RolePermissionValue) => setCopyValues(current => ({ ...current, [actionId]: value }))
|
||||
return <div className="au-copy">
|
||||
<p className="ad-hint">以「{source.name}」的当前权限为起点创建新角色,并把 {accountLabel(selected!)} 换绑到新角色。新角色独立维护,不再随原角色更新。</p>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="新角色名称(必填)" placeholder="新角色名称(必填)" value={copyName} disabled={busy} onChange={event => setCopyName(event.target.value)} />
|
||||
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={copyDescription} disabled={busy} onChange={event => setCopyDescription(event.target.value)} />
|
||||
</div>
|
||||
{permissionCategories(catalog).map(category => <fieldset className="au-copy-group" key={category}>
|
||||
<legend>{category}</legend>
|
||||
{categoryActions(catalog, category).map(item => {
|
||||
const value = valueOf(item.actionId)
|
||||
const changed = value.allowed !== baseline[item.actionId].allowed || value.capAllowed !== baseline[item.actionId].capAllowed
|
||||
const label = `新角色 ${item.label}`
|
||||
return <div className="au-copy-row" key={item.actionId}>
|
||||
<span title={item.description}>{item.label}</span>
|
||||
{cellKind(item) === 'toggle'
|
||||
? <select className="rp-state" aria-label={label} value={value.allowed ? 'on' : 'off'} disabled={busy}
|
||||
onChange={event => setValue(item.actionId, stateToValue(event.target.value === 'on' ? 'default' : 'denied'))}>
|
||||
<option value="off">不允许</option><option value="on">允许</option>
|
||||
</select>
|
||||
: <select className="rp-state" aria-label={label} value={valueToState(value)} disabled={busy}
|
||||
onChange={event => setValue(item.actionId, stateToValue(event.target.value as RolePermissionStateName))}>
|
||||
{ROLE_PERMISSION_STATES.map(option => <option key={option.state} value={option.state}>{option.label}</option>)}
|
||||
</select>}
|
||||
{changed && <span className="rp-cell-flag">已调整</span>}
|
||||
</div>
|
||||
})}
|
||||
</fieldset>)}
|
||||
<div className="ad-row">
|
||||
<span className="ad-hint au-grow">相对「{source.name}」调整 {adjusted} 项</span>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !copyName.trim()} onClick={() => void copyAndAssign()}>确认复制并分配</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const batchPanel = () => <section className="rp-preview au-batch" aria-label={`为选中的 ${counts.count} 人分配角色`}>
|
||||
<div className="rp-preview-head">
|
||||
<strong>为选中的 {counts.count} 人分配角色</strong>
|
||||
<button className="ad-mini" disabled={busy} onClick={closeBatch}>收起</button>
|
||||
</div>
|
||||
{roleSearch()}
|
||||
<select className="lg-input" aria-label="批量分配的目标角色" value={batchRoleId} disabled={busy || roleLoading} onChange={event => void previewBatch(event.target.value)}>
|
||||
<option value="">请选择目标角色</option>
|
||||
{batchRoleId && !roles.some(role => role.roleId === batchRoleId) && <option value={batchRoleId}>{batchPreview?.[0]?.targetRoleName ?? '已选角色'}</option>}
|
||||
{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
|
||||
</select>
|
||||
{batchPreview && <ul className="au-preview-list">{batchPreview.map(item => <li key={item.userId}>
|
||||
<span>{accountLabel(item)}</span><span className="ad-dim">{previewTransition(item)}</span>
|
||||
{item.error && <span className="rp-conflict"><Icon name="alert" size={12} />{item.error}</span>}
|
||||
{!item.error && !item.stateMatches && <span className="rp-conflict"><Icon name="alert" size={12} />用户状态已变化</span>}
|
||||
</li>)}</ul>}
|
||||
{batchPreview && !batchReady && <p className="rp-conflict" role="alert"><Icon name="alert" size={12} />存在停用、版本变化或角色无效的用户,处理后才能确认分配</p>}
|
||||
<div className="ad-row">
|
||||
{batchStale.length > 0 && <button className="ad-mini" disabled={busy} onClick={() => void refreshBatchVersions()}>刷新 {batchStale.length} 人的状态并重新预览</button>}
|
||||
{batchRoleId && !batchPreview && <button className="ad-mini" disabled={busy} onClick={() => void previewBatch(batchRoleId)}>重新预览</button>}
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !batchReady} onClick={() => void confirmBatch()}>确认分配</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
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>
|
||||
<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 ? <div className="rp-editor">
|
||||
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
|
||||
<b>{selected.owner}</b><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>
|
||||
{selected.initializationState === 'pending' && <p className="ad-hint">交给可信 owner 的初始化定位:userId <span className="ad-mono">{selected.userId}</span> · 状态版本 {selected.stateVersion} · 代数 {selected.initializationGeneration}。凭据只经保护文件交付。</p>}
|
||||
{allowed('system.users.edit') && <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 && <><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') && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
|
||||
</div>
|
||||
{canAssignRoles && roleSearch()}
|
||||
{canCopyRole && roleView?.role && catalog.length > 0 && <Collapsible id={`copy-role-${selected.userId}`} trigger="复制角色并分配…">
|
||||
{fullSourceRole ? copyForm(fullSourceRole)
|
||||
: sourceRoleError ? <p className="lg-err" role="alert"><Icon name="alert" size={14} />{sourceRoleError}</p>
|
||||
: <p className="ad-hint">正在读取源角色权限…</p>}
|
||||
</Collapsible>}
|
||||
{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 || 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 && <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>
|
||||
{sameNameHint(accounts ?? []) && <p className="ad-hint">{sameNameHint(accounts ?? [])}</p>}
|
||||
<table className="ad-table"><thead><tr>
|
||||
<th className="au-select-col"><input type="checkbox" aria-label="全选本页用户" checked={pageState === 'all'} disabled={busy || !pageIds.length}
|
||||
ref={element => { if (element) element.indeterminate = pageState === 'some' }}
|
||||
onChange={event => changeSelection(selectPage(selection, pageIds, event.target.checked))} /></th>
|
||||
<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="au-select-col"><input type="checkbox" aria-label={`选择 ${accountLabel(account)}`} checked={isSelected(selection, account.userId)} disabled={busy}
|
||||
onChange={event => changeSelection(toggleOne(selection, account.userId, event.target.checked))} /></td>
|
||||
<td><div className="au-name-cell"><span>{accountName(account)}</span>
|
||||
{account.profile.name.trim() && <span className="ad-dim ad-mono">{account.owner}</span>}
|
||||
{duplicates.has(account.profile.name.trim()) && <span className="au-same-name">同名 · 按账号辨识</span>}</div></td>
|
||||
<td>{account.profile.staffNo || '—'}</td><td>{roleColumnLabel(canViewRoles ? account.role : undefined)}</td>
|
||||
<td>{account.isAdmin ? '系统管理员' : '普通账号'}</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={8} className="ad-dim">没有匹配用户</td></tr>}
|
||||
{accounts === null && <tr><td colSpan={8} 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>
|
||||
<Collapsible open={batchOpen && canAssignRoles && counts.count > 0} onToggle={value => { if (!value) closeBatch() }}>
|
||||
{batchPanel()}
|
||||
</Collapsible>
|
||||
{counts.count > 0 && <div className="rp-savebar" role="region" aria-label="已选用户">
|
||||
<span className="rp-savebar-summary" aria-live="polite">{selectionSummary(counts)}</span>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => { changeSelection(clearSelection()); closeBatch() }}>清空选择</button>
|
||||
{canAssignRoles && <button className="ad-mini" disabled={busy} aria-expanded={batchOpen} onClick={batchOpen ? closeBatch : openBatch}>为选中的 {counts.count} 人分配角色</button>}
|
||||
</div>}
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import { EmptyState, Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
|
||||
import { ExecEnvPanel } from './ExecEnvPanel'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
import { RdmRolesPanel } from './adminRdmSections'
|
||||
import { AccountUsersPanel } from './AccountUsersPanel'
|
||||
import { RolePermissionsPanel, roleDraftLeaveGuard, hasSystemAction } from './RolePermissionsPanel'
|
||||
import type { AdminZoneSection } from '../api'
|
||||
|
||||
interface AdminSectionDef {
|
||||
@@ -18,6 +19,8 @@ interface AdminSectionDef {
|
||||
label: string
|
||||
icon: IconName
|
||||
desc: string
|
||||
requiredAction?: string
|
||||
rootOnly?: boolean
|
||||
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
|
||||
render: () => React.ReactNode
|
||||
}
|
||||
@@ -27,16 +30,30 @@ function ProfilesPanelWrapper() {
|
||||
return <ProfilesSection profiles={profiles} />
|
||||
}
|
||||
|
||||
/** 账号目录(按系统权限开放)+ 登录凭据与恢复提案(根管理员专属,旧 /api/admin/users 列表与密码端点)。 */
|
||||
function UsersSection() {
|
||||
const isAdmin = useStore(s => !!s.me?.isAdmin)
|
||||
return <>
|
||||
<AccountUsersPanel />
|
||||
{isAdmin && <>
|
||||
<div className="az-section-head" style={{ marginTop: 18 }}>
|
||||
<h3>登录凭据与恢复</h3>
|
||||
<p>建号并发放初始密码、重置临时密码、发起恢复提案(仅根管理员)</p>
|
||||
</div>
|
||||
<UsersPanel />
|
||||
</>}
|
||||
</>
|
||||
}
|
||||
|
||||
const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
|
||||
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
|
||||
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员另可发放与恢复登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
|
||||
{ key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => <RolePermissionsPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => <ProfilesPanelWrapper /> },
|
||||
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
|
||||
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> },
|
||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
|
||||
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => <ExecEnvPanel /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', rootOnly: true, render: () => <SystemSettingsPanel /> },
|
||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', rootOnly: true, render: () => <PluginsSection /> },
|
||||
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', rootOnly: true, render: () => <ExecEnvPanel /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', rootOnly: true, render: () => <PluginWorkspacesPanel /> },
|
||||
]
|
||||
|
||||
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
|
||||
@@ -64,14 +81,15 @@ function DeclaredSection({ section }: { section: AdminZoneSection }) {
|
||||
export function AdminZone() {
|
||||
const me = useStore(s => s.me)
|
||||
const declared = useStore(s => s.adminSections)
|
||||
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
|
||||
const [sectionKey, setSectionKey] = useState('users')
|
||||
const isAdmin = !!me?.isAdmin
|
||||
const systemActions = me?.systemActions ?? []
|
||||
const canOpenSystem = isAdmin || systemActions.length > 0
|
||||
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
|
||||
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
|
||||
if (!isAdmin && declared.length === 0) return null
|
||||
if (!canOpenSystem && declared.length === 0) return null
|
||||
const sections: AdminSectionDef[] = [
|
||||
...(isAdmin ? SECTIONS.filter(s => s.key !== 'rdm-roles' || rdmRolesAvailable) : []),
|
||||
...SECTIONS.filter(section => section.rootOnly ? isAdmin : isAdmin || hasSystemAction(systemActions, section.requiredAction ?? '')),
|
||||
...declared.map(s => ({
|
||||
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
|
||||
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
|
||||
@@ -84,11 +102,15 @@ export function AdminZone() {
|
||||
return (
|
||||
<div className="az" role="dialog" aria-label="系统管理">
|
||||
<div className="az-head">
|
||||
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
|
||||
<button className="btn ghost" onClick={() => {
|
||||
// DR9:带未保存权限草稿离开须显式决定;守卫返回 false 表示用户取消。
|
||||
if (roleDraftLeaveGuard.current && !roleDraftLeaveGuard.current()) return
|
||||
store.setUI({ adminZoneOpen: false })
|
||||
}}>
|
||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||
</button>
|
||||
<span className="az-title">系统管理</span>
|
||||
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
|
||||
<span className="az-sub">{isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
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 }
|
||||
|
||||
@@ -19,6 +21,8 @@ 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 }
|
||||
@@ -36,7 +40,14 @@ function useNodeDetail(pid: string, sel?: CockpitNodeRef) {
|
||||
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) setDetail(d) })
|
||||
.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])
|
||||
@@ -111,7 +122,7 @@ export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
||||
</div>
|
||||
{isSection && detail && (
|
||||
<>
|
||||
{renderBody(detail.body, detail.meta)}
|
||||
{renderBody(detail.body, detail.meta, detail.citations, `sec-${selection.ref.id}`)}
|
||||
<VersionBlock versions={detail.versions} density={density} />
|
||||
</>
|
||||
)}
|
||||
@@ -126,9 +137,13 @@ export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
||||
? <span className="cockpit-badge">内容受限({sec.blocks[0].reason || '权限'})</span>
|
||||
: sec.contentState === 'empty' && <span className="cockpit-badge">空</span>}
|
||||
</div>
|
||||
{sec.blocks.map((b, i) => b.kind === 'text'
|
||||
? <p key={i} className="cockpit-reader-para">{b.text}</p>
|
||||
: <p key={i} className="empty-note">内容受限——标题与状态可见,正文不展示</p>)}
|
||||
{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>
|
||||
))}
|
||||
@@ -138,14 +153,14 @@ export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
||||
)
|
||||
}
|
||||
|
||||
function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta) {
|
||||
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 (
|
||||
<>
|
||||
{body.map((b, i) => <p key={i} className="cockpit-reader-para">{b.text}</p>)}
|
||||
<CitedBody texts={body.map(b => b.text || '')} citations={citations} scope={scope} />
|
||||
{meta.truncated && <div className="cockpit-truncated">详情已截断:{meta.truncationReasons?.join(';')}</div>}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
type CockpitTreeNode, type CockpitTreePage,
|
||||
type Density, type PanelTabId, type ViewMode,
|
||||
} from '../documentCockpit'
|
||||
import { CockpitPanel, CockpitReader } from './CockpitReader'
|
||||
import { CockpitPanel, CockpitReader, VersionBlock } from './CockpitReader'
|
||||
|
||||
interface ProjectView { id: string; name: string; canAudit?: boolean }
|
||||
interface ProjectView { projectId: string; name: string; canAudit?: boolean }
|
||||
|
||||
function CockpitErrorState({ raw }: { raw: string }) {
|
||||
const c = classifyRdmError(raw)
|
||||
@@ -66,10 +66,12 @@ export function DocumentCockpit() {
|
||||
setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('')
|
||||
api.rdmCommand<{ projects?: ProjectView[] } | ProjectView[]>('rdm.project.list')
|
||||
.then(list => {
|
||||
if (!c) return
|
||||
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 }
|
||||
@@ -128,7 +130,7 @@ export function DocumentCockpit() {
|
||||
<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.id} value={p.id}>{p.name}</option>)}
|
||||
{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} ✕
|
||||
@@ -195,7 +197,9 @@ export function DocumentCockpit() {
|
||||
)}
|
||||
{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' })} />
|
||||
)}
|
||||
@@ -287,9 +291,11 @@ function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSel
|
||||
)
|
||||
}
|
||||
|
||||
function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenReader }: {
|
||||
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
|
||||
}) {
|
||||
@@ -300,10 +306,17 @@ function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenRead
|
||||
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) setDetail(d) })
|
||||
.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])
|
||||
}, [pid, selection.ref.kind, selection.ref.id, snapRev])
|
||||
const node = (detail?.node || null) as CockpitTreeNode | null
|
||||
const title = node?.title || selection.ref.id
|
||||
return (
|
||||
@@ -318,6 +331,9 @@ function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenRead
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// 统一渲染 Task 11:整文审签与规则绑定面板。纯展示:入口、载荷与校验来自 documentRenderPresentation,
|
||||
// 异步操作状态由工作区唯一持有并经 props 注入;入口只在服务端允许(能力 ∧ 冻结职责/项目设置权限)时出现。
|
||||
import { useState } from 'react'
|
||||
import { Btn, Chip, SectionLabel } from '@ui'
|
||||
import {
|
||||
BIND_SOURCE_LABELS, validateReviewText, ruleLabel,
|
||||
type BindSource, type BindableRule, type DecisionEntries, type DecisionKind, type DocSubmissionView, type RuleRef,
|
||||
} from '../documentRenderPresentation'
|
||||
import { OperationStatus, type OperationState } from './renderFeedback'
|
||||
|
||||
export const ACTION_OP_LABELS: Record<string, string> = {
|
||||
approve: '整文审核通过', reject: '整文驳回', final: '整文终批', bind: '绑定规则', unbind: '解绑规则',
|
||||
}
|
||||
|
||||
const DECISION_LABELS: Record<DecisionKind, string> = { approve: '审核通过', reject: '驳回', final: '终批' }
|
||||
const SUBMISSION_STATE: Record<string, { label: string; tone: 'ok' | 'info' | 'danger' | 'plain' }> = {
|
||||
submitted: { label: '审签中', tone: 'info' },
|
||||
rejected: { label: '已驳回', tone: 'danger' },
|
||||
final: { label: '已终批', tone: 'ok' },
|
||||
}
|
||||
|
||||
export function ReviewDecisionPanel({ sub, entries, busy, status, onRetry, onDecide }: {
|
||||
sub: DocSubmissionView
|
||||
entries: DecisionEntries
|
||||
busy: boolean
|
||||
status: OperationState
|
||||
onRetry: () => void
|
||||
onDecide: (kind: DecisionKind, text: string) => void
|
||||
}) {
|
||||
const [text, setText] = useState('')
|
||||
const [hint, setHint] = useState('')
|
||||
const reviewerIds = Object.keys(sub.reviews)
|
||||
const approvedCount = reviewerIds.filter(id => sub.reviews[id] === true).length
|
||||
const stateView = SUBMISSION_STATE[sub.state] ?? { label: '状态未知', tone: 'plain' as const }
|
||||
const anyEntry = entries.approve || entries.reject || entries.final
|
||||
|
||||
const decide = (kind: DecisionKind) => {
|
||||
// 驳回必须给出原因;审核通过与终批的说明可选(服务端同样可选)。
|
||||
if (kind === 'reject') {
|
||||
const bad = validateReviewText(text)
|
||||
if (bad) { setHint(bad); return }
|
||||
}
|
||||
setHint('')
|
||||
onDecide(kind, text)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="drw-row drw-row-between">
|
||||
<SectionLabel>整文审签</SectionLabel>
|
||||
<span className="drw-row">
|
||||
<Chip tone={stateView.tone}>{stateView.label}</Chip>
|
||||
<span className="drw-muted">第 {sub.round} 轮 · 审核 {approvedCount}/{reviewerIds.length}</span>
|
||||
</span>
|
||||
</div>
|
||||
{anyEntry && (
|
||||
<>
|
||||
<textarea className="ui-input drw-textarea" aria-label="审签说明" value={text}
|
||||
onChange={e => setText(e.target.value)} placeholder="说明可留空;驳回时必须填写原因" />
|
||||
{hint && <div className="drw-inline-err">{hint}</div>}
|
||||
<div className="drw-row">
|
||||
{entries.approve && <Btn variant="ghost" disabled={busy} onClick={() => decide('approve')}>{DECISION_LABELS.approve}</Btn>}
|
||||
{entries.reject && <Btn variant="danger" disabled={busy} onClick={() => decide('reject')}>{DECISION_LABELS.reject}</Btn>}
|
||||
{entries.final && <Btn variant="primary" disabled={busy} onClick={() => decide('final')}>{DECISION_LABELS.final}</Btn>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<OperationStatus state={status} onRetry={onRetry} labels={ACTION_OP_LABELS} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SCOPES: BindSource[] = ['document', 'document-type', 'project-default']
|
||||
const ruleKey = (r: RuleRef) => `${r.profileId}@${r.version}`
|
||||
|
||||
export function ProfileBindingPanel({ rules, bindings, typeKey, busy, status, onRetry, onBind, onUnbind }: {
|
||||
rules: BindableRule[]
|
||||
bindings: Partial<Record<BindSource, RuleRef>>
|
||||
typeKey: string | undefined
|
||||
busy: boolean
|
||||
status: OperationState
|
||||
onRetry: () => void
|
||||
onBind: (source: BindSource, rule: RuleRef) => void
|
||||
onUnbind: (source: BindSource) => void
|
||||
}) {
|
||||
const [choice, setChoice] = useState('')
|
||||
const selected = rules.find(r => ruleKey(r) === choice)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionLabel>规则绑定</SectionLabel>
|
||||
{rules.length === 0 ? (
|
||||
<div className="empty-note">没有可绑定的已发布规则</div>
|
||||
) : (
|
||||
<div className="drw-bind-rules" role="radiogroup" aria-label="可绑定规则">
|
||||
{rules.map(r => (
|
||||
<label key={ruleKey(r)} className={`drw-bind-rule${choice === ruleKey(r) ? ' sel' : ''}`}>
|
||||
<input type="radio" name="drw-bind-rule" value={ruleKey(r)} checked={choice === ruleKey(r)} onChange={() => setChoice(ruleKey(r))} />
|
||||
<span className="drw-mono">{ruleLabel(r)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{SCOPES.map(source => {
|
||||
const bound = bindings[source]
|
||||
const needsType = source === 'document-type'
|
||||
const bindDisabled = busy || !selected || (needsType && !typeKey)
|
||||
return (
|
||||
<div key={source} className="drw-bind-row">
|
||||
<div className="drw-row drw-row-between">
|
||||
<span className="drw-profile-source">{BIND_SOURCE_LABELS[source]}</span>
|
||||
<span className="drw-mono">{bound ? ruleLabel(bound) : '未绑定'}</span>
|
||||
</div>
|
||||
<div className="drw-row">
|
||||
<Btn variant="ghost" disabled={bindDisabled} onClick={() => selected && onBind(source, selected)}>绑定所选规则</Btn>
|
||||
{bound && <Btn variant="ghost" disabled={busy} onClick={() => onUnbind(source)}>解绑</Btn>}
|
||||
</div>
|
||||
{needsType && !typeKey && <div className="drw-muted">文档类型未就绪,暂不能绑定文档类型规则</div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<OperationStatus state={status} onRetry={onRetry} labels={ACTION_OP_LABELS} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(恢复/激活,只输 Key);
|
||||
// LoginPage —— 用户名+密码唯一入口;
|
||||
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
|
||||
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
|
||||
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
||||
@@ -33,10 +33,8 @@ function BrandPanel() {
|
||||
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
|
||||
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
|
||||
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
|
||||
const [tab, setTab] = useState<'password' | 'loginkey'>('password')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [key, setKey] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
@@ -56,51 +54,20 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const submitKey = async () => {
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
// 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。
|
||||
const me = await api.redeemLoginKey(key.trim())
|
||||
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
||||
onMustChange(me.owner)
|
||||
return
|
||||
}
|
||||
onAuthed()
|
||||
} catch (e) {
|
||||
setErr(humanRedeemError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lg-wrap">
|
||||
<BrandPanel />
|
||||
<div className="lg-formside">
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); submitPassword() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">登录</h2>
|
||||
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key</p>
|
||||
<div className="lg-tabs" role="tablist">
|
||||
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
|
||||
</div>
|
||||
{tab === 'password' ? (
|
||||
<>
|
||||
<label className="lg-label">用户名</label>
|
||||
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
||||
<label className="lg-label">密码</label>
|
||||
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="lg-label">一次性登录 Key</label>
|
||||
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
|
||||
<p className="lg-hint">忘记密码时由管理员发起恢复提案,并由你在已登录设备的「设置 → 账号」中本人确认后获得;新账号使用管理员交付的激活 Key。单次有效、限时兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
|
||||
</>
|
||||
)}
|
||||
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员重置临时密码</p>
|
||||
<label className="lg-label">用户名</label>
|
||||
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
||||
<label className="lg-label">密码</label>
|
||||
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,9 +166,9 @@ export function humanLoginError(raw: string): string {
|
||||
|
||||
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
|
||||
export function humanRedeemError(raw: string): string {
|
||||
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
|
||||
if (raw.includes('409')) return '该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化'
|
||||
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
|
||||
if (raw.includes('401')) return '能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { startLatestFetch, TreeNodeRow } from './RdmAuditWorkspace'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
let reject!: (e: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const flush = () => new Promise(r => setTimeout(r, 0))
|
||||
|
||||
describe('startLatestFetch(历史请求单一所有者)', () => {
|
||||
it('较新的请求已发起时丢弃过期响应', async () => {
|
||||
const ref = { current: 0 }
|
||||
const onData = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const older = deferred<string>()
|
||||
const newer = deferred<string>()
|
||||
startLatestFetch(ref, () => older.promise, onData, onError)
|
||||
startLatestFetch(ref, () => newer.promise, onData, onError)
|
||||
newer.resolve('newer')
|
||||
await flush()
|
||||
older.resolve('older')
|
||||
await flush()
|
||||
expect(onData.mock.calls).toEqual([['newer']])
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('过期请求的失败不写回错误', async () => {
|
||||
const ref = { current: 0 }
|
||||
const onError = vi.fn()
|
||||
const older = deferred<string>()
|
||||
startLatestFetch(ref, () => older.promise, vi.fn(), onError)
|
||||
startLatestFetch(ref, () => new Promise<string>(() => {}), vi.fn(), onError)
|
||||
older.reject(new Error('stale'))
|
||||
await flush()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('清理(卸载或参数变化)后在途响应被丢弃', async () => {
|
||||
const ref = { current: 0 }
|
||||
const onData = vi.fn()
|
||||
const pending = deferred<string>()
|
||||
const cleanup = startLatestFetch(ref, () => pending.promise, onData, vi.fn())
|
||||
cleanup()
|
||||
pending.resolve('late')
|
||||
await flush()
|
||||
expect(onData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('只推进自己的 ref,不影响其他 seq 所有者', () => {
|
||||
const historyRef = { current: 0 }
|
||||
const eventsRef = { current: 7 }
|
||||
startLatestFetch(historyRef, () => new Promise<string>(() => {}), vi.fn(), vi.fn())
|
||||
expect(historyRef.current).toBe(1)
|
||||
expect(eventsRef.current).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TreeNodeRow 无障碍属性', () => {
|
||||
const noop = () => {}
|
||||
const render = (item: { kind: string; id: string; title: string; hasChildren?: boolean }, children = {}) =>
|
||||
renderToStaticMarkup(<TreeNodeRow item={item} children={children} onSelect={noop} onLoadMore={noop} onOpenDoc={noop} />)
|
||||
|
||||
it('可展开行:折叠时 aria-expanded=false,箭头 aria-hidden', () => {
|
||||
const html = render({ kind: 'spine', id: 's1', title: '骨架' })
|
||||
expect(html).toContain('aria-expanded="false"')
|
||||
expect(html).toMatch(/<span class="rdm-audit-tree-caret" aria-hidden="true">▸<\/span>/)
|
||||
})
|
||||
|
||||
it('可展开行:已加载下级时 aria-expanded=true', () => {
|
||||
const html = render({ kind: 'spine', id: 's1', title: '骨架' }, { s1: { items: [], hasMore: false, truncated: false } })
|
||||
expect(html).toContain('aria-expanded="true"')
|
||||
expect(html).toMatch(/<span class="rdm-audit-tree-caret" aria-hidden="true">▾<\/span>/)
|
||||
})
|
||||
|
||||
it('不可展开行省略 aria-expanded 与箭头', () => {
|
||||
const html = render({ kind: 'doc', id: 'd1', title: '文档' })
|
||||
expect(html).not.toContain('aria-expanded')
|
||||
expect(html).not.toContain('rdm-audit-tree-caret')
|
||||
})
|
||||
})
|
||||
@@ -5,19 +5,35 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
|
||||
import { classifyRdmError } from '../rdmErrors'
|
||||
import {
|
||||
describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable,
|
||||
relationOtherEndpoint,
|
||||
type AuditEventViewInput,
|
||||
} from '../rdmAuditPresentation'
|
||||
import {
|
||||
collectRecursiveTree, isTreeItemInspectable, mergeTreePage,
|
||||
type AuditTreeItem, type AuditTreePage,
|
||||
} from '../rdmAuditTree'
|
||||
import { store, useStore } from '../store'
|
||||
import { exportNeedsDraft } from '../workspaces'
|
||||
import {
|
||||
CITATION_CHIP_TONE, citationStateLabel, citationStyle, legStateLabel, REG_TREE_KIND_LABELS,
|
||||
regsTreeParams, sealStateLabel,
|
||||
type RegBatchDetail, type RegCitationDetail,
|
||||
} from '../regCitations'
|
||||
import { DocumentRenderWorkspace, saveBase64File } from './DocumentRenderWorkspace'
|
||||
import { CitationCard, CitationListItem, CitationStateChips } from './RegCitation'
|
||||
|
||||
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
||||
|
||||
interface InspectProject { projectId: string; name: string; canAudit: boolean }
|
||||
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
|
||||
interface CoverageItem { face: string; reason: string }
|
||||
interface ListShell<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
|
||||
interface ListShell<T = any> { items: T[]; businessAudit?: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
|
||||
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
|
||||
interface TreeItem { kind: string; id: string; title: string; state?: string }
|
||||
type TreeItem = AuditTreeItem
|
||||
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
|
||||
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
|
||||
interface EventDTO extends AuditEventViewInput {}
|
||||
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
|
||||
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
|
||||
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
|
||||
@@ -32,6 +48,23 @@ const TABS = [
|
||||
] as const
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
const OPERATION_FILTERS = [
|
||||
['', '全部操作'],
|
||||
['doc.instantiate', '创建文档实例'],
|
||||
['section.add', '新增文档章节'],
|
||||
['slice.ingest', '新增项目资料'],
|
||||
['edge.candidate', '提出追溯候选'],
|
||||
['edge.confirm', '确认追溯关系'],
|
||||
['change.requested', '提出变更申请'],
|
||||
['change.applied', '应用变更'],
|
||||
['open-item.add', '登记项目问题'],
|
||||
['open-item.close', '关闭项目问题'],
|
||||
['rdm.work.observe', '拆解工作来源'],
|
||||
['rdm.work.risk', '发现项目风险'],
|
||||
['rdm.work.risk.answer', '回答风险澄清'],
|
||||
['rdm.structure.decide', '决定结构变更'],
|
||||
] as const
|
||||
|
||||
/** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断;
|
||||
* 「无可审计项目」由调用处独立空态承担,不走本组件。 */
|
||||
function RdmErrorState({ raw }: { raw: string }) {
|
||||
@@ -93,7 +126,7 @@ export function RdmAuditWorkspace() {
|
||||
</div>
|
||||
<div className="rdm-audit-body">
|
||||
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
|
||||
{tab === 'materials' && <MaterialsTab pid={pid} />}
|
||||
{tab === 'materials' && <MaterialsTab key={pid} pid={pid} />}
|
||||
{tab === 'trace' && <TraceTab pid={pid} />}
|
||||
{tab === 'timeline' && <TimelineTab pid={pid} />}
|
||||
</div>
|
||||
@@ -169,19 +202,6 @@ interface ExportDownloaded {
|
||||
sha256: string
|
||||
}
|
||||
|
||||
function saveExportFile(fileName: string, mime: string, dataB64: string) {
|
||||
const bin = atob(dataB64)
|
||||
const bytes = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||
const blob = new Blob([bytes], { type: mime || 'application/octet-stream' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function ExportSection({ pid }: { pid: string }) {
|
||||
const identity = useStore(() => store.getIdentityEpoch())
|
||||
const generation = useRef(0)
|
||||
@@ -231,7 +251,7 @@ function ExportSection({ pid }: { pid: string }) {
|
||||
try {
|
||||
const dl = await api.rdmCommand<ExportDownloaded>('rdm.inspect.export.download', { projectId: pid, exportId: artifact.exportId })
|
||||
if (!current()) return
|
||||
saveExportFile(dl.fileName, dl.mime, dl.dataB64)
|
||||
saveBase64File(dl.fileName, dl.mime, dl.dataB64)
|
||||
} catch (e) {
|
||||
if (current()) setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
@@ -275,33 +295,221 @@ function ExportSection({ pid }: { pid: string }) {
|
||||
// ---- 资料与文档 ----
|
||||
|
||||
function MaterialsTab({ pid }: { pid: string }) {
|
||||
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
|
||||
const [kind, setKind] = useState<'notes' | 'docs' | 'regs'>('docs')
|
||||
// 打开文档时树列表保持挂载(只隐藏),返回后展开状态不丢。
|
||||
const [openDoc, setOpenDoc] = useState<TreeItem | null>(null)
|
||||
return (
|
||||
<div className="rdm-audit-materials">
|
||||
<div className="rdm-audit-materials-side">
|
||||
{openDoc && (
|
||||
<DocumentRenderWorkspace projectId={pid} docId={openDoc.id} docTitle={openDoc.title} onBack={() => setOpenDoc(null)} />
|
||||
)}
|
||||
<div className="rdm-audit-materials-side" hidden={!!openDoc}>
|
||||
<div className="tabs">
|
||||
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
||||
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
||||
<button className={`tab${kind === 'regs' ? ' active' : ''}`} onClick={() => setKind('regs')}>法规核验</button>
|
||||
</div>
|
||||
<TreeList pid={pid} kind={kind} />
|
||||
{kind === 'regs' ? <RegsTreeList pid={pid} /> : <TreeList pid={pid} kind={kind} onOpenDoc={setOpenDoc} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
// ---- 法规核验树(U7:rdm.inspect.tree kind=regs;根层证据批,parent=<batchId> 列引用) ----
|
||||
|
||||
function RegTreeNode({ item, selected, onSelect }: { item: TreeItem; selected: boolean; onSelect: () => void }) {
|
||||
return (
|
||||
<button className={`rdm-audit-tree-node${selected ? ' sel' : ''}`} onClick={onSelect}>
|
||||
<span className="rdm-audit-tree-kind">{REG_TREE_KIND_LABELS[item.kind] || item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title || item.id}</span>
|
||||
{item.state && <Chip tone={CITATION_CHIP_TONE[citationStyle({ state: item.state })]}>{citationStateLabel(item.state)}</Chip>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function RegsTreeList({ pid }: { pid: string }) {
|
||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [children, setChildren] = useState<Record<string, ListShell<TreeItem>>>({})
|
||||
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||
const [detail, setDetail] = useState<RegBatchDetail | RegCitationDetail | null>(null)
|
||||
const [detailErr, setDetailErr] = useState('')
|
||||
// genRef:项目切换代际(树与子页);selRef:选中代际(快速切换时丢弃旧详情响应)。
|
||||
const genRef = useRef(0)
|
||||
const selRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const gen = ++genRef.current
|
||||
selRef.current++
|
||||
setShell(null); setErr(''); setSelected(null); setDetail(null); setDetailErr(''); setChildren({})
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid))
|
||||
.then(s => { if (gen === genRef.current) setShell({ ...s, items: s.items || [] }) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid])
|
||||
|
||||
const loadMore = () => {
|
||||
if (!shell?.nextCursor) return
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid, { cursor: shell.nextCursor }))
|
||||
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...(s.items || [])] } : s) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const loadChildren = (batchId: string, cursor?: string) => {
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid, { parent: batchId, cursor }))
|
||||
.then(s => {
|
||||
if (gen !== genRef.current) return
|
||||
setChildren(prev => {
|
||||
const items = cursor ? [...(prev[batchId]?.items || []), ...(s.items || [])] : (s.items || [])
|
||||
return { ...prev, [batchId]: { ...s, items } }
|
||||
})
|
||||
})
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const toggleBatch = (item: TreeItem) => {
|
||||
if (children[item.id]) {
|
||||
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
|
||||
return
|
||||
}
|
||||
loadChildren(item.id)
|
||||
}
|
||||
|
||||
const selectNode = (item: TreeItem) => {
|
||||
setSelected(item); setDetail(null); setDetailErr('')
|
||||
const gen = genRef.current
|
||||
const sel = ++selRef.current
|
||||
const current = () => gen === genRef.current && sel === selRef.current
|
||||
api.rdmCommand<RegBatchDetail | RegCitationDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||
.then(d => { if (current()) setDetail(d) })
|
||||
.catch(e => { if (current()) setDetailErr(e.message) })
|
||||
}
|
||||
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
if (!shell) return <div className="empty-note">加载中…</div>
|
||||
if (shell.items.length === 0) return <EmptyState icon="folder">暂无法规核验证据批</EmptyState>
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-tree">
|
||||
<div className="rdm-audit-tree-list">
|
||||
{shell.items.map(item => {
|
||||
const sub = children[item.id]
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div className="regcite-tree-row">
|
||||
<button type="button" className="regcite-tree-caret" aria-label={sub ? '收起引用' : '展开引用'}
|
||||
aria-expanded={!!sub} onClick={() => toggleBatch(item)}>{sub ? '▾' : '▸'}</button>
|
||||
<RegTreeNode item={item} selected={selected?.id === item.id} onSelect={() => selectNode(item)} />
|
||||
</div>
|
||||
{sub && (
|
||||
<div className="rdm-audit-tree-children">
|
||||
{sub.items.length === 0 && <div className="empty-note">无引用</div>}
|
||||
{sub.items.map(c => (
|
||||
<RegTreeNode key={c.id} item={c} selected={selected?.id === c.id} onSelect={() => selectNode(c)} />
|
||||
))}
|
||||
{sub.hasMore && (
|
||||
<button className="btn v-ghost rdm-audit-more" onClick={() => loadChildren(item.id, sub.nextCursor)}>加载更多引用</button>
|
||||
)}
|
||||
{sub.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
|
||||
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="rdm-audit-detail">
|
||||
<SectionLabel>核验详情</SectionLabel>
|
||||
{detailErr ? <RdmErrorState raw={detailErr} />
|
||||
: !detail ? <div className="empty-note">加载中…</div>
|
||||
: selected.kind === 'reg-batch'
|
||||
? <RegBatchView item={selected} detail={detail as RegBatchDetail} />
|
||||
: <RegCitationView item={selected} detail={detail as RegCitationDetail} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RegBatchView({ item, detail }: { item: TreeItem; detail: RegBatchDetail }) {
|
||||
const reasons = detail.stateReasons || []
|
||||
const citations = detail.citations || []
|
||||
const resolutions = detail.resolutions || []
|
||||
return (
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div><strong>{detail.title || item.title || item.id}</strong></div>
|
||||
<div className="regcite-mono">reg-batch:{item.id}</div>
|
||||
<div>状态:<CitationStateChips c={{ state: detail.state }} /></div>
|
||||
{reasons.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>状态原因</SectionLabel>
|
||||
<ul className="regcite-reasons">{reasons.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
{detail.legStates && (
|
||||
<div>
|
||||
<SectionLabel>来源腿</SectionLabel>
|
||||
<dl className="regcite-fields">
|
||||
<dt>知识库</dt><dd>{legStateLabel(detail.legStates.kb, detail.legStates.kbReason)}</dd>
|
||||
<dt>网络</dt><dd>{legStateLabel(detail.legStates.web, detail.legStates.webReason)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{detail.seal && (
|
||||
<div>
|
||||
<SectionLabel>封存</SectionLabel>
|
||||
<dl className="regcite-fields">
|
||||
<dt>状态</dt><dd>{sealStateLabel(detail.seal.state)}</dd>
|
||||
<dt>密钥</dt><dd className="regcite-mono">{detail.seal.keyId || '—'}</dd>
|
||||
<dt>清单哈希</dt><dd className="regcite-mono">{detail.seal.manifestSha256 || '—'}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<SectionLabel>引用({citations.length})</SectionLabel>
|
||||
{citations.length === 0 && <div className="empty-note">该批没有引用</div>}
|
||||
{citations.map(c => <CitationListItem key={c.id} id={c.id} citation={c} scope={`audit-${item.id}`} />)}
|
||||
</div>
|
||||
{resolutions.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>人工裁决({resolutions.length})</SectionLabel>
|
||||
{resolutions.map((r, i) => (
|
||||
<div key={i} className="rdm-audit-version">
|
||||
{r.decision} · {r.actor} · {new Date(r.at).toLocaleString('zh-CN')}{r.note ? ` · ${r.note}` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RegCitationView({ item, detail }: { item: TreeItem; detail: RegCitationDetail }) {
|
||||
return (
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div><strong>{detail.title || item.title || item.id}</strong></div>
|
||||
<CitationCard id={item.id} citation={detail.citation} scope="audit" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeList({ pid, kind, onOpenDoc }: { pid: string; kind: 'notes' | 'docs'; onOpenDoc: (doc: TreeItem) => void }) {
|
||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||
const [detail, setDetail] = useState<ObjectDetail | null>(null)
|
||||
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
|
||||
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
|
||||
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
|
||||
const [detailErr, setDetailErr] = useState('')
|
||||
const [children, setChildren] = useState<Record<string, AuditTreePage>>({})
|
||||
const genRef = useRef(0)
|
||||
const detailGenRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const gen = ++genRef.current
|
||||
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
|
||||
detailGenRef.current++
|
||||
setShell(null); setErr(''); setSelected(null); setDetail(null); setDetailErr(''); setChildren({})
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setShell(s) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
@@ -315,24 +523,42 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const toggleDoc = (item: TreeItem) => {
|
||||
const toggleNode = (item: TreeItem) => {
|
||||
if (children[item.id]) {
|
||||
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
|
||||
return
|
||||
}
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) })
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, parent: item.id, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s })) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const loadMoreChildren = (item: TreeItem) => {
|
||||
const current = children[item.id]
|
||||
if (!current?.nextCursor) return
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', {
|
||||
projectId: pid, kind, parent: item.id, cursor: current.nextCursor,
|
||||
})
|
||||
.then(s => {
|
||||
if (gen !== genRef.current) return
|
||||
setChildren(prev => ({ ...prev, [item.id]: mergeTreePage(prev[item.id], s) }))
|
||||
})
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const selectNode = (item: TreeItem) => {
|
||||
if (item.kind === 'doc') { toggleDoc(item); return }
|
||||
setSelected(item); setDetail(null)
|
||||
const gen = genRef.current
|
||||
if (isTreeItemExpandable(item)) toggleNode(item)
|
||||
const detailGen = ++detailGenRef.current
|
||||
if (!isTreeItemInspectable(item)) {
|
||||
setSelected(null); setDetail(null); setDetailErr('')
|
||||
return
|
||||
}
|
||||
setSelected(item); setDetail(null); setDetailErr('')
|
||||
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||
.then(d => { if (gen === genRef.current) setDetail(d) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
.then(d => { if (detailGen === detailGenRef.current) setDetail(d) })
|
||||
.catch(e => { if (detailGen === detailGenRef.current) setDetailErr(e.message) })
|
||||
}
|
||||
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
@@ -342,37 +568,53 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
return (
|
||||
<div className="rdm-audit-tree">
|
||||
<div className="rdm-audit-tree-list">
|
||||
{shell.items.map(item => (
|
||||
<div key={item.id}>
|
||||
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(item)}>
|
||||
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
|
||||
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
||||
</button>
|
||||
{item.kind === 'doc' && children[item.id] && (
|
||||
<div className="rdm-audit-tree-children">
|
||||
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
|
||||
{children[item.id].map(sub => (
|
||||
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(sub)}>
|
||||
<span className="rdm-audit-tree-kind">{sub.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{sub.title}</span>
|
||||
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{shell.items.map(item => <TreeNodeRow key={item.id} item={item} selectedId={selected?.id} children={children} onSelect={selectNode} onLoadMore={loadMoreChildren} onOpenDoc={onOpenDoc} />)}
|
||||
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
|
||||
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="rdm-audit-detail">
|
||||
<SectionLabel>对象详情</SectionLabel>
|
||||
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
|
||||
{detailErr ? <RdmErrorState raw={detailErr} /> : !detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore, onOpenDoc }: {
|
||||
item: TreeItem
|
||||
selectedId?: string
|
||||
children: Record<string, AuditTreePage>
|
||||
onSelect: (item: TreeItem) => void
|
||||
onLoadMore: (item: TreeItem) => void
|
||||
onOpenDoc: (doc: TreeItem) => void
|
||||
}) {
|
||||
const expandable = isTreeItemExpandable(item)
|
||||
const nestedPage = children[item.id]
|
||||
const nested = nestedPage?.items
|
||||
const readable = describeTreeItem(item)
|
||||
return (
|
||||
<div>
|
||||
<div className="rdm-audit-tree-row">
|
||||
<button className={`rdm-audit-tree-node${selectedId === item.id ? ' sel' : ''}`} onClick={() => onSelect(item)}
|
||||
aria-expanded={expandable ? Boolean(nested) : undefined}>
|
||||
{expandable && <span className="rdm-audit-tree-caret" aria-hidden="true">{nested ? '▾' : '▸'}</span>}
|
||||
<span className="rdm-audit-tree-kind">{readable.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{readable.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : item.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(item.state)}</Chip>}
|
||||
</button>
|
||||
{item.kind === 'doc' && (
|
||||
<button type="button" className="btn v-ghost rdm-audit-tree-open" onClick={() => onOpenDoc(item)}
|
||||
aria-label={`阅读与交付:${readable.title}`}>阅读与交付</button>
|
||||
)}
|
||||
</div>
|
||||
{nested && (
|
||||
<div className="rdm-audit-tree-children">
|
||||
{nested.length === 0 && <div className="empty-note">无下级节点</div>}
|
||||
{nested.map(child => <TreeNodeRow key={child.id} item={child} selectedId={selectedId} children={children} onSelect={onSelect} onLoadMore={onLoadMore} onOpenDoc={onOpenDoc} />)}
|
||||
{nestedPage.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => onLoadMore(item)}>加载更多下级节点</button>}
|
||||
{nestedPage.truncated && <div className="rdm-audit-truncated">该节点的下级内容已截断</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -397,21 +639,17 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, sel])
|
||||
|
||||
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
|
||||
// (parent=<docId>),按文档聚合后合并展示。
|
||||
const [sections, setSections] = useState<TreeItem[]>([])
|
||||
const [treeItems, setTreeItems] = useState<TreeItem[]>([])
|
||||
const [pickErr, setPickErr] = useState('')
|
||||
useEffect(() => {
|
||||
if (sel) return
|
||||
let c = false
|
||||
;(async () => {
|
||||
const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
|
||||
const secs: TreeItem[] = []
|
||||
for (const d of top.items.filter(i => i.kind === 'doc')) {
|
||||
const sub = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id })
|
||||
secs.push(...sub.items.filter(i => i.kind === 'section'))
|
||||
}
|
||||
if (!c) setSections(secs)
|
||||
})().catch(() => {})
|
||||
setTreeItems([]); setPickErr('')
|
||||
const items = await collectRecursiveTree((parent, cursor) =>
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent, cursor }))
|
||||
if (!c) setTreeItems(items.filter(item => item.kind !== 'doc'))
|
||||
})().catch(e => { if (!c) setPickErr(e instanceof Error ? e.message : String(e)) })
|
||||
return () => { c = true }
|
||||
}, [pid, sel])
|
||||
|
||||
@@ -419,13 +657,14 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>选择对象查看来源与影响</SectionLabel>
|
||||
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
|
||||
{pickErr ? <RdmErrorState raw={pickErr} /> : treeItems.length === 0 ? <EmptyState icon="folder">暂无可追溯对象</EmptyState> : (
|
||||
<div className="rdm-audit-pick">
|
||||
{sections.map(s => (
|
||||
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
|
||||
{s.title}
|
||||
{treeItems.map(item => {
|
||||
const readable = describeTreeItem(item)
|
||||
return <button key={item.id} className="btn v-ghost" onClick={() => setSel({ kind: item.kind, id: item.id, title: readable.title })}>
|
||||
{readable.kind} · {readable.title}
|
||||
</button>
|
||||
))}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -440,7 +679,7 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
<div className="rdm-audit-trace-head">
|
||||
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
|
||||
<strong>{sel.title}</strong>
|
||||
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
|
||||
<Chip tone="plain">{humanizeKind(sel.kind)} · {sel.id}</Chip>
|
||||
</div>
|
||||
{err && <RdmErrorState raw={err} />}
|
||||
{!relations && !err && <div className="empty-note">加载中…</div>}
|
||||
@@ -449,11 +688,11 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
<div className="rdm-audit-trace-cols">
|
||||
<div className="rdm-audit-trace-col">
|
||||
<SectionLabel>来源依据({sources.length})</SectionLabel>
|
||||
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} selected={sel} />)}
|
||||
</div>
|
||||
<div className="rdm-audit-trace-col">
|
||||
<SectionLabel>影响对象({impacts.length})</SectionLabel>
|
||||
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} selected={sel} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -462,20 +701,35 @@ function TraceTab({ pid }: { pid: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RelationRow({ r }: { r: RelationEntry }) {
|
||||
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
|
||||
function RelationRow({ r, selected }: { r: RelationEntry; selected: { kind: string; id: string } }) {
|
||||
const other = relationOtherEndpoint(r, selected)
|
||||
const readable = humanizeRelation(r)
|
||||
return (
|
||||
<div className="rdm-audit-rel">
|
||||
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
|
||||
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
|
||||
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
|
||||
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
|
||||
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{readable.layer}</Chip>
|
||||
<span className="rdm-audit-rel-type">{readable.type}</span>
|
||||
<span className="rdm-audit-rel-endpoint">{humanizeKind(other?.kind)}“{other?.title || other?.id || '未知对象'}”{other?.ver ? `(版本 ${other.ver})` : ''}</span>
|
||||
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' || r.state === 'stale' ? 'warn' : 'plain'}>{readable.state}</Chip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 操作记录(事件时间线) ----
|
||||
|
||||
// 单一所有者:仅最近一次发起的请求可写回;返回的清理函数作废在途请求。
|
||||
export function startLatestFetch<T>(
|
||||
genRef: { current: number },
|
||||
request: () => Promise<T>,
|
||||
onData: (data: T) => void,
|
||||
onError: (message: string) => void,
|
||||
): () => void {
|
||||
const gen = ++genRef.current
|
||||
request()
|
||||
.then(d => { if (gen === genRef.current) onData(d) })
|
||||
.catch(e => { if (gen === genRef.current) onError(e instanceof Error ? e.message : String(e)) })
|
||||
return () => { if (gen === genRef.current) genRef.current++ }
|
||||
}
|
||||
|
||||
function TimelineTab({ pid }: { pid: string }) {
|
||||
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
|
||||
const [cursor, setCursor] = useState('')
|
||||
@@ -487,6 +741,7 @@ function TimelineTab({ pid }: { pid: string }) {
|
||||
const [historySeq, setHistorySeq] = useState(0)
|
||||
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
|
||||
const genRef = useRef(0)
|
||||
const historyGenRef = useRef(0)
|
||||
|
||||
const load = useCallback((cur: string, replace: boolean) => {
|
||||
const gen = ++genRef.current
|
||||
@@ -497,7 +752,8 @@ function TimelineTab({ pid }: { pid: string }) {
|
||||
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
|
||||
.then(s => {
|
||||
if (gen !== genRef.current) return
|
||||
setItems(prev => replace ? s.items : [...prev, ...s.items])
|
||||
const page = replace ? [...(s.businessAudit || []), ...s.items] : s.items
|
||||
setItems(prev => replace ? page : [...prev, ...page])
|
||||
setCursor(s.nextCursor || '')
|
||||
setHasMore(s.hasMore)
|
||||
})
|
||||
@@ -508,12 +764,9 @@ function TimelineTab({ pid }: { pid: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyMode) { setHistoryData(null); return }
|
||||
const gen = genRef.current
|
||||
setHistoryData(null)
|
||||
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
|
||||
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
|
||||
.then(d => { if (gen === genRef.current) setHistoryData(d) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
return startLatestFetch(historyGenRef, () => api.rdmCommand<HistoryData>('rdm.inspect.history', params), setHistoryData, setErr)
|
||||
}, [pid, historyMode, historySeq])
|
||||
|
||||
return (
|
||||
@@ -521,8 +774,9 @@ function TimelineTab({ pid }: { pid: string }) {
|
||||
<div className="rdm-audit-timeline-filters">
|
||||
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
|
||||
onChange={e => setFilterActor(e.target.value)} />
|
||||
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
|
||||
onChange={e => setFilterOp(e.target.value)} />
|
||||
<select className="ui-input" aria-label="按操作筛选" value={filterOp} onChange={e => setFilterOp(e.target.value)}>
|
||||
{OPERATION_FILTERS.map(([value, label]) => <option key={value || 'all'} value={value}>{label}</option>)}
|
||||
</select>
|
||||
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
|
||||
{historyMode ? '← 返回当前' : '历史模式'}
|
||||
</button>
|
||||
@@ -573,27 +827,38 @@ function TimelineTab({ pid }: { pid: string }) {
|
||||
}
|
||||
|
||||
function OpGroupView({ group }: { group: OpGroup }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const first = group.events[0] ? describeAuditEvent(group.events[0]) : null
|
||||
return (
|
||||
<div className="rdm-audit-opgroup">
|
||||
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
|
||||
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
|
||||
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
|
||||
<Chip tone="plain">{group.events.length} 个事件</Chip>
|
||||
</button>
|
||||
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||||
<Collapsible id={`rdm-audit-operation-${group.operationId}`} trigger={(
|
||||
<span className="rdm-audit-opgroup-head">
|
||||
<span className="rdm-audit-opgroup-actor">{first?.subject || group.actor}</span>
|
||||
<span className="rdm-audit-opgroup-action">{first?.action || '项目操作'}</span>
|
||||
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
|
||||
<Chip tone="plain">包含 {group.events.length} 项记录</Chip>
|
||||
</span>
|
||||
)}>
|
||||
{group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ ev }: { ev: EventDTO }) {
|
||||
const view = describeAuditEvent(ev)
|
||||
return (
|
||||
<div className="rdm-audit-event">
|
||||
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
|
||||
<span className="rdm-audit-event-op">{ev.op}</span>
|
||||
<span className="rdm-audit-event-actor">{ev.actor}</span>
|
||||
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
|
||||
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
|
||||
<div className="rdm-audit-event-head">
|
||||
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleString('zh-CN')}</span>
|
||||
<strong className="rdm-audit-event-action">{view.action}</strong>
|
||||
<Chip tone={ev.disclosure === 'ok' && view.outcome === '已生效' ? 'ok' : 'warn'}>{view.outcome}</Chip>
|
||||
</div>
|
||||
<div className="rdm-audit-event-summary">{view.subject}处理了{view.object}</div>
|
||||
<dl className="rdm-audit-event-fields">
|
||||
<div><dt>原因</dt><dd>{view.reason}</dd></div>
|
||||
<div><dt>影响</dt><dd>{view.impact}</dd></div>
|
||||
<div><dt>依据</dt><dd>{view.source}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -604,8 +869,8 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
|
||||
return (
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div><strong>{detail.title || detail.id}</strong></div>
|
||||
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
|
||||
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
|
||||
<div>{humanizeKind(detail.kind)} · 对象编号 {detail.id}{detail.ver ? ` · 版本 ${detail.ver}` : ''}</div>
|
||||
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : detail.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(detail.state)}</Chip></div>}
|
||||
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
|
||||
{detail.versions && detail.versions.length > 0 && (
|
||||
<div>
|
||||
@@ -625,7 +890,7 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
|
||||
{detail.relations && detail.relations.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
|
||||
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
{detail.relations.map((r, i) => <RelationRow key={i} r={r} selected={{ kind: detail.kind, id: detail.id }} />)}
|
||||
</div>
|
||||
)}
|
||||
{detail.recentEvents && detail.recentEvents.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// U7 法规结构化引用的共享呈现:编号标记、引用卡片、引用清单(审计工作区与驾驶舱阅读器共用)。
|
||||
// 正文与原文片段一律作为文本节点渲染,不经 HTML。非 verified 状态用未核验样式,invalid 用无效样式;
|
||||
// 折叠只用 @ui Collapsible;颜色/字号/圆角只消费 @ui/tokens.css(见 reg-citations.css)。
|
||||
import { Fragment, useState, type ReactNode } from 'react'
|
||||
import { Chip, Collapsible } from '@ui'
|
||||
import {
|
||||
CITATION_CHIP_TONE, citationListOrder, citationMarkText, citationStateLabel, citationStyle,
|
||||
disclosureLabel, excerptView, parseCitedBlocks, safeWebHref, staleLabel,
|
||||
type CitationView,
|
||||
} from '../regCitations'
|
||||
import '../reg-citations.css'
|
||||
|
||||
/** 状态徽标:核验状态 + 过期「待复核」。 */
|
||||
export function CitationStateChips({ c }: { c: { state?: string; stale?: boolean; staleReason?: string } }) {
|
||||
const stale = staleLabel(c)
|
||||
return (
|
||||
<span className="regcite-chips">
|
||||
<Chip tone={CITATION_CHIP_TONE[citationStyle({ state: c.state })]}>{citationStateLabel(c.state)}</Chip>
|
||||
{stale && <Chip tone="warn">{stale}</Chip>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function citationHeading(c: CitationView): string {
|
||||
const parts = [c.docNo, c.clauseNo].filter(Boolean)
|
||||
return parts.length > 0 ? parts.join(' ') : (c.title || c.id)
|
||||
}
|
||||
|
||||
function formatTime(at: string | undefined): string {
|
||||
if (!at) return '—'
|
||||
const d = new Date(at)
|
||||
return Number.isNaN(d.getTime()) ? at : d.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function Excerpt({ id, label, text, redaction }: { id: string; label: string; text?: string; redaction?: string }) {
|
||||
if (!text) return <div className="regcite-redaction">{label}未展示:{redaction}</div>
|
||||
return (
|
||||
<Collapsible id={id} trigger={<span className="regcite-toggle">{label}</span>}>
|
||||
<blockquote className="regcite-excerpt">{text}</blockquote>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
/** 引用卡片:元数据 + 可展开原文片段(受披露约束);缺失或 invalid 的 ID 只给无效说明。 */
|
||||
export function CitationCard({ id, citation, scope }: { id: string; citation?: CitationView; scope: string }) {
|
||||
const style = citationStyle(citation)
|
||||
if (!citation || style === 'invalid') {
|
||||
return (
|
||||
<div className="regcite-card regcite-card-invalid">
|
||||
<div className="regcite-card-head">
|
||||
<Chip tone="danger">无效引用</Chip>
|
||||
<span className="regcite-mono">{id}</span>
|
||||
</div>
|
||||
<div className="regcite-note">
|
||||
{citation ? '引用 ID 无法解析为证据批' : '本节引用清单中没有该引用'}——不得作为法律依据
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const web = citation.web
|
||||
const href = safeWebHref(web?.finalUrl || web?.url)
|
||||
const fields: [string, ReactNode][] = []
|
||||
const add = (label: string, v: ReactNode) => { if (v !== undefined && v !== null && v !== '') fields.push([label, v]) }
|
||||
add('文号', citation.docNo)
|
||||
add('标题', citation.title)
|
||||
add('版本', citation.editionLabel)
|
||||
add('效力状态', citation.legalStatus)
|
||||
add('条款', citation.clauseNo)
|
||||
add('章节路径', citation.headingPath?.length ? citation.headingPath.join(' › ') : undefined)
|
||||
add('语料修订', citation.corpusRevision && <span className="regcite-mono">{citation.corpusRevision}</span>)
|
||||
add('原文披露', citation.disclosure && disclosureLabel(citation.disclosure))
|
||||
if (web) {
|
||||
add('网络来源', href
|
||||
? <a className="regcite-link" href={href} target="_blank" rel="noopener noreferrer">{web.finalUrl || web.url}</a>
|
||||
: web.finalUrl || web.url)
|
||||
add('官方来源', web.official ? '是' : '否')
|
||||
add('抓取时间', formatTime(web.fetchedAt))
|
||||
}
|
||||
add('证据批', citation.batchId && <span className="regcite-mono">{citation.batchId}</span>)
|
||||
add('引用 ID', <span className="regcite-mono">{citation.id}</span>)
|
||||
const ex = excerptView(citation)
|
||||
return (
|
||||
<div className={`regcite-card regcite-card-${style}`}>
|
||||
<div className="regcite-card-head">
|
||||
<span className="regcite-card-title">{citationHeading(citation)}</span>
|
||||
<CitationStateChips c={citation} />
|
||||
</div>
|
||||
{style !== 'valid' && <div className="regcite-note">未核验引用——不得作为有效法律依据</div>}
|
||||
{citation.claim && <div className="regcite-claim">主张:{citation.claim}</div>}
|
||||
<dl className="regcite-fields">
|
||||
{fields.map(([label, v]) => (
|
||||
<Fragment key={label}><dt>{label}</dt><dd>{v}</dd></Fragment>
|
||||
))}
|
||||
</dl>
|
||||
<Excerpt id={`regcite-excerpt-${scope}-${citation.id}`} label="原文片段"
|
||||
text={'excerpt' in ex ? ex.excerpt : undefined}
|
||||
redaction={'redaction' in ex ? ex.redaction : undefined} />
|
||||
{web?.excerpt && (
|
||||
<Excerpt id={`regcite-web-${scope}-${citation.id}`} label="网页摘录" text={web.excerpt} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 引用清单一行:摘要触发 + 折叠卡片。 */
|
||||
export function CitationListItem({ id, n, citation, scope }: { id: string; n?: number; citation?: CitationView; scope: string }) {
|
||||
const style = citationStyle(citation)
|
||||
return (
|
||||
<div className={`regcite-item regcite-item-${style}`}>
|
||||
<Collapsible id={`regcite-item-${scope}-${id}`} trigger={
|
||||
<span className="regcite-item-head">
|
||||
{n !== undefined && <span className="regcite-item-num">[{n}]</span>}
|
||||
<span className="regcite-item-title">{citation && style !== 'invalid' ? citationHeading(citation) : id}</span>
|
||||
{citation && style !== 'invalid' && citation.title && citation.title !== citationHeading(citation) && (
|
||||
<span className="regcite-item-sub">{citation.title}</span>
|
||||
)}
|
||||
{citation && style !== 'invalid'
|
||||
? <CitationStateChips c={citation} />
|
||||
: <Chip tone="danger">无效引用</Chip>}
|
||||
</span>
|
||||
}>
|
||||
<CitationCard id={id} citation={citation} scope={scope} />
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 带引用的正文:[cit-…] 渲染为编号标记,点击在段落下展开引用卡片;正文下附引用清单。 */
|
||||
export function CitedBody({ texts, citations, scope, paraClassName = 'cockpit-reader-para' }: {
|
||||
texts: string[]
|
||||
citations?: CitationView[] | null
|
||||
scope: string
|
||||
paraClassName?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState<string | null>(null)
|
||||
const { blocks, order } = parseCitedBlocks(texts)
|
||||
const index = new Map((citations || []).map(c => [c.id, c]))
|
||||
const list = citationListOrder(order, citations)
|
||||
return (
|
||||
<>
|
||||
{blocks.map((segs, bi) => {
|
||||
const ids = [...new Set(segs.flatMap(s => (s.kind === 'cite' ? [s.id] : [])))]
|
||||
if (ids.length === 0) {
|
||||
return <p key={bi} className={paraClassName}>{segs.map(s => (s.kind === 'text' ? s.text : '')).join('')}</p>
|
||||
}
|
||||
return (
|
||||
<div key={bi} className="regcite-block">
|
||||
<p className={paraClassName}>
|
||||
{segs.map((s, si) => {
|
||||
if (s.kind === 'text') return <Fragment key={si}>{s.text}</Fragment>
|
||||
const c = index.get(s.id)
|
||||
const key = `${bi}:${s.id}`
|
||||
const isOpen = open === key
|
||||
return (
|
||||
<button key={si} type="button"
|
||||
className={`regcite-mark regcite-${citationStyle(c)}${isOpen ? ' regcite-mark-open' : ''}`}
|
||||
aria-expanded={isOpen}
|
||||
title={`引用 ${s.n}:${c ? citationStateLabel(c.state) : '无效引用'}`}
|
||||
onClick={() => setOpen(isOpen ? null : key)}>
|
||||
[{citationMarkText(s.n, c)}]
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
{ids.map(id => (
|
||||
<Collapsible key={id} open={open === `${bi}:${id}`}>
|
||||
<CitationCard id={id} citation={index.get(id)} scope={`${scope}-inline`} />
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{list.length > 0 && (
|
||||
<div className="regcite-list">
|
||||
<div className="regcite-list-head">引用({list.length})</div>
|
||||
{list.map((id, i) => (
|
||||
<CitationListItem key={id} id={id} n={i + 1} citation={index.get(id)} scope={scope} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
api, type ManagedAccountRecord, type PermissionDescriptor, type RoleMember, type RolePermissionBatchResult, type RolePermissionPatchEntry,
|
||||
type RolePermissionPreview, type RoleRecord, type UserRoleAssignmentPreview,
|
||||
} from '../api'
|
||||
import {
|
||||
accountLabel, accountName, assignedToRole, clearSelection, createSelection, isSelected, memberAssignments, pageSelectionState,
|
||||
previewAllowsAssign, previewTransition, refreshStateVersions, sameNameHint, selectPage, selectionCounts, selectionSummary, staleUserIds,
|
||||
toggleOne, type MemberSelection,
|
||||
} from '../roleMembers'
|
||||
import {
|
||||
PROJECT_CREATE_ACTION, PROJECT_CREATE_LINKED_ACTIONS, ROLE_PERMISSION_STATES, categoryActions, createRolePermissionDraft, dirtyRoles,
|
||||
linkedByProjectCreate, permissionCategories, rebaseRoleDraft, rolePatchEntries, stateLabel, stateToValue, updateRolePermission,
|
||||
validateRolePermissionDraft, valueToState, visiblePatch,
|
||||
type RolePermissionDraft, type RolePermissionStateName, type RolePermissionValue,
|
||||
} from '../rolePermissions'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { useStore } from '../store'
|
||||
|
||||
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
|
||||
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
|
||||
|
||||
export const NARROW_ROLE_QUERY = '(max-width: 900px)'
|
||||
const LINKED_NOTE = '创建项目时自动具备 · 仅本人创建的项目'
|
||||
|
||||
/** 草稿携带建立基线时的角色快照:其 roleVersion 即批量 patch 的 expectedRoleVersion。 */
|
||||
type RoleDraft = RolePermissionDraft & { role: RoleRecord }
|
||||
type RoleDraftMap = Record<string, RoleDraft>
|
||||
type RolePermissionClient = Pick<typeof api, 'previewRolePermissions' | 'batchPatchRolePermissions' | 'getRole'>
|
||||
|
||||
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 shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean {
|
||||
return activeSequence === responseSequence
|
||||
}
|
||||
|
||||
export function hasSystemAction(actions: string[] | undefined, action: string): boolean {
|
||||
return actions?.includes(action) ?? false
|
||||
}
|
||||
|
||||
/** 窄屏(≤900px)默认单角色聚焦;仍可手动打开矩阵。 */
|
||||
export function initialRoleView(matches?: (query: string) => boolean): 'single' | 'matrix' {
|
||||
return matches?.(NARROW_ROLE_QUERY) ? 'single' : 'matrix'
|
||||
}
|
||||
|
||||
/** 系统动作(project.create)是二态开关;项目动作是三态。 */
|
||||
export function cellKind(item: PermissionDescriptor): 'toggle' | 'state' {
|
||||
return item.scope === 'system' ? 'toggle' : 'state'
|
||||
}
|
||||
|
||||
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 roleCellValue(drafts: RoleDraftMap, role: RoleRecord, actionId: string): RolePermissionValue {
|
||||
return drafts[role.roleId]?.values[actionId] ?? roleValue(role, actionId)
|
||||
}
|
||||
|
||||
// DR9:任何单元格修改都写进该角色自己的草稿(无需先选中);首次修改时以当时的服务端值为基线。
|
||||
export function editRoleDraft(
|
||||
drafts: RoleDraftMap, role: RoleRecord, catalog: PermissionDescriptor[], actionId: string, value: RolePermissionValue,
|
||||
): RoleDraftMap {
|
||||
const current = drafts[role.roleId] ?? { ...createRolePermissionDraft(roleValues(role, catalog)), role }
|
||||
return { ...drafts, [role.roleId]: { ...updateRolePermission(current, actionId, value), role: current.role } }
|
||||
}
|
||||
|
||||
export function roleLeaveMessage(names: string[]): string {
|
||||
return `以下角色有未保存的权限修改:${names.map(name => `「${name}」`).join('、')}。离开将放弃这些修改,确定离开吗?`
|
||||
}
|
||||
|
||||
function previewBlocksSave(previews: RolePermissionPreview[]): boolean {
|
||||
return previews.some(preview => !!preview.error || (preview.conflicts?.length ?? 0) > 0)
|
||||
}
|
||||
|
||||
export async function previewRoleBatch(client: Pick<RolePermissionClient, 'previewRolePermissions'>, entries: RolePermissionPatchEntry[]) {
|
||||
const previews = await client.previewRolePermissions(entries, { mode: 'patch' })
|
||||
return { previews, blocked: previewBlocksSave(previews) }
|
||||
}
|
||||
|
||||
// 原子保存:所有脏角色一次提交;预览存在冲突或错误时拒绝提交。
|
||||
export async function commitRoleBatch(
|
||||
client: Pick<RolePermissionClient, 'batchPatchRolePermissions'>, entries: RolePermissionPatchEntry[],
|
||||
previews: RolePermissionPreview[], idempotencyKey: string,
|
||||
): Promise<RolePermissionBatchResult> {
|
||||
if (!entries.length) throw new Error('没有需要保存的权限修改')
|
||||
if (previewBlocksSave(previews)) throw new Error('预览存在冲突或错误,不能保存')
|
||||
return client.batchPatchRolePermissions(entries, idempotencyKey)
|
||||
}
|
||||
|
||||
// DR9:409 后逐个读取最新角色,只重放各角色相对自身旧基线的本地变化。
|
||||
export async function rebaseRoleDrafts(
|
||||
client: Pick<RolePermissionClient, 'getRole'>, drafts: RoleDraftMap, roleIds: string[], catalog: PermissionDescriptor[],
|
||||
): Promise<{ fresh: RoleRecord[]; drafts: RoleDraftMap }> {
|
||||
const fresh = await Promise.all(roleIds.map(roleId => client.getRole(roleId)))
|
||||
const next = { ...drafts }
|
||||
for (const role of fresh) {
|
||||
const current = drafts[role.roleId]
|
||||
if (current) next[role.roleId] = { ...rebaseRoleDraft(current, roleValues(role, catalog)), role }
|
||||
}
|
||||
return { fresh, drafts: next }
|
||||
}
|
||||
|
||||
function pruneCleanDrafts(drafts: RoleDraftMap, actionIds: string[]): RoleDraftMap {
|
||||
const dirty = new Set(dirtyRoles(drafts, actionIds).map(entry => entry.roleId))
|
||||
if (Object.keys(drafts).every(roleId => dirty.has(roleId))) return drafts
|
||||
return Object.fromEntries(Object.entries(drafts).filter(([roleId]) => dirty.has(roleId)))
|
||||
}
|
||||
|
||||
// 本端资料/状态写入成功且草稿基线即写入前版本时,权限未变,草稿可继承新版本号。
|
||||
function adoptRoleSnapshot(drafts: RoleDraftMap, expectedVersion: number, updated: RoleRecord): RoleDraftMap {
|
||||
const current = drafts[updated.roleId]
|
||||
if (!current || current.role.roleVersion !== expectedVersion) return drafts
|
||||
return { ...drafts, [updated.roleId]: { ...current, role: updated } }
|
||||
}
|
||||
|
||||
function mergeRoles(current: RoleRecord[] | null, fresh: RoleRecord[]): RoleRecord[] {
|
||||
const byId = new Map(fresh.map(role => [role.roleId, role]))
|
||||
const merged = (current ?? []).map(role => byId.get(role.roleId) ?? role)
|
||||
return [...fresh.filter(role => !merged.some(item => item.roleId === role.roleId)), ...merged]
|
||||
}
|
||||
|
||||
export const roleMembersLabel = (role: Pick<RoleRecord, 'name'>) => `查看角色 ${role.name} 的成员`
|
||||
|
||||
export function memberStatusLabel(member: Pick<RoleMember, 'enabled'>): string {
|
||||
return member.enabled ? '有效' : '停用(绑定保留,不授予权限)'
|
||||
}
|
||||
|
||||
const formatAssignedAt = (value: string) => {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
/** 角色成员子视图:成员分页列表 + 「添加用户到角色」(预览后一次批量事务提交)。 */
|
||||
function RoleMembersView({ role, canAssign, backLabel, onBack, onAssigned }: {
|
||||
role: RoleRecord; canAssign: boolean; backLabel: string; onBack: () => void; onAssigned: () => void
|
||||
}) {
|
||||
const [members, setMembers] = useState<RoleMember[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [cursor, setCursor] = useState('')
|
||||
const [trail, setTrail] = useState<string[]>([])
|
||||
const [nextCursor, setNextCursor] = useState('')
|
||||
const [reload, setReload] = useState(0)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [userQuery, setUserQuery] = useState('')
|
||||
const [userCursor, setUserCursor] = useState('')
|
||||
const [userTrail, setUserTrail] = useState<string[]>([])
|
||||
const [nextUserCursor, setNextUserCursor] = useState('')
|
||||
const [candidates, setCandidates] = useState<ManagedAccountRecord[] | null>(null)
|
||||
const [known, setKnown] = useState<Record<string, ManagedAccountRecord>>({})
|
||||
const [selection, setSelection] = useState<MemberSelection>(createSelection)
|
||||
const [preview, setPreview] = useState<UserRoleAssignmentPreview[] | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const memberSequence = useRef(0)
|
||||
const userSequence = useRef(0)
|
||||
const previewSequence = useRef(0)
|
||||
const selectableIds = (candidates ?? []).filter(account => !assignedToRole(account, role.roleId)).map(account => account.userId)
|
||||
const pageState = pageSelectionState(selection, selectableIds)
|
||||
const counts = selectionCounts(selection, selectableIds)
|
||||
const ready = !!preview && previewAllowsAssign(preview, selection.ids)
|
||||
const stale = preview ? staleUserIds(preview) : []
|
||||
|
||||
useEffect(() => {
|
||||
const request = ++memberSequence.current
|
||||
const timer = window.setTimeout(() => {
|
||||
void api.listRoleMembers(role.roleId, { query: query.trim(), mode: 'fuzzy', status, cursor, limit: 20 })
|
||||
.then(page => { if (request === memberSequence.current) { setMembers(page.members ?? []); setNextCursor(page.nextCursor ?? '') } })
|
||||
.catch(error => { if (request === memberSequence.current) setErr(String(error)) })
|
||||
}, 180)
|
||||
return () => { window.clearTimeout(timer); memberSequence.current++ }
|
||||
}, [role.roleId, query, status, cursor, reload])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return
|
||||
const request = ++userSequence.current
|
||||
const timer = window.setTimeout(() => {
|
||||
void api.searchAdminUsers({ query: userQuery.trim(), mode: 'fuzzy', status: 'all', fields: ['owner', 'name', 'userId'], cursor: userCursor, limit: 20 })
|
||||
.then(page => {
|
||||
if (request !== userSequence.current) return
|
||||
setCandidates(page.accounts ?? []); setNextUserCursor(page.nextCursor ?? '')
|
||||
setKnown(current => ({ ...current, ...Object.fromEntries((page.accounts ?? []).map(account => [account.userId, account])) }))
|
||||
})
|
||||
.catch(error => { if (request === userSequence.current) setErr(String(error)) })
|
||||
}, 180)
|
||||
return () => { window.clearTimeout(timer); userSequence.current++ }
|
||||
}, [pickerOpen, userQuery, userCursor, reload])
|
||||
|
||||
const changeSelection = (next: MemberSelection) => {
|
||||
previewSequence.current++
|
||||
setSelection(next); setPreview(null)
|
||||
}
|
||||
|
||||
const requestPreview = async (versions = known) => {
|
||||
const request = ++previewSequence.current
|
||||
setPreview(null); setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
const assignments = memberAssignments(selection.ids, role.roleId,
|
||||
Object.fromEntries(Object.values(versions).map(account => [account.userId, account.stateVersion])))
|
||||
const previews = await api.previewUserRoleAssignments(assignments)
|
||||
if (request === previewSequence.current) setPreview(previews)
|
||||
} catch (error) { if (request === previewSequence.current) setErr(error instanceof Error ? error.message : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const refreshVersions = async () => {
|
||||
if (!preview) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
const fresh = { ...known, ...await refreshStateVersions(api, staleUserIds(preview)) }
|
||||
setKnown(fresh)
|
||||
await requestPreview(fresh)
|
||||
} catch (error) { setErr(String(error)); setBusy(false) }
|
||||
}
|
||||
|
||||
const confirm = async () => {
|
||||
if (!preview || !previewAllowsAssign(preview, selection.ids)) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const assignments = memberAssignments(selection.ids, role.roleId,
|
||||
Object.fromEntries(Object.values(known).map(account => [account.userId, account.stateVersion])))
|
||||
const result = await api.assignUserRolesBatch(assignments, crypto.randomUUID())
|
||||
changeSelection(clearSelection())
|
||||
setCursor(''); setTrail([]); setUserCursor(''); setUserTrail([]); setReload(value => value + 1)
|
||||
onAssigned()
|
||||
setNotice(`已将 ${result.bindings?.length ?? assignments.length} 人加入角色「${role.name}」`)
|
||||
} catch (error) {
|
||||
setPreview(null)
|
||||
setErr(String(error).includes('409') ? '部分用户状态或角色已变化,整批均未写入;选择已保留,请重新预览后再确认' : `整批均未写入:${String(error)}`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <section className="rp-members" aria-label={`角色「${role.name}」的成员`}>
|
||||
<div className="ad-row">
|
||||
<button className="ad-mini" disabled={busy} onClick={onBack}>{backLabel}</button>
|
||||
<strong className="au-grow">角色「{role.name}」的成员 · {role.bindingCount} 人</strong>
|
||||
</div>
|
||||
{!role.enabled && <p className="rp-disabled-note">角色已停用:现有绑定不再授予权限,也不能向其添加用户</p>}
|
||||
<div className="rp-status">
|
||||
{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>}
|
||||
</div>
|
||||
<div className="ad-row rp-filters">
|
||||
<input className="lg-input" aria-label="搜索角色成员" placeholder="姓名、账号或 ID" value={query} onChange={event => { setQuery(event.target.value); setCursor(''); setTrail([]) }} />
|
||||
<select className="lg-input" aria-label="成员状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); setCursor(''); setTrail([]) }}>
|
||||
<option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option>
|
||||
</select>
|
||||
</div>
|
||||
{sameNameHint(members ?? []) && <p className="ad-hint">{sameNameHint(members ?? [])}</p>}
|
||||
<table className="ad-table"><thead><tr><th>姓名与账号</th><th>状态</th><th>分配时间</th></tr></thead>
|
||||
<tbody>{(members ?? []).map(member => <tr key={member.userId}>
|
||||
<td><div className="au-name-cell"><span>{accountName(member)}</span>{member.name.trim() && <span className="ad-dim ad-mono">{member.owner}</span>}</div></td>
|
||||
<td>{memberStatusLabel(member)}</td><td>{formatAssignedAt(member.assignedAt)}</td>
|
||||
</tr>)}
|
||||
{members !== null && members.length === 0 && <tr><td colSpan={3} className="ad-dim">该角色暂无匹配成员</td></tr>}
|
||||
{members === null && <tr><td colSpan={3} 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>
|
||||
{canAssign && role.enabled && <Collapsible open={pickerOpen} onToggle={setPickerOpen} trigger="添加用户到角色">
|
||||
<div className="au-copy">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索要添加的用户" placeholder="搜索用户姓名、账号或 ID" value={userQuery}
|
||||
onChange={event => { setUserQuery(event.target.value); setUserCursor(''); setUserTrail([]) }} />
|
||||
<button className="ad-mini" disabled={!userTrail.length} onClick={() => { setUserCursor(userTrail[userTrail.length - 1]); setUserTrail(userTrail.slice(0, -1)) }}>上一页用户</button>
|
||||
<button className="ad-mini" disabled={!nextUserCursor} onClick={() => { setUserTrail([...userTrail, userCursor]); setUserCursor(nextUserCursor) }}>下一页用户</button>
|
||||
</div>
|
||||
{sameNameHint(candidates ?? []) && <p className="ad-hint">{sameNameHint(candidates ?? [])}</p>}
|
||||
<table className="ad-table"><thead><tr>
|
||||
<th className="au-select-col"><input type="checkbox" aria-label="全选本页可添加用户" checked={pageState === 'all'} disabled={busy || !selectableIds.length}
|
||||
ref={element => { if (element) element.indeterminate = pageState === 'some' }}
|
||||
onChange={event => changeSelection(selectPage(selection, selectableIds, event.target.checked))} /></th>
|
||||
<th>姓名与账号</th><th>当前角色</th><th>状态</th></tr></thead>
|
||||
<tbody>{(candidates ?? []).map(account => {
|
||||
const assigned = assignedToRole(account, role.roleId)
|
||||
return <tr key={account.userId}>
|
||||
<td className="au-select-col"><input type="checkbox" aria-label={assigned ? `${accountLabel(account)} 已分配` : `选择 ${accountLabel(account)}`}
|
||||
checked={!assigned && isSelected(selection, account.userId)} disabled={busy || assigned}
|
||||
onChange={event => changeSelection(toggleOne(selection, account.userId, event.target.checked))} /></td>
|
||||
<td><div className="au-name-cell"><span>{accountName(account)}</span>{account.profile.name.trim() && <span className="ad-dim ad-mono">{account.owner}</span>}</div></td>
|
||||
<td>{assigned ? <span className="au-same-name">已分配</span> : account.role === null ? '未分配' : account.role?.name ?? '—'}</td>
|
||||
<td>{account.enabled ? '有效' : '停用'}</td>
|
||||
</tr>
|
||||
})}
|
||||
{candidates !== null && candidates.length === 0 && <tr><td colSpan={4} className="ad-dim">没有匹配用户</td></tr>}
|
||||
{candidates === null && <tr><td colSpan={4} className="ad-dim">加载中…</td></tr>}</tbody></table>
|
||||
{preview && <ul className="au-preview-list">{preview.map(item => <li key={item.userId}>
|
||||
<span>{accountLabel(item)}</span><span className="ad-dim">{previewTransition(item)}</span>
|
||||
{item.error && <span className="rp-conflict"><Icon name="alert" size={12} />{item.error}</span>}
|
||||
{!item.error && !item.stateMatches && <span className="rp-conflict"><Icon name="alert" size={12} />用户状态已变化</span>}
|
||||
</li>)}</ul>}
|
||||
{preview && !ready && <p className="rp-conflict" role="alert"><Icon name="alert" size={12} />存在停用、版本变化或角色无效的用户,处理后才能确认添加</p>}
|
||||
<div className="ad-row">
|
||||
<span className="ad-hint au-grow" aria-live="polite">{selectionSummary(counts)}</span>
|
||||
<button className="ad-mini" disabled={busy || !counts.count} onClick={() => changeSelection(clearSelection())}>清空选择</button>
|
||||
{stale.length > 0 && <button className="ad-mini" disabled={busy} onClick={() => void refreshVersions()}>刷新 {stale.length} 人的状态并重新预览</button>}
|
||||
<button className="ad-mini" disabled={busy || !counts.count} onClick={() => void requestPreview()}>预览分配</button>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !ready} onClick={() => void confirm()}>确认添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>}
|
||||
</section>
|
||||
}
|
||||
|
||||
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 canViewMembers = allowed('system.users.view') && allowed('system.roles.view')
|
||||
const canAssignMembers = canViewMembers && allowed('system.roles.assign')
|
||||
const [membersRole, setMembersRole] = useState<RoleRecord | null>(null)
|
||||
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
|
||||
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
||||
const [catalogFailed, setCatalogFailed] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [drafts, setDrafts] = useState<RoleDraftMap>({})
|
||||
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 [category, setCategory] = useState('')
|
||||
const [actionFilter, setActionFilter] = useState('')
|
||||
const [roleCursor, setRoleCursor] = useState('')
|
||||
const [roleTrail, setRoleTrail] = useState<string[]>([])
|
||||
const [nextRoleCursor, setNextRoleCursor] = useState('')
|
||||
const [view, setView] = useState<'single' | 'matrix'>(() => initialRoleView(
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? media => window.matchMedia(media).matches : undefined))
|
||||
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 [preview, setPreview] = useState<{ previews: RolePermissionPreview[]; confirm: boolean } | null>(null)
|
||||
const requestSequence = useRef(0)
|
||||
const selectedIdRef = useRef('')
|
||||
const savePermsRef = useRef<HTMLButtonElement>(null)
|
||||
const statusRef = useRef<HTMLDivElement>(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 descriptorById = useMemo(() => new Map(catalog.map(item => [item.actionId, item])), [catalog])
|
||||
const categories = useMemo(() => permissionCategories(catalog), [catalog])
|
||||
const activeCategory = categories.includes(category) ? category : categories[0] ?? ''
|
||||
const columns = useMemo(() => categoryActions(catalog, activeCategory), [catalog, activeCategory])
|
||||
const dirtyList = useMemo(() => dirtyRoles(drafts, allEditableIds), [drafts, allEditableIds])
|
||||
const dirtyNames = dirtyList.map(entry => roles?.find(role => role.roleId === entry.roleId)?.name ?? drafts[entry.roleId].role.name)
|
||||
const changeCount = dirtyList.reduce((sum, entry) => sum + entry.changes, 0)
|
||||
const changes = draft ? visiblePatch(draft, allEditableIds) : []
|
||||
const filtered = roles ?? []
|
||||
const effectiveView = canViewPermissions ? view : 'single'
|
||||
const editable = canManagePermissions && !catalogFailed && catalog.length > 0
|
||||
const blocked = !!preview && previewBlocksSave(preview.previews)
|
||||
const labelOf = (actionId: string) => descriptorById.get(actionId)?.label ?? actionId
|
||||
const cellDisabled = (role: RoleRecord) => busy || roleLoading || !editable || !role.enabled
|
||||
|
||||
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)
|
||||
setCatalogFailed(catalogResult.status === 'rejected')
|
||||
if (roleResult.status === 'rejected') {
|
||||
setErr(String(roleResult.reason))
|
||||
return
|
||||
}
|
||||
const rolePage = roleResult.value
|
||||
setRoles(rolePage.roles ?? [])
|
||||
setNextRoleCursor(rolePage.nextCursor ?? '')
|
||||
setDrafts(prev => pruneCleanDrafts(prev, descriptors.filter(item => !item.protected).map(item => item.actionId)))
|
||||
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
|
||||
if (nextSelected) selectRole(nextSelected, sequence)
|
||||
else setSelectedId('')
|
||||
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
|
||||
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
|
||||
}
|
||||
|
||||
const selectRole = (role: RoleRecord, sequence?: number) => {
|
||||
const nextSequence = sequence ?? ++requestSequence.current
|
||||
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
|
||||
setSelectedId(role.roleId)
|
||||
setName(role.name)
|
||||
setDescription(role.description)
|
||||
setNotice('')
|
||||
setErr('')
|
||||
}
|
||||
|
||||
// DR9:离开管理区须显式决定(覆盖所有脏角色);带未保存草稿时刷新走浏览器原生确认。
|
||||
const dirtyKey = dirtyNames.join('\u0000')
|
||||
useEffect(() => {
|
||||
if (!dirtyNames.length) { roleDraftLeaveGuard.current = null; return }
|
||||
const message = roleLeaveMessage(dirtyNames)
|
||||
roleDraftLeaveGuard.current = () => window.confirm(message)
|
||||
return () => { roleDraftLeaveGuard.current = null }
|
||||
}, [dirtyKey])
|
||||
const dirty = dirtyList.length > 0
|
||||
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])
|
||||
|
||||
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 或保存区收起掉回 body,则归还。
|
||||
const restoreFocus = () => requestAnimationFrame(() => {
|
||||
if (document.activeElement !== document.body) return
|
||||
const target = savePermsRef.current && !savePermsRef.current.disabled ? savePermsRef.current : statusRef.current
|
||||
target?.focus()
|
||||
})
|
||||
|
||||
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, ++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 {
|
||||
const updated = await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
|
||||
setDrafts(prev => adoptRoleSnapshot(prev, selected.roleVersion, updated))
|
||||
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 => mergeRoles(current, [fresh]))
|
||||
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 {
|
||||
const updated = await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
|
||||
setDrafts(prev => adoptRoleSnapshot(prev, selected.roleVersion, updated))
|
||||
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 => mergeRoles(current, [fresh]))
|
||||
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
|
||||
} catch (refreshError) { setErr(String(refreshError)) }
|
||||
} else setErr(String(error))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const editCell = (role: RoleRecord, actionId: string, value: RolePermissionValue) => {
|
||||
setDrafts(prev => editRoleDraft(prev, role, catalog, actionId, value))
|
||||
setPreview(null)
|
||||
}
|
||||
|
||||
const patchEntries = () => rolePatchEntries(drafts,
|
||||
Object.fromEntries(Object.entries(drafts).map(([roleId, item]) => [roleId, item.role.roleVersion])), allEditableIds)
|
||||
|
||||
// 「查看变化」与「保存」都先取服务端权威预览;confirm=true 时保存按钮变为「确认保存」。
|
||||
const requestPreview = async (confirm: boolean) => {
|
||||
for (const entry of dirtyList) {
|
||||
const invalid = validateRolePermissionDraft(drafts[entry.roleId])
|
||||
if (invalid) { setErr(`角色「${drafts[entry.roleId].role.name}」的 ${labelOf(invalid.actionId)} 默认权限不能超过角色上限`); return }
|
||||
}
|
||||
const entries = patchEntries()
|
||||
if (!entries.length) return
|
||||
const operationSequence = ++requestSequence.current
|
||||
setRoleLoading(false)
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const result = await previewRoleBatch(api, entries)
|
||||
if (requestSequence.current !== operationSequence) return
|
||||
setPreview({ previews: result.previews, confirm })
|
||||
} catch (error) { setErr(String(error)) } finally { setBusy(false); restoreFocus() }
|
||||
}
|
||||
|
||||
const confirmSave = async () => {
|
||||
if (!preview) return
|
||||
const entries = patchEntries()
|
||||
const snapshot = drafts
|
||||
const operationSequence = ++requestSequence.current
|
||||
setRoleLoading(false)
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const result = await commitRoleBatch(api, entries, preview.previews, crypto.randomUUID())
|
||||
const saved = new Set(entries.map(entry => entry.roleId))
|
||||
setDrafts(current => Object.fromEntries(Object.entries(current).filter(([roleId]) => !saved.has(roleId))))
|
||||
setPreview(null)
|
||||
await load(roleCursor, true, selectedId)
|
||||
setNotice(`已保存 ${result.receipts.length} 个角色的权限设置`)
|
||||
} catch (error) {
|
||||
if (String(error).includes('409')) {
|
||||
try {
|
||||
const rebased = await rebaseRoleDrafts(api, snapshot, entries.map(entry => entry.roleId), catalog)
|
||||
if (requestSequence.current !== operationSequence) return
|
||||
setRoles(current => mergeRoles(current, rebased.fresh))
|
||||
setDrafts(current => ({ ...current, ...Object.fromEntries(entries.map(entry => [entry.roleId, rebased.drafts[entry.roleId]])) }))
|
||||
setPreview(null)
|
||||
setErr(`权限版本冲突;已基于最新版本为 ${entries.length} 个角色重放本地草稿,请核对后再次保存`)
|
||||
} catch (refreshError) { setErr(String(refreshError)) }
|
||||
} else setErr(String(error))
|
||||
} finally { setBusy(false); restoreFocus() }
|
||||
}
|
||||
|
||||
const cancelAll = () => {
|
||||
if (!window.confirm(`确定放弃 ${dirtyNames.map(name => `「${name}」`).join('、')} 的全部未保存权限修改吗?`)) return
|
||||
setDrafts({}); setPreview(null); setErr(''); setNotice('已取消全部未保存的权限修改')
|
||||
}
|
||||
|
||||
const openFocus = (role: RoleRecord) => { selectRole(role); setView('single') }
|
||||
|
||||
const valueLabel = (actionId: string, value: RolePermissionValue | undefined) => descriptorById.get(actionId)?.scope === 'system'
|
||||
? (value?.allowed ? '允许' : '不允许') : stateLabel(valueToState(value))
|
||||
|
||||
const cell = (role: RoleRecord, item: PermissionDescriptor) => {
|
||||
const value = roleCellValue(drafts, role, item.actionId)
|
||||
const label = `${role.name} ${item.label}`
|
||||
const disabled = cellDisabled(role)
|
||||
const roleDraft = drafts[role.roleId]
|
||||
const modified = !!roleDraft && visiblePatch(roleDraft, [item.actionId]).length > 0
|
||||
const linked = linkedByProjectCreate({ [PROJECT_CREATE_ACTION]: roleCellValue(drafts, role, PROJECT_CREATE_ACTION) }, item.actionId)
|
||||
return <div className="rp-cell">
|
||||
{cellKind(item) === 'toggle'
|
||||
? <select className="rp-state" aria-label={label} value={value.allowed ? 'on' : 'off'} disabled={disabled}
|
||||
onChange={event => editCell(role, item.actionId, stateToValue(event.target.value === 'on' ? 'default' : 'denied'))}>
|
||||
<option value="off">不允许</option><option value="on">允许</option>
|
||||
</select>
|
||||
: <select className="rp-state" aria-label={label} value={valueToState(value)} disabled={disabled}
|
||||
onChange={event => editCell(role, item.actionId, stateToValue(event.target.value as RolePermissionStateName))}>
|
||||
{ROLE_PERMISSION_STATES.map(option => <option key={option.state} value={option.state}>{option.label}</option>)}
|
||||
</select>}
|
||||
{modified && <span className="rp-cell-flag">已修改</span>}
|
||||
{linked && <span className="rp-linked"><Icon name="sparkles" size={12} />{LINKED_NOTE}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
const matrix = () => <div className="rp-matrix-wrap" role="region" aria-label={`角色矩阵 · ${activeCategory}`} tabIndex={0}>
|
||||
<table className="ad-table rp-matrix">
|
||||
<thead><tr><th scope="col">角色</th>{columns.map(item => <th scope="col" key={item.actionId} title={item.description}>{item.label}</th>)}</tr></thead>
|
||||
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
|
||||
<th scope="row"><div className="rp-role-cell">
|
||||
<button className="rp-role-name" disabled={busy || roleLoading} onClick={() => openFocus(role)}>{role.name}</button>
|
||||
{role.description && <span className="rp-role-desc" title={role.description}>{role.description}</span>}
|
||||
<span className="ad-dim">{canViewMembers
|
||||
? <button className="rp-members-link" aria-label={roleMembersLabel(role)} disabled={busy} onClick={() => setMembersRole(role)}>{role.bindingCount} 人</button>
|
||||
: `${role.bindingCount} 人`} · {role.enabled ? '启用' : '已停用'}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''}</span>
|
||||
{!role.enabled && <span className="rp-disabled-note">停用角色不可编辑权限,请先启用</span>}
|
||||
</div></th>
|
||||
{columns.map(item => <td key={item.actionId}>{cell(role, item)}</td>)}
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && !roleLoading && <p className="ad-hint rp-empty">没有符合条件的角色</p>}
|
||||
</div>
|
||||
|
||||
const focus = () => <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}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''}</span><span className="ad-dim">{role.bindingCount} 人</span>
|
||||
</button>)}
|
||||
</aside>
|
||||
<section className="rp-editor">
|
||||
{selected ? <>
|
||||
<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>)}
|
||||
{canViewMembers && <button className="ad-mini" aria-label={roleMembersLabel(selected)} disabled={busy} onClick={() => setMembersRole(selected)}>成员 {selected.bindingCount} 人</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 ? <>
|
||||
{!selected.enabled && <p className="rp-disabled-note">角色已停用,启用后才能编辑权限</p>}
|
||||
<table className="ad-table rp-focus-table"><thead><tr><th>权限 · {activeCategory}</th><th>状态</th><th>说明</th></tr></thead>
|
||||
<tbody>{columns.map(item => <tr key={item.actionId}>
|
||||
<td>{item.label}</td><td>{cell(selected, item)}</td><td className="ad-dim">{item.description}</td>
|
||||
</tr>)}</tbody></table>
|
||||
<Collapsible id={`role-tech-${selected.roleId}`} trigger="技术详情">
|
||||
<ul className="rp-diff">
|
||||
<li>角色 ID:<span className="ad-mono">{selected.roleId}</span> · 版本 {selected.roleVersion}</li>
|
||||
{columns.map(item => <li key={item.actionId}>{item.label}:<span className="ad-mono">{item.actionId}</span></li>)}
|
||||
</ul>
|
||||
</Collapsible>
|
||||
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
|
||||
{changes.length === 0 || !draft ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
|
||||
<li key={change.actionId}>{labelOf(change.actionId)}:{valueLabel(change.actionId, draft.baseline[change.actionId])} → {valueLabel(change.actionId, change)}</li>)}</ul>}
|
||||
</Collapsible>
|
||||
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
|
||||
</> : <p className="ad-hint">{roleLoading ? '加载中…' : '请选择角色'}</p>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
const previewPanel = (current: NonNullable<typeof preview>) => <section className="rp-preview" aria-label="保存前预览">
|
||||
<div className="rp-preview-head">
|
||||
<strong>{current.confirm ? '请确认以下变化后保存' : '待保存的变化'}</strong>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => setPreview(null)}>收起</button>
|
||||
</div>
|
||||
{current.previews.map(item => <div key={item.roleId} className="rp-preview-role">
|
||||
<div className="rp-preview-title">{item.roleName}
|
||||
{!item.versionMatches && <span className="rp-warn-text"> · 角色已被他人修改,保存时将提示冲突并基于最新版本重放</span>}</div>
|
||||
{item.error && <p className="rp-conflict"><Icon name="alert" size={12} />错误{item.errorActionId ? ` · ${labelOf(item.errorActionId)}` : ''}:{item.error}</p>}
|
||||
{(item.changes ?? []).length > 0
|
||||
? <ul className="rp-diff">{item.changes.map(change => <li key={change.actionId}>{change.label}:{change.before} → {change.after}</li>)}</ul>
|
||||
: <p className="ad-hint">没有状态变化</p>}
|
||||
{(item.linkedExpansion ?? []).length > 0 && <p className="rp-linked"><Icon name="sparkles" size={12} />
|
||||
系统将自动补齐:{item.linkedExpansion.map(labelOf).join('、')}{item.linkedExpansionState ? `(${item.linkedExpansionState})` : ''}</p>}
|
||||
{(item.keptLinkedActions ?? []).length > 0 && <p className="ad-hint">关闭创建项目后保留:{item.keptLinkedActions.map(labelOf).join('、')}</p>}
|
||||
{item.migrationNote && <p className="ad-hint">{item.migrationNote}</p>}
|
||||
{(item.conflicts ?? []).map(conflict => <p key={conflict.actionId} className="rp-conflict">
|
||||
<Icon name="alert" size={12} />冲突 · {labelOf(conflict.actionId)}:{conflict.reason}</p>)}
|
||||
</div>)}
|
||||
{blocked && <p className="rp-conflict" role="alert"><Icon name="alert" size={12} />存在冲突或错误,调整后才能保存</p>}
|
||||
</section>
|
||||
|
||||
return <div className="rp-panel">
|
||||
{canManageRoles && <div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="新角色名称" placeholder="角色名称" value={newName} onChange={event => setNewName(event.target.value)} />
|
||||
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={newDescription} onChange={event => setNewDescription(event.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || roleLoading || !newName.trim()} onClick={create}>创建角色</button>
|
||||
</div>
|
||||
</div>}
|
||||
<Collapsible id="role-permission-filters" trigger="搜索与过滤">
|
||||
<div className="ad-row rp-filters">
|
||||
<input className="lg-input" aria-label="搜索角色" placeholder="角色名称、描述或 ID" value={query} disabled={busy} onChange={event => { setQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
|
||||
<select className="lg-input" aria-label="角色匹配方式" value={mode} disabled={busy} onChange={event => { setMode(event.target.value as typeof mode); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
|
||||
</select>
|
||||
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
|
||||
</select>
|
||||
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
|
||||
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
|
||||
</select>}
|
||||
</div>
|
||||
</Collapsible>
|
||||
<div className="ad-row rp-view-toggle">
|
||||
{canViewPermissions && categories.length > 0 && <div className="rp-category-nav" role="group" aria-label="权限分类">
|
||||
{categories.map(value => <button key={value} className={`ad-mini ${value === activeCategory ? 'on' : ''}`} aria-pressed={value === activeCategory}
|
||||
onClick={() => setCategory(value)}>{value}</button>)}
|
||||
</div>}
|
||||
{canViewPermissions && <button className={`ad-mini ${effectiveView === 'matrix' ? 'on' : ''}`} aria-pressed={effectiveView === 'matrix'} onClick={() => { setMembersRole(null); setView('matrix') }}>角色矩阵</button>}
|
||||
<button className={`ad-mini ${effectiveView === 'single' ? 'on' : ''}`} aria-pressed={effectiveView === 'single'} onClick={() => { setMembersRole(null); setView('single') }}>单角色配置</button>
|
||||
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
||||
</div>
|
||||
{canViewPermissions && <Collapsible id="role-linked-help" trigger="权限关联说明">
|
||||
<p className="ad-hint">开启「{labelOf(PROJECT_CREATE_ACTION)}」的角色,在本人创建的项目中自动具备{PROJECT_CREATE_LINKED_ACTIONS.map(id => `「${labelOf(id)}」`).join('、')}。
|
||||
这些资格仅对本人创建的项目生效,其他项目仍按项目授权判断。</p>
|
||||
</Collapsible>}
|
||||
<div ref={statusRef} tabIndex={-1} className="rp-status">
|
||||
{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>}
|
||||
</div>
|
||||
{membersRole && canViewMembers
|
||||
? <RoleMembersView key={membersRole.roleId} role={roles?.find(role => role.roleId === membersRole.roleId) ?? membersRole} canAssign={canAssignMembers}
|
||||
backLabel={effectiveView === 'matrix' ? '返回角色矩阵' : '返回单角色配置'} onBack={() => setMembersRole(null)} onAssigned={() => void load()} />
|
||||
: roles === null && roleLoading ? <div className="empty-note">加载中…</div> : effectiveView === 'matrix' ? matrix() : focus()}
|
||||
{preview && previewPanel(preview)}
|
||||
{canManagePermissions && dirty && <div className="rp-savebar" role="region" aria-label="未保存的权限修改">
|
||||
<span className="rp-savebar-summary">已修改 {dirtyList.length} 个角色、{changeCount} 项设置</span>
|
||||
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => void requestPreview(false)}>查看变化</button>
|
||||
<button className="ad-mini" disabled={busy} onClick={cancelAll}>取消全部</button>
|
||||
{preview?.confirm
|
||||
? <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || blocked} onClick={() => void confirmSave()}>确认保存</button>
|
||||
: <button ref={savePermsRef} className="ad-mini" disabled={busy || roleLoading || !editable} onClick={() => void requestPreview(true)}>保存</button>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -280,7 +280,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
|
||||
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{(me?.isAdmin || (me?.systemActions?.length ?? 0) > 0 || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{me?.auth && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||||
|
||||
+152
-173
@@ -1,154 +1,30 @@
|
||||
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
|
||||
// 文档职责」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
|
||||
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||
// RDM 项目设置组件:成员 / 文档职责。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||
// 门控集中在 src/rdmManagement.ts;分区可见性由 ProjectSettingsModal 按 store 的
|
||||
// rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
|
||||
import { Btn, Collapsible, Icon, Picker } from '@ui'
|
||||
import {
|
||||
ROLE_ACTIONS,
|
||||
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
|
||||
memberAddPayload, memberRemovePayload, memberSetRolePayload,
|
||||
permCheckPayload, describeSource,
|
||||
rolesListPlaceholder, describeRoleListError,
|
||||
} from '../rdmManagement'
|
||||
|
||||
// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ----
|
||||
|
||||
interface RoleView {
|
||||
roleId: string
|
||||
name: string
|
||||
disabled: boolean
|
||||
version: number
|
||||
actions: Record<string, [boolean, boolean]> | null
|
||||
accounts: string[] | null
|
||||
}
|
||||
|
||||
export function RdmRolesPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
const [roles, setRoles] = useState<RoleView[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
// 动作编辑器
|
||||
const [editRole, setEditRole] = useState('')
|
||||
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
|
||||
const [editDef, setEditDef] = useState(false)
|
||||
const [editCap, setEditCap] = useState(true)
|
||||
// 绑定
|
||||
const [assignOwner, setAssignOwner] = useState('')
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
|
||||
setErr(''); setRoles(out.roles ?? [])
|
||||
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try { await fn(); setNotice(label); await refresh() }
|
||||
catch (e) { setErr(describeRoleListError(String(e))) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!me?.isAdmin) return <EmptyState icon="shield">仅平台管理员可管理专业角色</EmptyState>
|
||||
|
||||
// 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见
|
||||
// rolesListPlaceholder 注释)。
|
||||
const placeholder = rolesListPlaceholder(roles, err)
|
||||
|
||||
const sel = roles?.find(r => r.roleId === editRole)
|
||||
const editCurrent = sel?.actions?.[editAction]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
|
||||
onChange={e => setNewName(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()}
|
||||
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
|
||||
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
|
||||
setNewName('')
|
||||
})}>创建角色</button>
|
||||
</div>
|
||||
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
|
||||
</div>
|
||||
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(roles ?? []).map(r => (
|
||||
<tr key={r.roleId}>
|
||||
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></td>
|
||||
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
|
||||
<td className="ad-dim">v{r.version}</td>
|
||||
<td>{Object.keys(r.actions ?? {}).length} 项</td>
|
||||
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
|
||||
<td><button className="ad-mini" disabled={busy}
|
||||
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
|
||||
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
|
||||
{r.disabled ? '启用' : '停用'}</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
|
||||
{placeholder && <tr><td colSpan={6} className="ad-dim">{placeholder}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
|
||||
<div className="ad-row">
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<select className="lg-input" value={editAction}
|
||||
onChange={e => {
|
||||
setEditAction(e.target.value)
|
||||
const cur = sel?.actions?.[e.target.value]
|
||||
setEditDef(!!cur?.[0]); setEditCap(cur ? cur[1] : true)
|
||||
}}>
|
||||
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<label className="ad-check"><input type="checkbox" checked={editDef}
|
||||
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
|
||||
<label className="ad-check"><input type="checkbox" checked={editCap}
|
||||
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
|
||||
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
|
||||
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
|
||||
</div>
|
||||
{sel && editCurrent && <p className="ad-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
|
||||
onChange={e => setAssignOwner(e.target.value)} />
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
|
||||
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
|
||||
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
|
||||
</div>
|
||||
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { dutyConditionNote, linkedCapabilitySummary } from '../permissionExplanations'
|
||||
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
import {
|
||||
type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type RdmMemberPage, type RdmProfRole,
|
||||
type SearchFieldsChoice,
|
||||
MATCH_MODE_LABELS, SEARCH_FIELDS, accountCandidatePager, accountSearchKey, appendMemberPage, memberListParams,
|
||||
personLabel, roleOptionsPayload,
|
||||
} from '../rdmManagement'
|
||||
|
||||
interface MemberRow {
|
||||
owner: string; role: string; addedBy: string; addedAt: string
|
||||
owner: string; name?: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: { roleId: string; name: string } | null
|
||||
}
|
||||
|
||||
@@ -157,42 +33,84 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
const writeError = projectWorkError(project)
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||
const [memberNext, setMemberNext] = useState('')
|
||||
const [memberTotal, setMemberTotal] = useState<number | null>(null)
|
||||
const [directoryOk, setDirectoryOk] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [memberQuery, setMemberQuery] = useState('')
|
||||
const [memberRoleId, setMemberRoleId] = useState('')
|
||||
// null=未加载或 rdm.role.options 失败:两处专业角色筛选均不渲染(不给误导选项)。
|
||||
const [roleOptions, setRoleOptions] = useState<RdmProfRole[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [picked, setPicked] = useState<string[]>([])
|
||||
const [pickedItems, setPickedItems] = useState<PersonPickItem[]>([])
|
||||
const [candRoleId, setCandRoleId] = useState('')
|
||||
const [candMatch, setCandMatch] = useState<MemberMatchMode>('fuzzy')
|
||||
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
|
||||
const [role, setRole] = useState<'member' | 'owner'>('member')
|
||||
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
|
||||
const rowsRef = useRef<MemberRow[] | null>(null)
|
||||
rowsRef.current = rows
|
||||
const knownPicks = useRef(new Map<string, PersonPickItem>())
|
||||
// 成员表请求代次:切项目/切筛选推进代次,迟到响应丢弃。
|
||||
const memberGen = useRef(0)
|
||||
|
||||
/** 人员选择器取数适配(P7-A):rdm.account.list 目录(member.manage 生效者)。
|
||||
* query 透传后端(基座升级 AccountSearcher 后全量生效);旧基座忽略 query 时
|
||||
* 对首页做本地包含匹配降级(翻页为浏览模式,不过滤)。 */
|
||||
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const out = await cmd<{ accounts?: { owner: string; name?: string; valid: boolean }[]; next?: string }>(
|
||||
'rdm.account.list', { projectId, cursor, limit, query })
|
||||
const joined = new Set((rowsRef.current ?? []).map(m => m.owner))
|
||||
let items = (out.accounts ?? []).filter(a => a.valid).map(a => ({
|
||||
id: a.owner,
|
||||
title: a.name || a.owner,
|
||||
subtitle: a.owner,
|
||||
badge: joined.has(a.owner) ? '已加入' : undefined,
|
||||
}))
|
||||
if (query !== '' && cursor === '') {
|
||||
const q = query.toLowerCase()
|
||||
items = items.filter(it => it.id.toLowerCase().includes(q) || it.title.toLowerCase().includes(q))
|
||||
}
|
||||
return { items, nextCursor: out.next || undefined }
|
||||
useEffect(() => {
|
||||
setPicked([]); setPickedItems([]); knownPicks.current.clear()
|
||||
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
|
||||
setRoleOptions(null)
|
||||
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])
|
||||
|
||||
const refresh = async () => {
|
||||
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
|
||||
[candMatch, candFields, candRoleId])
|
||||
|
||||
/** 人员选择器取数适配:rdm.account.list 服务端按 query/mode/searchFields/roleId 先过滤后分页。 */
|
||||
const fetchPage = useMemo(() => {
|
||||
const page = accountCandidatePager(params => cmd<RdmAccountPage>('rdm.account.list', params), projectId,
|
||||
{ ...candOptions, joinedOwners: () => (rowsRef.current ?? []).map(m => m.owner) })
|
||||
return async (query: string, cursor: string, limit: number) => {
|
||||
const out = await page(query, cursor, limit)
|
||||
for (const it of out.items) knownPicks.current.set(it.id, it)
|
||||
return out
|
||||
}
|
||||
}, [project, candOptions])
|
||||
|
||||
/** cursor 空=重载首页(clear 时先清空表格);非空=追加下一页。 */
|
||||
const loadMembers = useCallback(async (cursor: string, clear: boolean) => {
|
||||
const gen = cursor ? memberGen.current : ++memberGen.current
|
||||
if (cursor) setLoadingMore(true)
|
||||
else {
|
||||
setLoadingMore(false)
|
||||
if (clear) { setErr(''); setRows(null); setMemberNext(''); setMemberTotal(null) }
|
||||
}
|
||||
try {
|
||||
const out = await cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
setRows(out.members ?? [])
|
||||
} catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { setPicked([]); refresh() }, [project])
|
||||
const out = await cmd<RdmMemberPage>('rdm.project.member.list',
|
||||
memberListParams(projectId, { query: memberQuery, roleId: memberRoleId, cursor }))
|
||||
if (gen !== memberGen.current) return
|
||||
setRows(prev => appendMemberPage(cursor ? prev : null, out.members))
|
||||
setMemberNext(out.next || '')
|
||||
setMemberTotal(typeof out.total === 'number' ? out.total : null)
|
||||
setDirectoryOk(out.accountDirectoryAvailable !== false)
|
||||
} catch (e) {
|
||||
if (gen === memberGen.current) setErr(String(e))
|
||||
} finally {
|
||||
if (cursor && gen === memberGen.current) setLoadingMore(false)
|
||||
}
|
||||
}, [project, memberQuery, memberRoleId])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { void loadMembers('', true) }, memberQuery.trim() ? 250 : 0)
|
||||
return () => { clearTimeout(timer); ++memberGen.current }
|
||||
}, [loadMembers])
|
||||
|
||||
const refresh = () => loadMembers('', false)
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
@@ -200,38 +118,82 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const onPick = (ids: string[]) => {
|
||||
setPicked(ids)
|
||||
setPickedItems(ids.flatMap(id => {
|
||||
const it = knownPicks.current.get(id)
|
||||
return it ? [it] : []
|
||||
}))
|
||||
}
|
||||
|
||||
const pickedId = picked[0] ?? ''
|
||||
const pickedItem = pickedItems.find(it => it.id === pickedId)
|
||||
const pickedValid = !!pickedItem && !pickedItem.disabled && !(rows ?? []).some(m => m.owner === pickedItem.owner)
|
||||
const pickerDisabled = busy || !!writeError
|
||||
const filtering = memberQuery.trim() !== '' || memberRoleId !== ''
|
||||
|
||||
const roleFilter = (value: string, onChange: (v: string) => void, label: string) =>
|
||||
roleOptions && roleOptions.length > 0 ? (
|
||||
<select className="lg-input" aria-label={label} value={value} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">全部专业角色</option>
|
||||
{roleOptions.map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
|
||||
<select className="lg-input" aria-label="匹配方式" value={candMatch}
|
||||
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
|
||||
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
|
||||
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
|
||||
</select>
|
||||
<select className="lg-input" aria-label="检索字段" value={candFields}
|
||||
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
|
||||
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
|
||||
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<Picker
|
||||
aria-label="选择人员"
|
||||
selected={picked}
|
||||
onSelect={setPicked}
|
||||
onSelect={onPick}
|
||||
selectedItems={pickedItems}
|
||||
fetchPage={fetchPage}
|
||||
placeholder="输入账号或姓名,或浏览候选…"
|
||||
reloadKey={accountSearchKey(projectId, candOptions)}
|
||||
disabled={pickerDisabled}
|
||||
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
|
||||
emptyText="输入后显示匹配账号"
|
||||
/>
|
||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">项目成员</option>
|
||||
<option value="owner">项目负责人</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedId}
|
||||
onClick={() => run(`已添加 ${pickedId}`, async () => {
|
||||
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role))
|
||||
setPicked([])
|
||||
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
|
||||
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
|
||||
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
|
||||
setPicked([]); setPickedItems([])
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
|
||||
onChange={e => setMemberQuery(e.target.value)} />
|
||||
{roleFilter(memberRoleId, setMemberRoleId, '成员专业角色筛选')}
|
||||
{rows !== null && <span className="ad-dim">共 {memberTotal ?? rows.length} 名成员匹配</span>}
|
||||
</div>
|
||||
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
<td><span className="ad-mono">{m.owner}</span>{m.profRole?.name ? <span className="ad-dim"> · {m.profRole.name}</span> : null}</td>
|
||||
<td className="ad-mono">{personLabel(m)}</td>
|
||||
<td className="ad-dim">{m.profRole?.name || '—'}</td>
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
|
||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||
@@ -249,10 +211,16 @@ export function RdmMembersSection({ project }: { project: Project }) {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无成员</td></tr>}
|
||||
{rows === null && <tr><td colSpan={6} className="ad-dim">加载中…</td></tr>}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||
{rows === null && <tr><td colSpan={6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{memberNext && rows !== null && (
|
||||
<div className="ad-row">
|
||||
<button className="ad-mini" disabled={loadingMore}
|
||||
onClick={() => loadMembers(memberNext, false)}>{loadingMore ? '加载中…' : '加载更多成员'}</button>
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
@@ -302,7 +270,10 @@ function ActionDetailTable({ actions, onOverride, canManage }: {
|
||||
<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>
|
||||
<td className="ad-dim">
|
||||
{a.reason || '—'}
|
||||
{dutyConditionNote(a.action) && <span className="ad-dim ad-cond-note">{dutyConditionNote(a.action)}</span>}
|
||||
</td>
|
||||
{canManage ? (
|
||||
<td>
|
||||
<select className="lg-input" value={overrideOf(a)} style={{ minWidth: 120 }}
|
||||
@@ -393,6 +364,9 @@ export function RdmPermMatrixSection({ project }: { project: Project }) {
|
||||
}
|
||||
|
||||
const canManage = !writeError && !!matrix && matrix.role !== undefined
|
||||
const linked = me?.linkedCapabilities
|
||||
const showLinked = !!linked && !!matrix && matrix.member === me?.owner
|
||||
&& (linked.projectCreate || (linked.actions ?? []).length > 0)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -418,6 +392,11 @@ export function RdmPermMatrixSection({ project }: { project: Project }) {
|
||||
|
||||
{view === 'member' && matrix && (
|
||||
<>
|
||||
{showLinked && (
|
||||
<Collapsible trigger="创建项目与本人项目管理资格">
|
||||
<p className="ad-hint">{linkedCapabilitySummary(linked)}</p>
|
||||
</Collapsible>
|
||||
)}
|
||||
<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></>}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
|
||||
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
|
||||
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type RecoveryProposal } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { AgentProfile } from '../contracts'
|
||||
@@ -9,12 +9,13 @@ import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocB
|
||||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery'
|
||||
import {
|
||||
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected,
|
||||
recoveryStatusLabel, type RecoveryAttempt,
|
||||
} from '../recovery'
|
||||
|
||||
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化
|
||||
// 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key;
|
||||
// 恢复走属主确认制提案:管理员提案 → 用户在「设置 → 账号」本人确认 → 一次性 Key
|
||||
// 只在属主会话内出现(直接签发 Key 的 HTTP 入口已被 410 替换)。----
|
||||
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
|
||||
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
|
||||
export function UsersPanel() {
|
||||
@@ -24,16 +25,27 @@ export function UsersPanel() {
|
||||
const [notice, setNotice] = useState('')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [initialPassword, setInitialPassword] = useState('')
|
||||
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
|
||||
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
|
||||
// 提案视图代次:切换/关闭目标推进代次,迟到响应不得覆盖新选中的账号。
|
||||
const proposalSeq = useRef(0)
|
||||
// 结果未确定的提案请求:显式重试沿用同一 idempotencyKey,不重复创建。
|
||||
const recoveryAttempt = useRef<RecoveryAttempt | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
const refreshProposals = async (userId: string) => {
|
||||
try { setProposals(await api.listAdminRecoveryProposals(userId)) } catch (e) { setErr(String(e)) }
|
||||
const seq = proposalSeq.current
|
||||
try {
|
||||
const statuses = await api.listAdminRecoveryProposals(userId)
|
||||
if (seq === proposalSeq.current) setProposals(statuses)
|
||||
} catch (e) { if (seq === proposalSeq.current) setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
@@ -41,34 +53,82 @@ export function UsersPanel() {
|
||||
setErr(''); setNotice('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim())
|
||||
setNotice(`已创建 ${out.owner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`)
|
||||
setOwner(''); setName('')
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined)
|
||||
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
|
||||
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
|
||||
setOwner(''); setName(''); setInitialPassword('')
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const resetPassword = async (u: AdminUserRow) => {
|
||||
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
|
||||
try {
|
||||
const out = await api.adminResetUserPassword(u.owner)
|
||||
setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'temporary' })
|
||||
setNotice(`已为 ${u.owner} 生成临时密码。该用户登录后必须立即修改密码。`)
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const openProposals = async (u: AdminUserRow) => {
|
||||
proposalSeq.current++
|
||||
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
|
||||
await refreshProposals(u.userId)
|
||||
}
|
||||
const closeProposals = () => { proposalSeq.current++; setPropFor(null); setProposals(null) }
|
||||
|
||||
const propose = async () => {
|
||||
if (!propFor) return
|
||||
const target = propFor
|
||||
const seq = proposalSeq.current
|
||||
const stillSelected = () => seq === proposalSeq.current
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
const idempotencyKey = recoveryAttemptKey(recoveryAttempt.current, target.userId, target.stateVersion, () => crypto.randomUUID())
|
||||
recoveryAttempt.current = { userId: target.userId, stateVersion: target.stateVersion, key: idempotencyKey }
|
||||
try {
|
||||
await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID())
|
||||
setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`)
|
||||
await refreshProposals(propFor.userId)
|
||||
} catch (e) { setErr(recoveryProposalErrorMessage(String(e))) } finally { setBusy(false) }
|
||||
const created = await api.createAdminRecoveryProposal(target.userId, target.stateVersion, idempotencyKey)
|
||||
recoveryAttempt.current = null
|
||||
const statuses = await api.listAdminRecoveryProposals(target.userId).catch(() => null)
|
||||
if (!stillSelected()) return
|
||||
if (statuses) setProposals(statuses)
|
||||
setNotice(recoveryProposalNotice(target.owner, (statuses?.find(p => p.id === created.id) ?? created).status))
|
||||
} catch (e) {
|
||||
const message = String(e)
|
||||
if (message.includes('409')) {
|
||||
recoveryAttempt.current = null
|
||||
await resetPassword(target)
|
||||
return
|
||||
}
|
||||
if (!stillSelected()) return
|
||||
if (recoveryProposalRejected(message)) {
|
||||
recoveryAttempt.current = null
|
||||
setErr(recoveryProposalErrorMessage(message))
|
||||
return
|
||||
}
|
||||
// 响应丢失或服务端异常时提案可能已提交:先重读元数据,再允许沿用同一 key 显式重试。
|
||||
try {
|
||||
const statuses = await api.listAdminRecoveryProposals(target.userId)
|
||||
if (!stillSelected()) return
|
||||
setProposals(statuses)
|
||||
if (statuses.length > 0) {
|
||||
setNotice(`提案响应未收到;状态已刷新。最近记录:${recoveryStatusLabel[statuses[0].status]}。请核对下方记录;再次提交会沿用同一请求标识。`)
|
||||
} else {
|
||||
setErr('提案结果未确定;状态已刷新。再次提交会沿用同一请求标识。')
|
||||
}
|
||||
} catch {
|
||||
if (stillSelected()) setErr('提案结果未知且状态暂时无法读取;请稍后核对,不会自动重复创建。')
|
||||
}
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const revokeProposal = async (p: RecoveryProposal) => {
|
||||
if (!propFor) return
|
||||
setErr(''); setBusy(true)
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version)
|
||||
await refreshProposals(propFor.userId)
|
||||
setNotice('提案已撤销')
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
@@ -79,19 +139,25 @@ export function UsersPanel() {
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
|
||||
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
|
||||
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<p className="ad-hint">账号创建后处于待初始化状态:在服务器执行 user initialize-account 发放一次性激活 Key,用户经兑换入口设置密码后登录</p>
|
||||
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{generatedCredential && <div className="ad-reset">
|
||||
<p className="ad-hint">{generatedCredential.kind === 'initial' ? '初始密码' : '临时密码'}仅显示一次,请立即安全交付给 {generatedCredential.owner}。</p>
|
||||
<input className="lg-input ad-mono" readOnly value={generatedCredential.password} aria-label="一次性密码" />
|
||||
<button type="button" className="ad-mini" onClick={() => setGeneratedCredential(null)}>关闭</button>
|
||||
</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
{propFor ? (
|
||||
<div className="ad-reset">
|
||||
<div className="ad-row">
|
||||
<span><b>{propFor.owner}</b> 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key)</span>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={propose}>发起恢复提案</button>
|
||||
<button type="button" className="ad-mini" onClick={() => { setPropFor(null); setProposals(null) }}>关闭</button>
|
||||
<button type="button" className="ad-mini" onClick={closeProposals}>关闭</button>
|
||||
</div>
|
||||
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
|
||||
{proposals !== null && proposals.length > 0 && (
|
||||
@@ -125,9 +191,10 @@ export function UsersPanel() {
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && (
|
||||
<td>{me?.isAdmin && <span className="ad-row">
|
||||
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
|
||||
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
|
||||
)}</td>
|
||||
</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from '../../api'
|
||||
import type { Project } from '../../contracts'
|
||||
import { projectWorkError } from '../../projectmeta'
|
||||
import { Btn, Collapsible, EmptyState, Icon } from '@ui'
|
||||
import { dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload } from '../../rdmManagement'
|
||||
import {
|
||||
SEARCH_FIELDS, accountCandidatePager, accountSearchKey, dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload,
|
||||
type DutyAccountIds, type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type SearchFieldsChoice,
|
||||
} from '../../rdmManagement'
|
||||
import {
|
||||
absorbServerDocs, dirtyDocs, editCell, savePayload,
|
||||
type CellDuty, type DutyDocDraft, type DutyMember,
|
||||
} from '../../duties/draft'
|
||||
import { DutyByDocumentView, DutyByMemberView, DutyChangePreview, DutyMatrixView } from '../../duties/views'
|
||||
import { DutyByDocumentView, DutyByMemberView, DutyCandidateSearch, DutyChangePreview, DutyMatrixView } from '../../duties/views'
|
||||
|
||||
type ViewName = 'member' | 'document' | 'matrix'
|
||||
|
||||
@@ -68,6 +71,9 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
const [confirm, setConfirm] = useState(false)
|
||||
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
|
||||
const [legacyOpen, setLegacyOpen] = useState(false)
|
||||
const [candMode, setCandMode] = useState<MemberMatchMode>('fuzzy')
|
||||
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
|
||||
const [accountIds, setAccountIds] = useState<DutyAccountIds>({})
|
||||
const seq = useRef(0)
|
||||
const op = useRef(0)
|
||||
const docsRef = useRef(docs)
|
||||
@@ -117,12 +123,25 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
return () => { seq.current += 1 }
|
||||
}, [project, query, stage, typeKey, assignment, docCursor])
|
||||
|
||||
useEffect(() => { setAccountIds({}) }, [project])
|
||||
|
||||
const candOptions = useMemo(() => ({ docId, mode: candMode, searchFields: SEARCH_FIELDS[candFields].fields }),
|
||||
[docId, candMode, candFields])
|
||||
const fetchCandidates = useMemo(() => accountCandidatePager(
|
||||
params => api.rdmProjectCommand<RdmAccountPage>(project, 'rdm.account.list', params), projectId, candOptions,
|
||||
), [project, candOptions])
|
||||
|
||||
const onCell = (id: string, owner: string, duty: CellDuty) => {
|
||||
setPreview([])
|
||||
setConfirm(false)
|
||||
setDocs(current => editCell(current, id, owner, duty))
|
||||
}
|
||||
|
||||
const onCandidate = (id: string, candidate: PersonPickItem, duty: Exclude<CellDuty, 'none'>) => {
|
||||
setAccountIds(current => ({ ...current, [candidate.owner]: candidate.id }))
|
||||
onCell(id, candidate.owner, duty)
|
||||
}
|
||||
|
||||
const visibleDocs = docs.filter(doc => {
|
||||
const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase()
|
||||
if (query && !text.includes(query.trim().toLowerCase())) return false
|
||||
@@ -132,7 +151,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
})
|
||||
|
||||
const save = async () => {
|
||||
const payload = savePayload(docsRef.current, confirm)
|
||||
const payload = savePayload(docsRef.current, confirm, accountIds)
|
||||
if (payload.issues.length > 0) {
|
||||
setErr(payload.issues.join(';'))
|
||||
return
|
||||
@@ -154,7 +173,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
return
|
||||
}
|
||||
sent = true
|
||||
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm).docs))
|
||||
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm, accountIds).docs))
|
||||
if (request !== seq.current) return
|
||||
setNotice('已保存文档职责')
|
||||
setPreview([])
|
||||
@@ -228,6 +247,14 @@ export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
</div>
|
||||
{view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />}
|
||||
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
|
||||
{view === 'document' && (() => {
|
||||
const doc = docs.find(item => item.docId === docId)
|
||||
return doc ? (
|
||||
<DutyCandidateSearch doc={doc} mode={candMode} fields={candFields} fetchPage={fetchCandidates}
|
||||
reloadKey={accountSearchKey(projectId, candOptions)} disabled={disabled}
|
||||
onMode={setCandMode} onFields={setCandFields} onAssign={(candidate, duty) => onCandidate(doc.docId, candidate, duty)} />
|
||||
) : null
|
||||
})()}
|
||||
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
|
||||
<div className="ad-row">
|
||||
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 统一渲染的失败与操作状态展示:工作区与各面板共用,只渲染,不持有异步状态。
|
||||
// 原始回执只进 Collapsible 折叠区;可读文案由 describeRenderFailure 统一给出。
|
||||
import { Btn, Collapsible } from '@ui'
|
||||
import { describeRenderFailure, type RenderOperation } from '../documentRenderPresentation'
|
||||
|
||||
export interface OperationState { op: RenderOperation | null; status: 'idle' | 'running' | 'done' | 'failed'; error: string }
|
||||
|
||||
export function RenderFailure({ error, id }: { error: unknown; id: string }) {
|
||||
const f = describeRenderFailure(error)
|
||||
return (
|
||||
<div className="drw-failure" role="alert">
|
||||
<div className="drw-failure-msg">{f.message}</div>
|
||||
<Collapsible id={`drw-failure-${id}`} trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
|
||||
<code className="rdm-audit-err-raw">{f.raw}</code>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OperationStatus({ state, onRetry, labels }: { state: OperationState; onRetry: () => void; labels: Record<string, string> }) {
|
||||
if (state.status === 'idle' || !state.op) return null
|
||||
const label = labels[state.op.kind] ?? '操作'
|
||||
if (state.status === 'running') return <div className="drw-op"><span className="mk-sweep">{label}进行中</span></div>
|
||||
if (state.status === 'failed') {
|
||||
return (
|
||||
<div className="drw-op">
|
||||
<RenderFailure error={state.error} id={`op-${state.op.kind}`} />
|
||||
<Btn variant="ghost" onClick={onRetry}>重试</Btn>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// 统一渲染 Task 11(审签来源):整文审签的正式命令必须锚定一条已提交的对话消息(基座来源门禁)。
|
||||
// 流程:宿主项目的审签记录对话 → 提交一条中文审签说明(用户消息,幂等编号 = 动作编号)→ 以该消息为锚点分发命令。
|
||||
// 任一步失败都可读地拒绝且绝不分发命令;身份只由服务端认证主体决定,载荷不含任何身份字段。
|
||||
import type { Conversation, Message, Project } from './contracts'
|
||||
import { describeRenderFailure, type DecisionKind } from './documentRenderPresentation'
|
||||
import { rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
|
||||
|
||||
export const DECISION_RECORD_TITLE = '文档审签记录'
|
||||
const DECISION_ENTRANCE: CommandSource['entrance'] = 'button'
|
||||
const CONVERSATION_STAGE = '审签记录对话'
|
||||
const SOURCE_STAGE = '审签来源消息'
|
||||
|
||||
/** 只需要的会话端口:调用方传入共享的 api 对象,不另起一层 API。 */
|
||||
export interface DecisionPort {
|
||||
listConversations: (projectId: string) => Promise<Conversation[]>
|
||||
createConversation: (projectId: string, title?: string) => Promise<Conversation>
|
||||
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) => Promise<{ message?: Message; command?: string }>
|
||||
rdmCommand: (id: string, params: Record<string, unknown>, source?: CommandSource) => Promise<unknown>
|
||||
}
|
||||
|
||||
export interface DecisionRequest {
|
||||
host: Project | undefined
|
||||
/** 同一用户动作的编号:重试复用,服务端据此幂等返回同一条来源消息。 */
|
||||
operationId: string
|
||||
decision: DecisionKind
|
||||
doc: { id: string; title: string }
|
||||
round: number
|
||||
note: string
|
||||
command: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
function decisionLabel(kind: DecisionKind): string {
|
||||
switch (kind) {
|
||||
case 'approve': return '审核通过'
|
||||
case 'reject': return '驳回'
|
||||
case 'final': return '终批'
|
||||
default: {
|
||||
const exhaustive: never = kind
|
||||
throw new Error(`unknown decision kind: ${String(exhaustive)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 审签来源正文(中文):决定、文档、轮次与说明/原因;不含任何身份字段,首字符不为 “/” 以免被当作命令。 */
|
||||
export function decisionSourceText(input: Pick<DecisionRequest, 'decision' | 'doc' | 'round' | 'note'>): string {
|
||||
const doc = input.doc.title ? `${input.doc.title}(${input.doc.id})` : input.doc.id
|
||||
const noteLabel = input.decision === 'reject' ? '驳回原因' : '说明'
|
||||
return [
|
||||
`审签决定:${decisionLabel(input.decision)}`,
|
||||
`文档:${doc}`,
|
||||
`轮次:第 ${input.round} 轮`,
|
||||
`${noteLabel}:${input.note.trim() || '无'}`,
|
||||
'来源:文档渲染工作区',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** 研发项目 → 其宿主对话项目(settings 中绑定的 rdm.projectId 相同者)。 */
|
||||
export function hostProjectFor(projects: Project[], rdmProjectId: string): Project | undefined {
|
||||
return projects.find(p => {
|
||||
try { return rdmCommandProjectId(p) === rdmProjectId } catch { return false }
|
||||
})
|
||||
}
|
||||
|
||||
function refusal(stage: string, detail: string): Error {
|
||||
return new Error(`${stage}未能提交,审签未执行:${detail}`)
|
||||
}
|
||||
|
||||
function stepFailure(stage: string, err: unknown): Error {
|
||||
const raw = err instanceof Error ? err.message : String(err ?? '')
|
||||
if (/\b403\b/.test(raw)) return refusal(stage, '当前账号无权在该对话中记录审签')
|
||||
if (/\b409\b/.test(raw)) return refusal(stage, '会话正在处理其他任务,请稍后重试')
|
||||
if (/\b429\b/.test(raw)) return refusal(stage, '会话排队已满,请稍后重试')
|
||||
return refusal(stage, describeRenderFailure(err).message)
|
||||
}
|
||||
|
||||
/** 记录对话:复用宿主项目中标题为审签记录、未归档的对话;没有则新建。 */
|
||||
async function recordConversation(port: DecisionPort, projectId: string): Promise<Conversation> {
|
||||
let list: Conversation[]
|
||||
try {
|
||||
list = await port.listConversations(projectId)
|
||||
} catch (e) {
|
||||
throw stepFailure(CONVERSATION_STAGE, e)
|
||||
}
|
||||
const existing = list.find(c => c.title === DECISION_RECORD_TITLE && !c.archived)
|
||||
if (existing) return existing
|
||||
try {
|
||||
return await port.createConversation(projectId, DECISION_RECORD_TITLE)
|
||||
} catch (e) {
|
||||
throw stepFailure(CONVERSATION_STAGE, e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交来源消息:必须是已提交的同会话用户消息;被当作命令路由的说明不算来源。 */
|
||||
async function recordSource(port: DecisionPort, convId: string, req: DecisionRequest): Promise<string> {
|
||||
let out: { message?: Message; command?: string }
|
||||
try {
|
||||
out = await port.sendMessage(convId, decisionSourceText(req), [], `decision:${req.operationId}`)
|
||||
} catch (e) {
|
||||
throw stepFailure(SOURCE_STAGE, e)
|
||||
}
|
||||
if (out.command) throw refusal(SOURCE_STAGE, '审签说明被识别为命令,未作为来源记录')
|
||||
const msg = out.message
|
||||
if (!msg?.id || msg.role !== 'user' || msg.conversationId !== convId) {
|
||||
throw refusal(SOURCE_STAGE, '服务端未返回已提交的对话消息')
|
||||
}
|
||||
return msg.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 整文审签的唯一写路径:先记录来源,再以来源消息为锚点分发命令。
|
||||
* 宿主项目缺失、项目未就绪、任一记录步骤失败时,命令一律不分发。
|
||||
*/
|
||||
export async function commitDecision(port: DecisionPort, req: DecisionRequest): Promise<unknown> {
|
||||
const host = req.host
|
||||
if (!host) throw new Error('未找到该研发项目对应的对话项目,请刷新项目列表后重试;审签未执行')
|
||||
const gate = rdmProjectCommandError(host, req.command)
|
||||
if (gate) throw new Error(`${gate};审签未执行`)
|
||||
const conversation = await recordConversation(port, host.id)
|
||||
const anchorMessageId = await recordSource(port, conversation.id, req)
|
||||
return port.rdmCommand(req.command, req.params, {
|
||||
conversationId: conversation.id, anchorMessageId, entrance: DECISION_ENTRANCE,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+19
-9
@@ -1,4 +1,6 @@
|
||||
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
|
||||
import { dutyAccountIds, type DutyAccountIds } from '../rdmManagement'
|
||||
|
||||
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
|
||||
|
||||
interface DutyRoles {
|
||||
@@ -35,6 +37,7 @@ interface DutySaveItem {
|
||||
approver: string
|
||||
expectedDutyVersion: number
|
||||
confirmInvalidation: boolean
|
||||
accountIds?: DutyAccountIds
|
||||
}
|
||||
|
||||
export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
|
||||
@@ -174,7 +177,10 @@ export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
|
||||
return docs.filter(doc => doc.dirty)
|
||||
}
|
||||
|
||||
export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): { docs: DutySaveItem[]; issues: string[] } {
|
||||
/** accountIds:候选检索选中的 owner → userId;每份文档只携带其职责涉及的人。 */
|
||||
export function savePayload(
|
||||
docs: DutyDocDraft[], confirmInvalidation: boolean, accountIds?: DutyAccountIds,
|
||||
): { docs: DutySaveItem[]; issues: string[] } {
|
||||
const dirty = dirtyDocs(docs)
|
||||
const issues: string[] = []
|
||||
if (dirty.length === 0) issues.push('没有未保存的职责')
|
||||
@@ -183,14 +189,18 @@ export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean):
|
||||
for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`)
|
||||
}
|
||||
return {
|
||||
docs: dirty.slice(0, 20).map(doc => ({
|
||||
docId: doc.docId,
|
||||
author: doc.author,
|
||||
reviewers: doc.reviewers,
|
||||
approver: doc.approver,
|
||||
expectedDutyVersion: doc.dutyVersion,
|
||||
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
|
||||
})),
|
||||
docs: dirty.slice(0, 20).map(doc => {
|
||||
const ids = dutyAccountIds(doc, accountIds)
|
||||
return {
|
||||
docId: doc.docId,
|
||||
author: doc.author,
|
||||
reviewers: doc.reviewers,
|
||||
approver: doc.approver,
|
||||
expectedDutyVersion: doc.dutyVersion,
|
||||
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
|
||||
...(ids ? { accountIds: ids } : {}),
|
||||
}
|
||||
}),
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Picker } from '@ui'
|
||||
import type { CellDuty, DutyDocDraft, DutyMember } from './draft'
|
||||
import { cellDuty, completeness, replacementNote, saveIssues } from './draft'
|
||||
import {
|
||||
MATCH_MODE_LABELS, SEARCH_FIELDS,
|
||||
type MemberMatchMode, type PersonPickItem, type SearchFieldsChoice,
|
||||
} from '../rdmManagement'
|
||||
|
||||
const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [
|
||||
{ value: 'none', label: '无职责' },
|
||||
@@ -120,6 +126,67 @@ export function DutyByDocumentView({
|
||||
)
|
||||
}
|
||||
|
||||
type AssignedDuty = Exclude<CellDuty, 'none'>
|
||||
|
||||
/** 文档职责候选检索:rdm.account.list 携带 docId,只有当前有效成员可选;选中后按 userId 指派。 */
|
||||
export function DutyCandidateSearch({
|
||||
doc, mode, fields, fetchPage, reloadKey, disabled, onMode, onFields, onAssign,
|
||||
}: {
|
||||
doc: DutyDocDraft
|
||||
mode: MemberMatchMode
|
||||
fields: SearchFieldsChoice
|
||||
fetchPage: (query: string, cursor: string, limit: number) => Promise<{ items: PersonPickItem[]; nextCursor?: string }>
|
||||
reloadKey: string
|
||||
disabled: boolean
|
||||
onMode: (mode: MemberMatchMode) => void
|
||||
onFields: (fields: SearchFieldsChoice) => void
|
||||
onAssign: (candidate: PersonPickItem, duty: AssignedDuty) => void
|
||||
}) {
|
||||
const [picked, setPicked] = useState<PersonPickItem | null>(null)
|
||||
const [duty, setDuty] = useState<AssignedDuty>('reviewer')
|
||||
const known = useRef(new Map<string, PersonPickItem>())
|
||||
useEffect(() => { known.current.clear(); setPicked(null) }, [reloadKey])
|
||||
const fetchKnown = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const page = await fetchPage(query, cursor, limit)
|
||||
for (const item of page.items) known.current.set(item.id, item)
|
||||
return page
|
||||
}, [fetchPage])
|
||||
return (
|
||||
<div className="settings-group">
|
||||
<h4>搜索候选人</h4>
|
||||
<div className="ad-row">
|
||||
<select className="lg-input" aria-label="候选匹配方式" value={mode} onChange={event => onMode(event.target.value as MemberMatchMode)}>
|
||||
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m => <option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
|
||||
</select>
|
||||
<select className="lg-input" aria-label="候选检索字段" value={fields} onChange={event => onFields(event.target.value as SearchFieldsChoice)}>
|
||||
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f => <option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<Picker
|
||||
aria-label="职责候选人"
|
||||
selected={picked ? [picked.id] : []}
|
||||
selectedItems={picked ? [picked] : []}
|
||||
onSelect={ids => setPicked(known.current.get(ids[0] ?? '') ?? null)}
|
||||
fetchPage={fetchKnown}
|
||||
reloadKey={reloadKey}
|
||||
disabled={disabled}
|
||||
placeholder={mode === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
|
||||
emptyText="输入后显示当前有效成员"
|
||||
/>
|
||||
<select className="lg-input" aria-label="候选人职责" value={duty} onChange={event => setDuty(event.target.value as AssignedDuty)}>
|
||||
{DUTY_OPTIONS.filter(option => option.value !== 'none').map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<button className="ad-mini" disabled={disabled || !picked || picked.disabled}
|
||||
onClick={() => { if (picked) { onAssign(picked, duty); setPicked(null) } }}>
|
||||
{picked ? `指派到 ${doc.title}` : '指派'}
|
||||
</button>
|
||||
</div>
|
||||
{picked && <p className="ad-hint">{picked.title} · {picked.owner}{replacementNote(doc, picked.owner, duty) ? `(${replacementNote(doc, picked.owner, duty)})` : ''}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DutyMatrixView({
|
||||
docs, members, disabled, narrow, onCell,
|
||||
}: {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// 项目有效权限说明的纯文案层:只消费服务端字段(/api/me linkedCapabilities)与固定权限
|
||||
// 语义,不按角色/成员关系推算任何有效权限。未知动作 id / 来源回退原文。
|
||||
import type { LinkedCapabilities } from './api'
|
||||
|
||||
/** 联动动作中文名(与后端权限 catalog 一致)。 */
|
||||
export const LINKED_ACTION_LABELS: Record<string, string> = {
|
||||
'project.settings': '管理项目设置',
|
||||
'project.members.manage': '管理项目成员',
|
||||
'project.permissions.manage': '管理项目权限',
|
||||
'document.duties.assign': '分配文档职责',
|
||||
}
|
||||
|
||||
const PROJECT_CREATE_SOURCE_LABELS: Record<string, string> = {
|
||||
grant: '管理员授予',
|
||||
'admin-grant': '管理员授予',
|
||||
direct: '管理员授予',
|
||||
role: '角色',
|
||||
admin: '系统管理员',
|
||||
}
|
||||
|
||||
const actionLabels = (ids: string[]) => ids.map(id => LINKED_ACTION_LABELS[id] ?? id).join('、')
|
||||
|
||||
/** 创建项目与本人项目管理资格说明;字段缺失(旧服务)返回空串。 */
|
||||
export function linkedCapabilitySummary(linked: LinkedCapabilities | undefined): string {
|
||||
if (!linked) return ''
|
||||
if (!linked.projectCreate) return '未具备创建项目资格'
|
||||
const source = linked.source ? `(来源:${PROJECT_CREATE_SOURCE_LABELS[linked.source] ?? linked.source})` : ''
|
||||
const parts = [`已具备创建项目资格${source}`]
|
||||
const actions = linked.actions ?? []
|
||||
if (actions.length > 0) {
|
||||
parts.push(`创建本人项目后自动具备:${actionLabels(actions)}`)
|
||||
parts.push('这些资格仅对本人创建的项目生效,其他项目按项目授权判断')
|
||||
}
|
||||
const conflicts = linked.conflicts ?? []
|
||||
if (conflicts.length > 0) parts.push(`存在冲突:${actionLabels(conflicts)} 被明确禁止`)
|
||||
return parts.join(';')
|
||||
}
|
||||
|
||||
const DUTY_CONDITION_NOTES: Record<string, string> = {
|
||||
'document.author': '仍需被分配该文档的编制职责,且文档状态符合要求',
|
||||
'document.review': '仍需被分配该文档的审核职责,且文档状态符合要求',
|
||||
'document.approve': '仍需被分配该文档的批准职责,且文档状态符合要求',
|
||||
}
|
||||
|
||||
/** 文档编制/审核/批准动作的固定条件说明(权限语义,非对具体文档的判定)。 */
|
||||
export function dutyConditionNote(actionId: string): string {
|
||||
return DUTY_CONDITION_NOTES[actionId] ?? ''
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState,
|
||||
isTreeItemExpandable, relationOtherEndpoint,
|
||||
} from './rdmAuditPresentation'
|
||||
|
||||
describe('RDM audit presentation', () => {
|
||||
it('turns a structured document event into a readable action and impact', () => {
|
||||
expect(describeAuditEvent({
|
||||
seq: 18,
|
||||
at: '2026-10-15T08:00:00Z',
|
||||
op: 'doc.instantiate',
|
||||
actor: 'alice',
|
||||
source: 'template:dhf-proposal@v2',
|
||||
target: { kind: 'doc', id: 'doc-1', title: '项目建议书' },
|
||||
detail: '{"raw":"must not be shown"}',
|
||||
operationId: 'op-1',
|
||||
disclosure: 'ok',
|
||||
result: 'committed',
|
||||
impact: '新增 12 个递归节点',
|
||||
reason: '从已发布模板建立项目实例',
|
||||
})).toEqual({
|
||||
action: '创建文档实例',
|
||||
subject: 'Alice',
|
||||
object: '文档“项目建议书”',
|
||||
outcome: '已生效',
|
||||
reason: '从已发布模板建立项目实例',
|
||||
impact: '新增 12 个递归节点',
|
||||
source: '模板:DHF 项目建议书(版本 v2)',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose unknown operation codes or raw JSON as user-facing text', () => {
|
||||
const view = describeAuditEvent({
|
||||
seq: 19,
|
||||
at: '2026-10-15T08:01:00Z',
|
||||
op: 'internal.weird_opcode',
|
||||
actor: 'user-42',
|
||||
source: 'host',
|
||||
target: { kind: 'node', id: 'node-secret' },
|
||||
detail: '{"secret":"raw payload"}',
|
||||
operationId: 'op-2',
|
||||
disclosure: 'restricted',
|
||||
})
|
||||
expect(view.action).toBe('更新项目内容')
|
||||
expect(JSON.stringify(view)).not.toContain('internal.weird_opcode')
|
||||
expect(JSON.stringify(view)).not.toContain('raw payload')
|
||||
expect(view.outcome).toBe('部分信息受限')
|
||||
expect(view.object).toBe('节点(node-secret)')
|
||||
expect(view.source).toBe('系统受信操作记录')
|
||||
})
|
||||
|
||||
it('preserves domain provenance and object identity in readable audit rows', () => {
|
||||
expect(describeAuditEvent({
|
||||
seq: 20,
|
||||
at: '2026-10-15T08:02:00Z',
|
||||
op: 'edge.candidate',
|
||||
actor: 'model-runner',
|
||||
source: 'model',
|
||||
target: { kind: 'edge', id: 'edg-7' },
|
||||
operationId: 'op-3',
|
||||
disclosure: 'model-candidate',
|
||||
})).toMatchObject({
|
||||
action: '提出追溯关系候选',
|
||||
object: '追溯边(edg-7)',
|
||||
source: '模型分析候选',
|
||||
})
|
||||
expect(describeAuditEvent({
|
||||
seq: 21,
|
||||
at: '2026-10-15T08:03:00Z',
|
||||
op: 'section.add',
|
||||
actor: 'alice',
|
||||
source: 'human',
|
||||
target: { kind: 'section', id: 'sec-2' },
|
||||
operationId: 'op-4',
|
||||
disclosure: 'ok',
|
||||
}).source).toBe('项目成员操作')
|
||||
})
|
||||
|
||||
it('humanizes tree, relationship, and lifecycle vocabulary', () => {
|
||||
expect(humanizeKind('section')).toBe('章节')
|
||||
expect(humanizeState('candidate')).toBe('待确认')
|
||||
expect(humanizeState('retired')).toBe('已停用')
|
||||
expect(humanizeState('stale')).toBe('待复核')
|
||||
expect(humanizeRelation({ layer: 'compliance', type: 'verifies', state: 'active' })).toEqual({
|
||||
layer: '合规关系',
|
||||
type: '验证',
|
||||
state: '已生效',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows recursive sections to expand when the server reports children', () => {
|
||||
expect(isTreeItemExpandable({ kind: 'doc', id: 'doc-1', title: '文档', hasChildren: true })).toBe(true)
|
||||
expect(isTreeItemExpandable({ kind: 'doc', id: 'doc-2', title: '空文档', hasChildren: false })).toBe(false)
|
||||
expect(isTreeItemExpandable({ kind: 'section', id: 'sec-1', title: '章节', hasChildren: true })).toBe(true)
|
||||
expect(isTreeItemExpandable({ kind: 'paragraph', id: 'p-1', title: '段落', hasChildren: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('gives untitled template nodes a distinct readable name', () => {
|
||||
expect(describeTreeItem({
|
||||
kind: 'paragraph',
|
||||
id: 'dn-1',
|
||||
title: '',
|
||||
order: 6,
|
||||
templateBlockId: 'dhf.dx.a7850d1881.3.placeholder',
|
||||
})).toEqual({ kind: '段落', title: '模板内容 7(3)' })
|
||||
expect(describeTreeItem({ kind: 'table', id: 'dn-2', title: '', order: 2 })).toEqual({
|
||||
kind: '表格',
|
||||
title: '模板内容 3',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the endpoint opposite the selected object in a trace relationship', () => {
|
||||
const relation = {
|
||||
from: { kind: 'slice', id: 'slc-6' },
|
||||
to: { kind: 'section', id: 'sec-2' },
|
||||
}
|
||||
expect(relationOtherEndpoint(relation, { kind: 'section', id: 'sec-2' })).toEqual(relation.from)
|
||||
expect(relationOtherEndpoint(relation, { kind: 'slice', id: 'slc-6' })).toEqual(relation.to)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
export interface AuditEventViewInput {
|
||||
seq: number
|
||||
at: string
|
||||
op: string
|
||||
actor: string
|
||||
source: string
|
||||
target: { kind: string; id: string; title?: string }
|
||||
detail?: string
|
||||
operationId: string
|
||||
disclosure: string
|
||||
summary?: string
|
||||
result?: string
|
||||
reason?: string
|
||||
impact?: string
|
||||
}
|
||||
|
||||
interface AuditEventPresentation {
|
||||
action: string
|
||||
subject: string
|
||||
object: string
|
||||
outcome: string
|
||||
reason: string
|
||||
impact: string
|
||||
source: string
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
doc: '文档', section: '章节', node: '节点', note: '知识', slice: '资料切片', spine: '知识主题', edge: '追溯边',
|
||||
paragraph: '段落', table: '表格', row: '表格行', cell: '表格单元格',
|
||||
field: '填写字段', attachment: '附件',
|
||||
requirement: '要求', risk: '风险', proposal: '变更提案', relation: '追溯关系',
|
||||
template: '模板', project: '项目', conclusion: '结论', issue: '问题',
|
||||
}
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
active: '已生效', candidate: '待确认', pending: '待处理', baselined: '已建立基线',
|
||||
retired: '已停用', stale: '待复核',
|
||||
approved: '已批准', rejected: '已拒绝', revoked: '已撤销', superseded: '已被取代',
|
||||
failed: '失败', committed: '已生效', accepted: '已接受', cancelled: '已取消',
|
||||
delivered: '已记录',
|
||||
unknown: '结果未知', restricted: '部分信息受限',
|
||||
}
|
||||
|
||||
const OP_LABELS: Record<string, string> = {
|
||||
'doc.instantiate': '创建文档实例',
|
||||
'section.add': '新增文档章节',
|
||||
'section.version': '更新文档内容',
|
||||
'section.state': '变更章节状态',
|
||||
'section.tombstone': '停用文档章节',
|
||||
'slice.ingest': '新增项目资料',
|
||||
'slice.retag': '更新资料标签',
|
||||
'slice.tombstone': '停用项目资料',
|
||||
'spine.add': '新增知识主题',
|
||||
'mount.add': '挂接项目资料',
|
||||
'mount.move': '移动项目资料',
|
||||
'mount.remove': '移除资料挂接',
|
||||
'edge.candidate': '提出追溯关系候选',
|
||||
'edge.confirm': '确认追溯关系',
|
||||
'edge.retire': '停用追溯关系',
|
||||
'change.requested': '提出变更申请',
|
||||
'change.approved': '批准变更申请',
|
||||
'change.applied': '应用变更',
|
||||
'open-item.add': '登记项目问题',
|
||||
'open-item.close': '关闭项目问题',
|
||||
'dictionary.revise': '修订项目字典',
|
||||
'req.change': '更新项目要求',
|
||||
}
|
||||
|
||||
const RELATION_LABELS: Record<string, string> = {
|
||||
verifies: '验证', supports: '支持', derives: '派生自', impacts: '影响',
|
||||
traces: '追溯到', replaces: '取代', conflicts: '存在冲突', references: '引用',
|
||||
}
|
||||
|
||||
const LAYER_LABELS: Record<string, string> = {
|
||||
compliance: '合规关系', knowledge: '知识关系', document: '文档关系', project: '项目关系',
|
||||
}
|
||||
|
||||
export function humanizeKind(kind: string): string {
|
||||
return KIND_LABELS[kind] || '项目对象'
|
||||
}
|
||||
|
||||
export function describeTreeItem(item: {
|
||||
kind: string
|
||||
id: string
|
||||
title?: string
|
||||
order?: number
|
||||
templateBlockId?: string
|
||||
}): { kind: string; title: string } {
|
||||
const title = item.title?.trim()
|
||||
if (title) return { kind: humanizeKind(item.kind), title }
|
||||
|
||||
const position = typeof item.order === 'number' ? item.order + 1 : undefined
|
||||
const parts = (item.templateBlockId || '').split('.').filter(Boolean)
|
||||
const semantic = new Set(['placeholder', 'example', 'fixed'])
|
||||
while (parts.length > 0 && semantic.has(parts[parts.length - 1])) parts.pop()
|
||||
const hint = parts.at(-1)
|
||||
const numbered = position ? `模板内容 ${position}` : '未命名模板内容'
|
||||
return {
|
||||
kind: humanizeKind(item.kind),
|
||||
title: hint ? `${numbered}(${hint})` : numbered,
|
||||
}
|
||||
}
|
||||
|
||||
export function humanizeState(state: string): string {
|
||||
return STATE_LABELS[state] || '状态已记录'
|
||||
}
|
||||
|
||||
function humanizeOperation(op: string): string {
|
||||
if (OP_LABELS[op]) return OP_LABELS[op]
|
||||
if (op.startsWith('doc.')) return '更新文档内容'
|
||||
if (op.startsWith('section.')) return '更新文档内容'
|
||||
if (op.startsWith('slice.') || op.startsWith('spine.') || op.startsWith('mount.') || op.startsWith('knowledge.')) return '更新项目知识'
|
||||
if (op.startsWith('edge.') || op.startsWith('relation.') || op.startsWith('trace.')) return '更新追溯关系'
|
||||
if (op.startsWith('change.')) return '处理项目变更'
|
||||
if (op.startsWith('open-item.')) return '处理项目问题'
|
||||
if (op.startsWith('risk.')) return '处理项目风险'
|
||||
if (op.startsWith('proposal.')) return '处理结构变更'
|
||||
return '更新项目内容'
|
||||
}
|
||||
|
||||
function humanizeActor(actor: string): string {
|
||||
if (!actor) return '未知成员'
|
||||
if (/^[a-z][a-z0-9._-]*$/i.test(actor) && !actor.includes('-') && !actor.includes('_')) {
|
||||
return actor.charAt(0).toUpperCase() + actor.slice(1)
|
||||
}
|
||||
return actor
|
||||
}
|
||||
|
||||
function humanizeSource(source: string): string {
|
||||
if (!source) return '当时未记录来源'
|
||||
const template = /^template:([^@]+)@(.+)$/.exec(source)
|
||||
if (template) {
|
||||
const title = template[1] === 'dhf-proposal' ? 'DHF 项目建议书' : template[1]
|
||||
return `模板:${title}(版本 ${template[2]})`
|
||||
}
|
||||
if (source === 'host') return '系统受信操作记录'
|
||||
if (source === 'human') return '项目成员操作'
|
||||
if (source === 'rule') return '项目规则推导'
|
||||
if (source === 'model') return '模型分析候选'
|
||||
if (source.startsWith('message:')) return '项目对话'
|
||||
if (source.startsWith('artifact:')) return '项目文件'
|
||||
return '已登记项目来源'
|
||||
}
|
||||
|
||||
function humanizeOutcome(input: AuditEventViewInput): string {
|
||||
if (input.disclosure !== 'ok') return input.disclosure === 'model-candidate' ? '模型候选,尚未确认' : '部分信息受限'
|
||||
return humanizeState(input.result || 'committed')
|
||||
}
|
||||
|
||||
export function describeAuditEvent(input: AuditEventViewInput): AuditEventPresentation {
|
||||
const targetTitle = input.target?.title ? `“${input.target.title}”` : ''
|
||||
const targetIdentity = targetTitle || (input.target?.id ? `(${input.target.id})` : '')
|
||||
return {
|
||||
action: input.summary || humanizeOperation(input.op),
|
||||
subject: humanizeActor(input.actor),
|
||||
object: `${humanizeKind(input.target?.kind)}${targetIdentity}`,
|
||||
outcome: humanizeOutcome(input),
|
||||
reason: input.reason || '未记录具体原因',
|
||||
impact: input.impact || '影响范围尚未记录',
|
||||
source: humanizeSource(input.source),
|
||||
}
|
||||
}
|
||||
|
||||
export function humanizeRelation(input: { layer?: string; type?: string; kind?: string; state?: string }) {
|
||||
const type = input.type || input.kind || ''
|
||||
return {
|
||||
layer: LAYER_LABELS[input.layer || ''] || '项目关系',
|
||||
type: RELATION_LABELS[type] || '关联',
|
||||
state: humanizeState(input.state || ''),
|
||||
}
|
||||
}
|
||||
|
||||
export function relationOtherEndpoint<T extends { kind?: string; id?: string }>(
|
||||
relation: { from?: T; to?: T },
|
||||
selected: { kind: string; id: string },
|
||||
): T | undefined {
|
||||
if (relation.to?.kind === selected.kind && relation.to.id === selected.id) return relation.from
|
||||
if (relation.from?.kind === selected.kind && relation.from.id === selected.id) return relation.to
|
||||
return relation.from || relation.to
|
||||
}
|
||||
|
||||
export function isTreeItemExpandable(item: { kind: string; hasChildren?: boolean; id?: string; title?: string }): boolean {
|
||||
return item.kind === 'spine' || item.hasChildren === true
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collectRecursiveTree, isTreeItemInspectable, mergeTreePage, type AuditTreePage } from './rdmAuditTree'
|
||||
|
||||
describe('RDM audit tree traversal', () => {
|
||||
it('keeps child cursors and appends later pages', () => {
|
||||
const first: AuditTreePage = {
|
||||
items: [{ kind: 'paragraph', id: 'p-1', title: '一' }],
|
||||
nextCursor: 'next', hasMore: true, truncated: false,
|
||||
}
|
||||
const second: AuditTreePage = {
|
||||
items: [{ kind: 'paragraph', id: 'p-2', title: '二' }],
|
||||
hasMore: false, truncated: false,
|
||||
}
|
||||
expect(mergeTreePage(first, second)).toEqual({
|
||||
items: [...first.items, ...second.items],
|
||||
hasMore: false,
|
||||
truncated: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('collects every page and every server-reported depth for trace selection', async () => {
|
||||
const pages = new Map<string, AuditTreePage>([
|
||||
['|', { items: [{ kind: 'doc', id: 'doc-1', title: '文档', hasChildren: true }], nextCursor: 'root-2', hasMore: true, truncated: false }],
|
||||
['|root-2', { items: [{ kind: 'doc', id: 'doc-2', title: '文档二', hasChildren: false }], hasMore: false, truncated: false }],
|
||||
['doc-1|', { items: [{ kind: 'section', id: 'dn-sec', title: '模板章', templateBlockId: 'tpl.sec', hasChildren: true }], nextCursor: 'child-2', hasMore: true, truncated: false }],
|
||||
['doc-1|child-2', { items: [{ kind: 'section', id: 'sec-2', title: '正文', hasChildren: false }], hasMore: false, truncated: false }],
|
||||
['dn-sec|', { items: [{ kind: 'paragraph', id: 'p-deep', title: '深层内容', hasChildren: false }], hasMore: false, truncated: false }],
|
||||
])
|
||||
const items = await collectRecursiveTree(async (parent, cursor) => {
|
||||
const page = pages.get(`${parent}|${cursor}`)
|
||||
if (!page) throw new Error(`unexpected page ${parent}|${cursor}`)
|
||||
return page
|
||||
})
|
||||
expect(items.map(item => item.id)).toEqual(['doc-1', 'doc-2', 'dn-sec', 'sec-2', 'p-deep'])
|
||||
})
|
||||
|
||||
it('only requests object details for legacy sections supported by the server', () => {
|
||||
expect(isTreeItemInspectable({ kind: 'section', id: 'sec-2', title: '正文', templateBlockId: '' })).toBe(true)
|
||||
expect(isTreeItemInspectable({ kind: 'section', id: 'dn-sec', title: '模板章', templateBlockId: 'tpl.sec' })).toBe(false)
|
||||
expect(isTreeItemInspectable({ kind: 'paragraph', id: 'p-1', title: '内容' })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface AuditTreeItem {
|
||||
kind: string
|
||||
id: string
|
||||
title: string
|
||||
state?: string
|
||||
hasChildren?: boolean
|
||||
order?: number
|
||||
templateBlockId?: string
|
||||
}
|
||||
|
||||
export interface AuditTreePage {
|
||||
items: AuditTreeItem[]
|
||||
nextCursor?: string
|
||||
hasMore: boolean
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
type AuditTreePageFetcher = (parent: string, cursor: string) => Promise<AuditTreePage>
|
||||
|
||||
export function mergeTreePage(current: AuditTreePage, next: AuditTreePage): AuditTreePage {
|
||||
return { ...next, items: [...current.items, ...next.items] }
|
||||
}
|
||||
|
||||
async function collectParentPages(fetchPage: AuditTreePageFetcher, parent: string): Promise<AuditTreeItem[]> {
|
||||
const items: AuditTreeItem[] = []
|
||||
const seenCursors = new Set<string>()
|
||||
let cursor = ''
|
||||
for (let pageNo = 0; pageNo < 100; pageNo++) {
|
||||
if (seenCursors.has(cursor)) throw new Error('树分页游标重复,已停止加载')
|
||||
seenCursors.add(cursor)
|
||||
const page = await fetchPage(parent, cursor)
|
||||
items.push(...page.items)
|
||||
if (!page.hasMore) return items
|
||||
if (!page.nextCursor) throw new Error('树分页缺少后续游标')
|
||||
cursor = page.nextCursor
|
||||
}
|
||||
throw new Error('树分页超过安全上限')
|
||||
}
|
||||
|
||||
export async function collectRecursiveTree(fetchPage: AuditTreePageFetcher): Promise<AuditTreeItem[]> {
|
||||
const roots = await collectParentPages(fetchPage, '')
|
||||
const out = [...roots]
|
||||
const queue = roots.filter(item => item.hasChildren === true)
|
||||
const seenParents = new Set<string>()
|
||||
while (queue.length > 0) {
|
||||
const parent = queue.shift()!
|
||||
if (seenParents.has(parent.id)) continue
|
||||
seenParents.add(parent.id)
|
||||
const children = await collectParentPages(fetchPage, parent.id)
|
||||
out.push(...children)
|
||||
if (out.length > 5000) throw new Error('树节点超过安全上限')
|
||||
queue.push(...children.filter(item => item.hasChildren === true))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function isTreeItemInspectable(item: AuditTreeItem): boolean {
|
||||
return item.kind === 'section' && !item.templateBlockId && !item.id.startsWith('dn-')
|
||||
}
|
||||
+204
-9
@@ -27,6 +27,7 @@ const PROJECT_READ_COMMANDS = new Set([
|
||||
'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
|
||||
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
|
||||
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan',
|
||||
'rdm.role.options',
|
||||
])
|
||||
|
||||
export function rdmProjectCommandError(project: Project, command: string): string | null {
|
||||
@@ -101,19 +102,39 @@ export const roleActionPayload = (roleId: string, action: string, def: boolean,
|
||||
export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId })
|
||||
export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled })
|
||||
|
||||
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
|
||||
({ projectId, member, role })
|
||||
/** userId 为候选检索选中的稳定账号;服务端按 member 精确查找后比对,不一致即拒。 */
|
||||
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member', userId?: string) =>
|
||||
({ projectId, member, role, ...(userId ? { userId } : {}) })
|
||||
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
|
||||
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
|
||||
|
||||
/** 职责账号身份:owner → 候选检索选中的 userId。 */
|
||||
export type DutyAccountIds = Record<string, string>
|
||||
|
||||
/** 只保留本条职责实际涉及的人;为空不携带。 */
|
||||
export function dutyAccountIds(
|
||||
roles: { author: string; reviewers: string[]; approver: string }, known: DutyAccountIds | undefined,
|
||||
): DutyAccountIds | undefined {
|
||||
if (!known) return undefined
|
||||
const out: DutyAccountIds = {}
|
||||
for (const owner of [roles.author, ...roles.reviewers, roles.approver]) {
|
||||
if (owner && known[owner]) out[owner] = known[owner]
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined
|
||||
}
|
||||
|
||||
export const assignDocDutiesPayload = (
|
||||
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
|
||||
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean },
|
||||
) => ({
|
||||
projectId, docId, author, reviewers, approver,
|
||||
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
|
||||
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
|
||||
})
|
||||
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean; accountIds?: DutyAccountIds },
|
||||
) => {
|
||||
const accountIds = dutyAccountIds({ author, reviewers, approver }, extra?.accountIds)
|
||||
return {
|
||||
projectId, docId, author, reviewers, approver,
|
||||
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
|
||||
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
|
||||
...(accountIds ? { accountIds } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const dutyMatrixPayload = (
|
||||
projectId: string,
|
||||
@@ -135,7 +156,10 @@ export const dutyMatrixPayload = (
|
||||
|
||||
export const dutyPreviewPayload = (
|
||||
projectId: string,
|
||||
docs: { docId: string; author: string; reviewers: string[]; approver: string; expectedDutyVersion: number; confirmInvalidation: boolean }[],
|
||||
docs: {
|
||||
docId: string; author: string; reviewers: string[]; approver: string
|
||||
expectedDutyVersion: number; confirmInvalidation: boolean; accountIds?: DutyAccountIds
|
||||
}[],
|
||||
) => ({ projectId, docs })
|
||||
|
||||
export const dutyLegacyPayload = (projectId: string) => ({ projectId })
|
||||
@@ -152,6 +176,175 @@ export const docStatusPayload = (projectId: string, docId: string) => ({ project
|
||||
|
||||
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
|
||||
|
||||
/**
|
||||
* 命令来源(基座 cmdbus.RequiresProjectWorkDecision):正式写命令必须带已提交的对话来源,
|
||||
* 且位于请求体顶层(conversationId/anchorMessageId/entrance),不得放进 params。
|
||||
* entrance 取服务端 CanonicalEntrance 集合;页面动作为 button。
|
||||
*/
|
||||
export interface CommandSource {
|
||||
conversationId: string
|
||||
anchorMessageId: string
|
||||
entrance: 'button' | 'card' | 'slash' | 'chat' | 'api' | 'agent'
|
||||
}
|
||||
|
||||
export const commandRequestBody = (params: Record<string, unknown>, source?: CommandSource) =>
|
||||
source
|
||||
? { params, conversationId: source.conversationId, anchorMessageId: source.anchorMessageId, entrance: source.entrance }
|
||||
: { params }
|
||||
|
||||
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
|
||||
|
||||
export type MemberMatchMode = 'fuzzy' | 'exact'
|
||||
type AccountSearchField = 'owner' | 'name'
|
||||
export interface RdmProfRole { roleId: string; name: string }
|
||||
/** rdm.account.list 单条候选。 */
|
||||
export interface RdmAccount {
|
||||
userId: string; owner: string; name?: string; valid: boolean; joined?: boolean
|
||||
projectRole?: string; profRole?: RdmProfRole | null
|
||||
}
|
||||
export interface RdmAccountPage {
|
||||
accounts?: RdmAccount[]; next?: string; directoryVersion?: string; count?: number
|
||||
}
|
||||
/** rdm.project.member.list 单条成员。 */
|
||||
export interface RdmMemberRow {
|
||||
owner: string; name?: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: RdmProfRole | null
|
||||
}
|
||||
export interface RdmMemberPage {
|
||||
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
|
||||
}
|
||||
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */
|
||||
export interface PersonPickItem {
|
||||
id: string; owner: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string
|
||||
}
|
||||
|
||||
export const MEMBER_PAGE_LIMIT = 50
|
||||
const ACCOUNT_PAGE_LIMIT = 50
|
||||
export const JOINED_REASON = '已加入项目'
|
||||
export const INVALID_REASON = '账号无效'
|
||||
export const NOT_MEMBER_REASON = '非当前项目成员'
|
||||
export const MATCH_MODE_LABELS: Record<MemberMatchMode, string> = {
|
||||
fuzzy: '模糊匹配',
|
||||
exact: '精确账号或姓名',
|
||||
}
|
||||
export type SearchFieldsChoice = 'both' | 'owner' | 'name'
|
||||
export const SEARCH_FIELDS: Record<SearchFieldsChoice, { label: string; fields: AccountSearchField[] }> = {
|
||||
both: { label: '账号和姓名', fields: ['owner', 'name'] },
|
||||
owner: { label: '仅账号', fields: ['owner'] },
|
||||
name: { label: '仅姓名', fields: ['name'] },
|
||||
}
|
||||
|
||||
export const roleOptionsPayload = (projectId: string) => ({ projectId, purpose: 'members' as const })
|
||||
|
||||
interface AccountSearchOptions {
|
||||
mode?: MemberMatchMode
|
||||
searchFields?: AccountSearchField[]
|
||||
roleId?: string
|
||||
/** 携带即按该文档的职责候选检索(仅当前有效成员)。 */
|
||||
docId?: string
|
||||
}
|
||||
|
||||
/** 只用 mode(match 是服务端旧别名,两者并存且不一致会被拒);空串筛选不携带。 */
|
||||
export function accountListParams(projectId: string, opts: AccountSearchOptions & {
|
||||
query?: string; cursor?: string; limit: number
|
||||
}) {
|
||||
const query = opts.query?.trim() ?? ''
|
||||
return {
|
||||
projectId,
|
||||
limit: Math.min(Math.max(1, opts.limit), ACCOUNT_PAGE_LIMIT),
|
||||
mode: opts.mode ?? 'fuzzy',
|
||||
...(opts.searchFields && opts.searchFields.length > 0 ? { searchFields: [...opts.searchFields] } : {}),
|
||||
...(query ? { query } : {}),
|
||||
...(opts.cursor ? { cursor: opts.cursor } : {}),
|
||||
...(opts.roleId ? { roleId: opts.roleId } : {}),
|
||||
...(opts.docId ? { docId: opts.docId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Picker reloadKey:项目/文档/匹配方式/检索字段/角色任一变化都清空查询与游标。 */
|
||||
export function accountSearchKey(projectId: string, opts: AccountSearchOptions): string {
|
||||
return JSON.stringify([projectId, opts.docId ?? '', opts.mode ?? 'fuzzy', opts.searchFields ?? [], opts.roleId ?? ''])
|
||||
}
|
||||
|
||||
/**
|
||||
* Picker 取数适配。空 query 不发请求(服务端空查询不枚举);cursor 只在签发它的
|
||||
* 同一检索条件(query + 选项)下沿用,条件变化即回到首页。
|
||||
*/
|
||||
export function accountCandidatePager(
|
||||
run: (params: ReturnType<typeof accountListParams>) => Promise<RdmAccountPage>,
|
||||
projectId: string,
|
||||
opts: AccountSearchOptions & { joinedOwners?: () => Iterable<string> },
|
||||
) {
|
||||
const base = accountSearchKey(projectId, opts)
|
||||
let issuedFor = ''
|
||||
return async (query: string, cursor: string, limit: number): Promise<{ items: PersonPickItem[]; nextCursor?: string }> => {
|
||||
const q = query.trim()
|
||||
if (!q) { issuedFor = ''; return { items: [] } }
|
||||
const key = `${base}|${q}`
|
||||
const out = await run(accountListParams(projectId, { ...opts, query: q, cursor: key === issuedFor ? cursor : '', limit }))
|
||||
issuedFor = out.next ? key : ''
|
||||
const items = accountsToPickItems(out.accounts, opts.joinedOwners?.() ?? [], opts.docId ? 'duty' : 'member')
|
||||
return { items, nextCursor: out.next || undefined }
|
||||
}
|
||||
}
|
||||
|
||||
export function memberListParams(projectId: string, opts: {
|
||||
query?: string; roleId?: string; cursor?: string; limit?: number
|
||||
match?: MemberMatchMode; projectRole?: 'owner' | 'member'
|
||||
} = {}) {
|
||||
const query = opts.query?.trim() ?? ''
|
||||
return {
|
||||
projectId,
|
||||
limit: opts.limit ?? MEMBER_PAGE_LIMIT,
|
||||
...(query ? { query } : {}),
|
||||
...(opts.roleId ? { roleId: opts.roleId } : {}),
|
||||
...(opts.cursor ? { cursor: opts.cursor } : {}),
|
||||
...(opts.match ? { match: opts.match } : {}),
|
||||
...(opts.projectRole ? { projectRole: opts.projectRole } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 人员标签:「姓名 · 账号」,姓名为空时只显示账号。 */
|
||||
export function personLabel(p: { owner: string; name?: string }): string {
|
||||
const name = p.name?.trim()
|
||||
return name && name !== p.owner ? `${name} · ${p.owner}` : p.owner
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选映射(id=userId;无 userId 的行无法稳定提交,不提供)。无效账号可见但不可选;
|
||||
* member:已加入(服务端 joined 或已在成员表)不可选;duty:只有当前成员可选。
|
||||
*/
|
||||
export function accountsToPickItems(
|
||||
accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = [], purpose: 'member' | 'duty' = 'member',
|
||||
): PersonPickItem[] {
|
||||
const joined = new Set(joinedOwners)
|
||||
return (accounts ?? []).filter(a => a.userId && a.owner).map(a => {
|
||||
const isJoined = a.joined === true || joined.has(a.owner)
|
||||
const reason = !a.valid ? INVALID_REASON
|
||||
: purpose === 'member' && isJoined ? JOINED_REASON
|
||||
: purpose === 'duty' && !isJoined ? NOT_MEMBER_REASON : ''
|
||||
return {
|
||||
id: a.userId,
|
||||
owner: a.owner,
|
||||
title: a.name?.trim() || a.owner,
|
||||
subtitle: a.owner,
|
||||
...(reason ? { disabled: true, disabledReason: reason } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 成员表追加下一页:保序,已存在的账号不重复。 */
|
||||
export function appendMemberPage(prev: RdmMemberRow[] | null, page: RdmMemberRow[] | undefined): RdmMemberRow[] {
|
||||
const out = [...(prev ?? [])]
|
||||
const seen = new Set(out.map(m => m.owner))
|
||||
for (const m of page ?? []) {
|
||||
if (seen.has(m.owner)) continue
|
||||
seen.add(m.owner)
|
||||
out.push(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
|
||||
export function describeSource(source: string): string {
|
||||
switch (source) {
|
||||
@@ -160,6 +353,8 @@ export function describeSource(source: string): string {
|
||||
case 'override': return '项目内覆盖'
|
||||
case 'relation-default': return '项目关系默认'
|
||||
case 'non-member': return '非项目成员'
|
||||
case 'creator': return '本人项目创建者自动具备(仅本人创建的项目)'
|
||||
case 'role': return '职位角色'
|
||||
default: return source
|
||||
}
|
||||
}
|
||||
|
||||
+39
-1
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from './recovery'
|
||||
import {
|
||||
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected, recoveryStatusLabel,
|
||||
} from './recovery'
|
||||
import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage'
|
||||
|
||||
// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的
|
||||
@@ -43,6 +45,42 @@ describe('recoveryProposalHint', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalNotice', () => {
|
||||
it('按服务端当前状态给出结果:待确认、已确认与终态(重复响应只回状态)', () => {
|
||||
expect(recoveryProposalNotice('alice', 'pending')).toContain('本人确认')
|
||||
expect(recoveryProposalNotice('alice', 'pending')).toContain('管理员不会收到设密能力')
|
||||
expect(recoveryProposalNotice('alice', 'confirmed')).toContain('已由本人确认')
|
||||
expect(recoveryProposalNotice('alice', 'confirmed')).toContain('管理员不会收到设密能力')
|
||||
for (const status of ['completed', 'revoked', 'expired'] as const) {
|
||||
const notice = recoveryProposalNotice('alice', status)
|
||||
expect(notice).toContain(recoveryStatusLabel[status])
|
||||
expect(notice).toContain('核对下方记录')
|
||||
expect(notice).not.toContain('请该用户')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryAttemptKey', () => {
|
||||
const fresh = () => 'new-key'
|
||||
it('同一账号同一状态版本的重试沿用未确定结果的 key', () => {
|
||||
expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('old-key')
|
||||
})
|
||||
it('无未确定请求、换账号或状态版本变化时生成新 key', () => {
|
||||
expect(recoveryAttemptKey(null, 'usr_1', 3, fresh)).toBe('new-key')
|
||||
expect(recoveryAttemptKey({ userId: 'usr_2', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key')
|
||||
expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 2, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalRejected', () => {
|
||||
it('4xx 为明确拒绝;网络失败与 5xx 结果未确定,需要先重读状态', () => {
|
||||
expect(recoveryProposalRejected('Error: 403: admin required')).toBe(true)
|
||||
expect(recoveryProposalRejected('Error: 404: not found')).toBe(true)
|
||||
expect(recoveryProposalRejected('Error: 502: bad gateway')).toBe(false)
|
||||
expect(recoveryProposalRejected('TypeError: Failed to fetch')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalErrorMessage', () => {
|
||||
it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => {
|
||||
expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明')
|
||||
|
||||
@@ -18,6 +18,27 @@ export function recoveryProposalHint(status: RecoveryProposal['status']): string
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 建立提案后的结果提示:以服务端返回或重读的当前状态为准。同一 idempotencyKey
|
||||
* 的重复响应只回状态(可能已是终态),管理员任何时候都拿不到设密能力。 */
|
||||
export function recoveryProposalNotice(owner: string, status: RecoveryProposal['status']): string {
|
||||
if (status === 'pending') return `已为 ${owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现,管理员不会收到设密能力`
|
||||
if (status === 'confirmed') return `${owner} 的恢复提案已由本人确认。只有目标本人可以取得设密能力;管理员不会收到设密能力`
|
||||
return `${owner} 的恢复提案${recoveryStatusLabel[status]}。如需再次恢复,请核对下方记录`
|
||||
}
|
||||
|
||||
export interface RecoveryAttempt { userId: string; stateVersion: number; key: string }
|
||||
|
||||
/** 提案请求标识:同一账号、同一状态版本的显式重试沿用上次未确定结果的 key,
|
||||
* 让响应丢失后的重试落到同一提案而不是重复创建;账号或状态版本变化才换新 key。 */
|
||||
export function recoveryAttemptKey(previous: RecoveryAttempt | null, userId: string, stateVersion: number, fresh: () => string): string {
|
||||
return previous?.userId === userId && previous.stateVersion === stateVersion ? previous.key : fresh()
|
||||
}
|
||||
|
||||
/** 4xx 是服务端明确拒绝(提案未创建);网络失败或 5xx 时提案可能已提交,结果未确定。 */
|
||||
export function recoveryProposalRejected(raw: string): boolean {
|
||||
return /\b4\d\d:/.test(raw)
|
||||
}
|
||||
|
||||
export function recoveryProposalErrorMessage(raw: string): string {
|
||||
if (raw.includes('OWNER_PROOF_UNAVAILABLE')) {
|
||||
return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/* U7 法规结构化引用:编号标记、引用卡片、引用清单与审计法规树。
|
||||
颜色、圆角、字号只消费 @ui/tokens.css;折叠动效由 @ui Collapsible(mk-col)承担。
|
||||
有效依据(verified)用实线成功色;未核验用虚线警告色;无效用虚线错误色加删除线。 */
|
||||
|
||||
.regcite-chips { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; }
|
||||
.regcite-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); color: var(--text-dim); word-break: break-all; }
|
||||
|
||||
.regcite-mark {
|
||||
display: inline;
|
||||
margin: 0 2px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: var(--fs-ui-sm);
|
||||
line-height: 1.4;
|
||||
vertical-align: baseline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.regcite-mark:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.regcite-mark.regcite-valid { color: var(--ok-text); border-color: var(--ok-border); background: var(--ok-soft); }
|
||||
.regcite-mark.regcite-unverified { color: var(--warn-text); border-style: dashed; border-color: var(--warn); background: var(--warn-soft); }
|
||||
.regcite-mark.regcite-invalid { color: var(--err-text); border-style: dashed; border-color: var(--err); background: var(--err-soft); text-decoration: line-through; }
|
||||
.regcite-mark-open { outline: 1px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
.regcite-block { margin: 0; }
|
||||
|
||||
.regcite-card {
|
||||
margin: 6px 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-raised);
|
||||
font-size: var(--fs-ui-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
.regcite-card-valid { border-left-color: var(--ok); }
|
||||
.regcite-card-unverified { border-left-color: var(--warn); border-left-style: dashed; }
|
||||
.regcite-card-invalid { border-left-color: var(--err); border-left-style: dashed; }
|
||||
.regcite-card-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.regcite-card-title { font-weight: 600; color: var(--text); }
|
||||
.regcite-note { margin-top: 4px; color: var(--text-dim); }
|
||||
.regcite-claim { margin-top: 4px; color: var(--text); }
|
||||
.regcite-fields { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 2px 10px; margin: 6px 0 0; }
|
||||
.regcite-fields dt { color: var(--text-dim); }
|
||||
.regcite-fields dd { margin: 0; color: var(--text); word-break: break-word; }
|
||||
.regcite-link { color: var(--accent-text); word-break: break-all; }
|
||||
.regcite-toggle { font-size: var(--fs-ui-sm); color: var(--accent-text); }
|
||||
.regcite-excerpt {
|
||||
margin: 4px 0 0;
|
||||
padding: 6px 10px;
|
||||
border-left: 2px solid var(--border-strong);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--bg-inset);
|
||||
white-space: pre-wrap;
|
||||
color: var(--text);
|
||||
}
|
||||
.regcite-redaction { margin-top: 6px; color: var(--text-dim); }
|
||||
|
||||
.regcite-list { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 2px; }
|
||||
.regcite-list-head { font-size: var(--fs-ui-sm); color: var(--text-dim); padding-bottom: 4px; }
|
||||
.regcite-item-head { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; }
|
||||
.regcite-item-num { font-family: var(--mono); color: var(--text-dim); }
|
||||
.regcite-item-title { font-weight: 550; }
|
||||
.regcite-item-sub { color: var(--text-dim); }
|
||||
.regcite-item-invalid .regcite-item-title { color: var(--err-text); text-decoration: line-through; }
|
||||
|
||||
.regcite-reasons { margin: 0; padding-left: 18px; color: var(--text-dim); }
|
||||
.regcite-tree-row { display: flex; align-items: center; gap: 2px; }
|
||||
.regcite-tree-row .rdm-audit-tree-node { flex: 1; min-width: 0; }
|
||||
.regcite-tree-caret {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-ui-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.regcite-tree-caret:hover { background: var(--bg-hover); color: var(--text); }
|
||||
@@ -0,0 +1,235 @@
|
||||
// U7 法规结构化引用纯逻辑层(契约:Dev 仓 docs/superpowers/specs
|
||||
// 2026-10-18-regulations-citation-contract.md §1/§3/§5/§6)。只放可测判定:
|
||||
// 正文 [cit-…] 标记解析与编号、核验状态文案与样式、原文省略原因、审计树请求参数。
|
||||
// 引用是 RDM 证据批的派生视图;除 verified 外一律不得呈现为有效法律依据。
|
||||
|
||||
// ---- 契约类型 ----
|
||||
|
||||
interface CitationWeb {
|
||||
url: string
|
||||
finalUrl?: string
|
||||
captureId?: string
|
||||
contentSha256?: string
|
||||
official?: boolean
|
||||
fetchedAt?: string
|
||||
excerpt?: string
|
||||
excerptStart?: number
|
||||
excerptEnd?: number
|
||||
}
|
||||
|
||||
export interface CitationView {
|
||||
id: string
|
||||
batchId?: string
|
||||
claim?: string
|
||||
state: string // verified|single_source|degraded|edition_unresolved|conflict|unverified|not_applicable|invalid
|
||||
stale?: boolean
|
||||
staleReason?: string
|
||||
regulationId?: string
|
||||
editionId?: string
|
||||
nodeId?: string
|
||||
docNo?: string
|
||||
title?: string
|
||||
editionLabel?: string
|
||||
legalStatus?: string
|
||||
clauseNo?: string
|
||||
headingPath?: string[]
|
||||
corpusRevision?: string
|
||||
excerpt?: string
|
||||
excerptStart?: number
|
||||
excerptEnd?: number
|
||||
nodeSha256?: string
|
||||
disclosure?: string // full|excerpt|metadata_only|withheld
|
||||
redactionReason?: string
|
||||
web?: CitationWeb
|
||||
manifestSha256?: string
|
||||
}
|
||||
|
||||
interface RegLegStates { kb: string; web: string; kbReason?: string; webReason?: string }
|
||||
interface RegSealView { state: string; keyId?: string; manifestSha256?: string }
|
||||
interface RegResolution { actor: string; decision: string; note?: string; at: string; batchManifestSha256?: string }
|
||||
|
||||
/** rdm.inspect.object kind=reg-batch(契约 §5)。 */
|
||||
export interface RegBatchDetail {
|
||||
title: string
|
||||
state: string
|
||||
stateReasons?: string[] | null
|
||||
legStates?: RegLegStates
|
||||
seal?: RegSealView
|
||||
citations?: CitationView[] | null
|
||||
resolutions?: RegResolution[] | null
|
||||
}
|
||||
|
||||
/** rdm.inspect.object kind=reg-citation(契约 §5)。 */
|
||||
export interface RegCitationDetail { title: string; citation: CitationView }
|
||||
|
||||
// ---- 正文标记:[cit-<12 位小写十六进制>] ----
|
||||
|
||||
const MARKER = /\[(cit-[0-9a-f]{12})\]/g
|
||||
|
||||
type CitedSegment =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'cite'; id: string; n: number }
|
||||
|
||||
/** 解析多段正文:按首次出现编号(跨段连续),重复标记复用编号;不合规的近似标记原样保留为文本。 */
|
||||
export function parseCitedBlocks(texts: string[]): { blocks: CitedSegment[][]; order: string[] } {
|
||||
const order: string[] = []
|
||||
const numbers = new Map<string, number>()
|
||||
const blocks = texts.map(text => {
|
||||
const out: CitedSegment[] = []
|
||||
let last = 0
|
||||
for (const m of text.matchAll(MARKER)) {
|
||||
const at = m.index ?? 0
|
||||
if (at > last) out.push({ kind: 'text', text: text.slice(last, at) })
|
||||
const id = m[1]
|
||||
let n = numbers.get(id)
|
||||
if (n === undefined) {
|
||||
order.push(id)
|
||||
n = order.length
|
||||
numbers.set(id, n)
|
||||
}
|
||||
out.push({ kind: 'cite', id, n })
|
||||
last = at + m[0].length
|
||||
}
|
||||
if (last < text.length) out.push({ kind: 'text', text: text.slice(last) })
|
||||
return out
|
||||
})
|
||||
return { blocks, order }
|
||||
}
|
||||
|
||||
/** 引用清单顺序:正文编号在前,正文未出现的引用按返回顺序续编。 */
|
||||
export function citationListOrder(order: string[], citations: CitationView[] | null | undefined): string[] {
|
||||
const out = [...order]
|
||||
const seen = new Set(order)
|
||||
for (const c of citations || []) {
|
||||
if (!seen.has(c.id)) { seen.add(c.id); out.push(c.id) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- 核验状态 ----
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
verified: '已核验',
|
||||
single_source: '单一来源',
|
||||
degraded: '降级',
|
||||
edition_unresolved: '版本未定',
|
||||
conflict: '冲突',
|
||||
unverified: '未核验',
|
||||
not_applicable: '不适用',
|
||||
invalid: '无效引用',
|
||||
}
|
||||
|
||||
/** 状态文案;未知状态原样显示,不冒充已知状态。 */
|
||||
export function citationStateLabel(state: string | undefined): string {
|
||||
if (!state) return STATE_LABELS.unverified
|
||||
return STATE_LABELS[state] ?? state
|
||||
}
|
||||
|
||||
type CitationStyle = 'valid' | 'unverified' | 'invalid'
|
||||
|
||||
/** 样式档:仅 verified 且未过期才是有效依据;ID 缺失或 invalid 为无效;其余一律未核验档。 */
|
||||
export function citationStyle(c: { state?: string; stale?: boolean } | undefined): CitationStyle {
|
||||
if (!c || c.state === 'invalid') return 'invalid'
|
||||
if (c.state === 'verified' && !c.stale) return 'valid'
|
||||
return 'unverified'
|
||||
}
|
||||
|
||||
export const CITATION_CHIP_TONE: Record<CitationStyle, 'ok' | 'warn' | 'danger'> = {
|
||||
valid: 'ok',
|
||||
unverified: 'warn',
|
||||
invalid: 'danger',
|
||||
}
|
||||
|
||||
const STALE_REASONS: Record<string, string> = {
|
||||
node_text_changed: '条款文本已变更',
|
||||
legal_status_changed: '效力状态已变更',
|
||||
edition_superseded: '版本已被替代',
|
||||
}
|
||||
|
||||
/** 过期提示:stale 显示「待复核」并附原因;未过期返回 null。 */
|
||||
export function staleLabel(c: { stale?: boolean; staleReason?: string }): string | null {
|
||||
if (!c.stale) return null
|
||||
if (!c.staleReason) return '待复核'
|
||||
return `待复核(${STALE_REASONS[c.staleReason] ?? c.staleReason})`
|
||||
}
|
||||
|
||||
/** 标记文字:有效依据只显示编号;其余附状态文案,不靠颜色单独区分。 */
|
||||
export function citationMarkText(n: number, c: CitationView | undefined): string {
|
||||
const style = citationStyle(c)
|
||||
if (style === 'valid') return `${n}`
|
||||
if (style === 'invalid') return `${n}·${STATE_LABELS.invalid}`
|
||||
return `${n}·${c?.stale && c.state === 'verified' ? '待复核' : citationStateLabel(c?.state)}`
|
||||
}
|
||||
|
||||
// ---- 原文披露 ----
|
||||
|
||||
const REDACTION_LABELS: Record<string, string> = {
|
||||
withheld_by_rights: '受版权限制',
|
||||
metadata_only: '仅元数据',
|
||||
no_source_text_permission: '无原文查看权限',
|
||||
source_withdrawn: '来源已撤回',
|
||||
}
|
||||
|
||||
/** 原文省略原因;未知原因原样显示。 */
|
||||
export function redactionLabel(reason: string | undefined): string {
|
||||
if (!reason) return '未提供原文'
|
||||
return REDACTION_LABELS[reason] ?? reason
|
||||
}
|
||||
|
||||
/** 原文片段呈现:有片段给片段;省略时给中文原因,不留空白。 */
|
||||
export function excerptView(c: Pick<CitationView, 'excerpt' | 'redactionReason'>): { excerpt: string } | { redaction: string } {
|
||||
if (c.excerpt) return { excerpt: c.excerpt }
|
||||
return { redaction: redactionLabel(c.redactionReason) }
|
||||
}
|
||||
|
||||
const DISCLOSURE_LABELS: Record<string, string> = {
|
||||
full: '全文',
|
||||
excerpt: '片段',
|
||||
metadata_only: '仅元数据',
|
||||
withheld: '已扣留',
|
||||
}
|
||||
|
||||
export function disclosureLabel(d: string | undefined): string {
|
||||
if (!d) return '—'
|
||||
return DISCLOSURE_LABELS[d] ?? d
|
||||
}
|
||||
|
||||
const LEG_LABELS: Record<string, string> = {
|
||||
ok: '正常',
|
||||
empty: '无结果',
|
||||
unavailable: '不可用',
|
||||
degraded: '降级',
|
||||
error: '出错',
|
||||
rejected: '已拒绝',
|
||||
}
|
||||
|
||||
export function legStateLabel(state: string | undefined, reason?: string): string {
|
||||
const label = state ? (LEG_LABELS[state] ?? state) : '—'
|
||||
return reason ? `${label}(${reason})` : label
|
||||
}
|
||||
|
||||
export function sealStateLabel(state: string | undefined): string {
|
||||
if (state === 'sealed') return '已封存'
|
||||
if (state === 'unsealed') return '未封存'
|
||||
return state || '—'
|
||||
}
|
||||
|
||||
/** 只有 http(s) 地址才可作为外链;其余地址按文本显示。 */
|
||||
export function safeWebHref(url: string | undefined): string | null {
|
||||
if (!url) return null
|
||||
return /^https?:\/\//i.test(url) ? url : null
|
||||
}
|
||||
|
||||
// ---- 审计读模型请求(rdm.inspect.tree kind=regs;契约 §5) ----
|
||||
|
||||
/** 根层列证据批;parent=<batchId> 列该批的引用。cursor 沿用既有键集分页。 */
|
||||
export function regsTreeParams(projectId: string, opts: { parent?: string; cursor?: string } = {}): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = { projectId, kind: 'regs', cursor: opts.cursor ?? '' }
|
||||
if (opts.parent) params.parent = opts.parent
|
||||
return params
|
||||
}
|
||||
|
||||
export const REG_TREE_KIND_LABELS: Record<string, string> = {
|
||||
'reg-batch': '证据批',
|
||||
'reg-citation': '引用',
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
AccountRoleSummary, PermissionDescriptor, RoleCopyAssignRequest, RolePermissionChangeInput, RoleRecord,
|
||||
UserRoleAssignmentInput, UserRoleAssignmentPreview,
|
||||
} from './api'
|
||||
import type { RolePermissionValue } from './rolePermissions'
|
||||
|
||||
/** 显式选择集合:只含逐个或整页勾选过的 userId;筛选与翻页既不清空也不扩大它。 */
|
||||
export interface MemberSelection { readonly ids: readonly string[] }
|
||||
|
||||
export const createSelection = (ids: readonly string[] = []): MemberSelection => ({ ids: [...new Set(ids)] })
|
||||
|
||||
export const clearSelection = (): MemberSelection => createSelection()
|
||||
|
||||
export const isSelected = (selection: MemberSelection, userId: string) => selection.ids.includes(userId)
|
||||
|
||||
export function toggleOne(selection: MemberSelection, userId: string, checked = !isSelected(selection, userId)): MemberSelection {
|
||||
if (checked) return isSelected(selection, userId) ? selection : { ids: [...selection.ids, userId] }
|
||||
return isSelected(selection, userId) ? { ids: selection.ids.filter(id => id !== userId) } : selection
|
||||
}
|
||||
|
||||
/** 表头全选只作用于当前页可选行;其他页已选的人保持不变。 */
|
||||
export function selectPage(selection: MemberSelection, pageIds: readonly string[], checked: boolean): MemberSelection {
|
||||
if (checked) return createSelection([...selection.ids, ...pageIds])
|
||||
const page = new Set(pageIds)
|
||||
return { ids: selection.ids.filter(id => !page.has(id)) }
|
||||
}
|
||||
|
||||
export function pageSelectionState(selection: MemberSelection, pageIds: readonly string[]): 'none' | 'some' | 'all' {
|
||||
const picked = pageIds.filter(id => isSelected(selection, id)).length
|
||||
if (!pageIds.length || picked === 0) return 'none'
|
||||
return picked === pageIds.length ? 'all' : 'some'
|
||||
}
|
||||
|
||||
export function selectionCounts(selection: MemberSelection, pageIds: readonly string[]): { count: number; beyondPage: number } {
|
||||
const page = new Set(pageIds)
|
||||
return { count: selection.ids.length, beyondPage: selection.ids.filter(id => !page.has(id)).length }
|
||||
}
|
||||
|
||||
export function selectionSummary(counts: { count: number; beyondPage: number }): string {
|
||||
return counts.beyondPage > 0 ? `已选 ${counts.count} 人(包含其他页的 ${counts.beyondPage} 人)` : `已选 ${counts.count} 人`
|
||||
}
|
||||
|
||||
type NamedAccount = { userId: string; owner: string; name?: string; profile?: { name: string } }
|
||||
|
||||
const rawName = (account: NamedAccount) => (account.profile?.name ?? account.name ?? '').trim()
|
||||
|
||||
/** 姓名缺失时以账号(登录名)展示;它只是显示文本,提交永远用 userId。 */
|
||||
export const accountName = (account: NamedAccount) => rawName(account) || account.owner
|
||||
|
||||
export const accountLabel = (account: NamedAccount) => rawName(account) ? `${rawName(account)}(${account.owner})` : account.owner
|
||||
|
||||
export function duplicateNames(accounts: readonly NamedAccount[]): Set<string> {
|
||||
const owners = new Map<string, Set<string>>()
|
||||
for (const account of accounts) {
|
||||
const name = rawName(account)
|
||||
if (!name) continue
|
||||
owners.set(name, (owners.get(name) ?? new Set()).add(account.userId))
|
||||
}
|
||||
return new Set([...owners].filter(([, ids]) => ids.size > 1).map(([name]) => name))
|
||||
}
|
||||
|
||||
export function sameNameHint(accounts: readonly NamedAccount[]): string {
|
||||
const duplicates = duplicateNames(accounts)
|
||||
if (!duplicates.size) return ''
|
||||
const groups = [...duplicates].map(name => `「${name}」:${accounts.filter(account => rawName(account) === name).map(account => account.owner).join('、')}`)
|
||||
return `存在同名用户${groups.join(';')},请按账号辨识`
|
||||
}
|
||||
|
||||
/** undefined = 无角色查看权(服务端不返回投影);null = 未分配。 */
|
||||
export function roleColumnLabel(role: AccountRoleSummary | null | undefined): string {
|
||||
if (role === undefined) return '—'
|
||||
if (role === null) return '未分配'
|
||||
return role.enabled ? role.name : `${role.name}(已停用)`
|
||||
}
|
||||
|
||||
export const assignedToRole = (account: { role?: AccountRoleSummary | null }, roleId: string) => !!roleId && account.role?.roleId === roleId
|
||||
|
||||
export function memberAssignments(
|
||||
selected: readonly string[], roleId: string, stateVersions: Readonly<Record<string, number>>,
|
||||
): UserRoleAssignmentInput[] {
|
||||
if (!roleId) throw new Error('请先选择目标角色')
|
||||
return [...new Set(selected)].map(userId => {
|
||||
const expectedStateVersion = stateVersions[userId]
|
||||
if (!expectedStateVersion) throw new Error('缺少用户状态版本,请刷新列表后重试')
|
||||
return { userId, roleId, expectedStateVersion }
|
||||
})
|
||||
}
|
||||
|
||||
/** 预览显示版本不一致时,只读重取这些用户的详情以获得最新 stateVersion(不涉及任何写接口)。 */
|
||||
export async function refreshStateVersions<T extends { userId: string; stateVersion: number }>(
|
||||
client: { adminGetUser: (userId: string) => Promise<T> }, userIds: readonly string[],
|
||||
): Promise<Record<string, T>> {
|
||||
const fresh = await Promise.all([...new Set(userIds)].map(userId => client.adminGetUser(userId)))
|
||||
return Object.fromEntries(fresh.map(account => [account.userId, account]))
|
||||
}
|
||||
|
||||
export const staleUserIds = (previews: readonly UserRoleAssignmentPreview[]) =>
|
||||
previews.filter(preview => !preview.stateMatches).map(preview => preview.userId)
|
||||
|
||||
export const previewTransition = (preview: Pick<UserRoleAssignmentPreview, 'currentRoleName' | 'targetRoleName'>) =>
|
||||
`${preview.currentRoleName || '未分配'} → ${preview.targetRoleName}`
|
||||
|
||||
/** 只有每个提交对象都有预览、且全部无错误且状态版本一致时才允许确认。 */
|
||||
export function previewAllowsAssign(previews: readonly UserRoleAssignmentPreview[], userIds: readonly string[]): boolean {
|
||||
if (!userIds.length) return false
|
||||
const byUser = new Map(previews.map(preview => [preview.userId, preview]))
|
||||
return userIds.every(userId => {
|
||||
const preview = byUser.get(userId)
|
||||
return !!preview && !preview.error && preview.stateMatches
|
||||
})
|
||||
}
|
||||
|
||||
/** 用户角色投影(GET users/{id}/role)不含 permissions;只有完整角色记录才能作为复制起点。 */
|
||||
export function roleSourceValues(role: RoleRecord, catalog: readonly PermissionDescriptor[]): Record<string, RolePermissionValue> {
|
||||
if (!Array.isArray(role.permissions)) return {}
|
||||
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 }]
|
||||
}))
|
||||
}
|
||||
|
||||
export function copyAssignRequest(input: {
|
||||
idempotencyKey: string; source: RoleRecord; name: string; description: string; catalog: readonly PermissionDescriptor[]
|
||||
values: Readonly<Record<string, RolePermissionValue>>; users: readonly { userId: string; stateVersion: number }[]
|
||||
}): RoleCopyAssignRequest {
|
||||
const name = input.name.trim()
|
||||
if (!name) throw new Error('请为新角色填写名称')
|
||||
if (!input.users.length) throw new Error('请至少选择一位用户')
|
||||
const baseline = roleSourceValues(input.source, input.catalog)
|
||||
const overrides: RolePermissionChangeInput[] = []
|
||||
for (const [actionId, before] of Object.entries(baseline)) {
|
||||
const after = input.values[actionId]
|
||||
if (after && (after.allowed !== before.allowed || after.capAllowed !== before.capAllowed)) overrides.push({ actionId, ...after })
|
||||
}
|
||||
return {
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
source: { roleId: input.source.roleId, expectedRoleVersion: input.source.roleVersion },
|
||||
name, description: input.description.trim(),
|
||||
...(overrides.length ? { overrides } : {}),
|
||||
assignments: input.users.map(user => ({ userId: user.userId, expectedStateVersion: user.stateVersion })),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { PermissionDescriptor, RolePermissionPatchEntry } from './api'
|
||||
|
||||
export type RolePermissionValue = { allowed: boolean; capAllowed: boolean }
|
||||
type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean }
|
||||
export type RolePermissionStateName = 'denied' | 'project' | 'default'
|
||||
|
||||
export const ROLE_PERMISSION_STATES: { state: RolePermissionStateName; label: string }[] = [
|
||||
{ state: 'denied', label: '不允许' },
|
||||
{ state: 'project', label: '按项目授权' },
|
||||
{ state: 'default', label: '默认允许' },
|
||||
]
|
||||
|
||||
const ROLE_PERMISSION_CATEGORIES = ['项目与协作', '文档工作', '项目内容'] as const
|
||||
|
||||
export const PROJECT_CREATE_ACTION = 'project.create'
|
||||
/** 开启 project.create 时随之具备的本人创建项目管理资格(与后端 catalog 同序)。 */
|
||||
export const PROJECT_CREATE_LINKED_ACTIONS = [
|
||||
'project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign',
|
||||
] as const
|
||||
|
||||
export function stateToValue(state: RolePermissionStateName): RolePermissionValue {
|
||||
if (state === 'default') return { allowed: true, capAllowed: true }
|
||||
if (state === 'project') return { allowed: false, capAllowed: true }
|
||||
return { allowed: false, capAllowed: false }
|
||||
}
|
||||
|
||||
export function valueToState(value: RolePermissionValue | undefined): RolePermissionStateName {
|
||||
if (!value?.capAllowed) return 'denied'
|
||||
return value.allowed ? 'default' : 'project'
|
||||
}
|
||||
|
||||
export function stateLabel(state: RolePermissionStateName): string {
|
||||
return ROLE_PERMISSION_STATES.find(item => item.state === state)?.label ?? state
|
||||
}
|
||||
|
||||
/** 受保护系统授权不进矩阵;缺分类的非保护系统动作(如 project.create)归入「项目与协作」。 */
|
||||
function permissionCategory(item: PermissionDescriptor): string {
|
||||
if (item.protected) return ''
|
||||
return item.category || (item.scope === 'system' ? ROLE_PERMISSION_CATEGORIES[0] : item.group)
|
||||
}
|
||||
|
||||
export function permissionCategories(catalog: PermissionDescriptor[]): string[] {
|
||||
const present = [...new Set(catalog.map(permissionCategory).filter(Boolean))]
|
||||
const fixed: string[] = ROLE_PERMISSION_CATEGORIES.filter(category => present.includes(category))
|
||||
return [...fixed, ...present.filter(category => !fixed.includes(category))]
|
||||
}
|
||||
|
||||
export function categoryActions(catalog: PermissionDescriptor[], category: string): PermissionDescriptor[] {
|
||||
if (!category) return []
|
||||
return catalog.filter(item => permissionCategory(item) === category)
|
||||
}
|
||||
|
||||
export function linkedByProjectCreate(values: Record<string, RolePermissionValue>, actionId: string): boolean {
|
||||
return (PROJECT_CREATE_LINKED_ACTIONS as readonly string[]).includes(actionId) && !!values[PROJECT_CREATE_ACTION]?.allowed
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function dirtyRoles(
|
||||
drafts: Record<string, RolePermissionDraft>, actionIds?: string[],
|
||||
): { roleId: string; changes: number }[] {
|
||||
return Object.entries(drafts)
|
||||
.map(([roleId, draft]) => ({ roleId, changes: visiblePatch(draft, actionIds ?? Object.keys(draft.values)).length }))
|
||||
.filter(entry => entry.changes > 0)
|
||||
}
|
||||
|
||||
// One entry per dirty role; each carries only that role's changes from its own baseline.
|
||||
export function rolePatchEntries(
|
||||
drafts: Record<string, RolePermissionDraft>, versions: Record<string, number>, actionIds?: string[],
|
||||
): RolePermissionPatchEntry[] {
|
||||
return dirtyRoles(drafts, actionIds).map(({ roleId }) => ({
|
||||
roleId,
|
||||
expectedRoleVersion: versions[roleId],
|
||||
changes: visiblePatch(drafts[roleId], actionIds ?? Object.keys(drafts[roleId].values)),
|
||||
}))
|
||||
}
|
||||
+12
-4
@@ -1,7 +1,7 @@
|
||||
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { type AdminZoneSection, api, connectEvents } from './api'
|
||||
import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api'
|
||||
import { auth } from './auth'
|
||||
import { projectActionError, projectWorkError } from './projectmeta'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||
@@ -13,6 +13,7 @@ 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'
|
||||
|
||||
type FontSizeChoice = 13 | 14 | 16 | 18
|
||||
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
|
||||
@@ -89,7 +90,10 @@ export interface UIState {
|
||||
projectSettingsId: string | null
|
||||
}
|
||||
|
||||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }
|
||||
export interface Me {
|
||||
owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]
|
||||
linkedCapabilities?: LinkedCapabilities
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
contextObs: Record<string, ContextObs>
|
||||
@@ -132,6 +136,8 @@ interface AppState {
|
||||
rdmMembersAvailable: boolean
|
||||
rdmDocReviewAvailable: boolean
|
||||
rdmPermMatrixAvailable: boolean
|
||||
/** 统一渲染(Task 11):rdm.render.* 核心命令齐备才 ready;审阅/批注/导出各自门控。 */
|
||||
rdmRender: RenderCapabilities
|
||||
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
|
||||
adminSections: AdminZoneSection[]
|
||||
ui: UIState
|
||||
@@ -203,6 +209,7 @@ let state: AppState = {
|
||||
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,
|
||||
@@ -445,7 +452,7 @@ export const store = {
|
||||
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,
|
||||
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,
|
||||
@@ -1205,11 +1212,12 @@ export const store = {
|
||||
rdmMembersAvailable: rdmMembersReady(cmds),
|
||||
rdmDocReviewAvailable: rdmDutyReady(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 })
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES })
|
||||
}
|
||||
try {
|
||||
const sections = await api.adminZoneSections()
|
||||
|
||||
+230
-5
@@ -517,8 +517,81 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.ad-table tr:last-child td { border-bottom: none; }
|
||||
.rp-panel { display: flex; flex-direction: column; gap: 12px; }
|
||||
.rp-filters .lg-input { min-width: 140px; }
|
||||
.rp-view-toggle { justify-content: flex-end; flex-wrap: wrap; }
|
||||
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); border-color: var(--accent); }
|
||||
.rp-category-nav { display: flex; gap: 6px; flex-wrap: wrap; margin-right: auto; }
|
||||
.rp-status:focus { outline: none; }
|
||||
.rp-status:empty { display: none; }
|
||||
.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: auto; max-height: 70vh; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
|
||||
.rp-matrix-wrap:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||
.rp-matrix { min-width: 760px; border-collapse: separate; border-spacing: 0; }
|
||||
.rp-matrix thead th { position: sticky; top: 0; z-index: 2; background: var(--bg-panel); vertical-align: bottom; }
|
||||
.rp-matrix th:first-child { position: sticky; left: 0; z-index: 1; background: var(--bg-panel); }
|
||||
.rp-matrix thead th:first-child { z-index: 3; }
|
||||
.rp-matrix tbody th { font-weight: normal; color: var(--text); border-bottom: 1px solid var(--border-soft); vertical-align: top; }
|
||||
.rp-matrix td { vertical-align: top; }
|
||||
.rp-matrix tr.rp-selected td, .rp-matrix tr.rp-selected th { background: var(--bg-active); }
|
||||
.rp-role-cell { display: flex; flex-direction: column; gap: 2px; min-width: 180px; max-width: 240px; }
|
||||
.rp-role-name { align-self: flex-start; border: none; background: none; padding: 0; color: var(--accent-text); font-size: var(--fs-ui-sm); font-weight: 600; cursor: pointer; text-align: left; }
|
||||
.rp-role-name:hover:not(:disabled) { text-decoration: underline; }
|
||||
.rp-role-name:disabled { color: var(--text-dim); cursor: default; }
|
||||
.rp-role-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||||
.rp-disabled-note { margin: 0; font-size: var(--fs-ui-xs); color: var(--warn-text); }
|
||||
.rp-empty { padding: 12px; }
|
||||
.rp-cell { display: flex; flex-direction: column; gap: 4px; min-width: 120px; }
|
||||
.rp-state { height: 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-raised); color: var(--text); font-size: var(--fs-ui-sm); padding: 0 6px; }
|
||||
.rp-state:focus-visible { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
|
||||
.rp-state:disabled { opacity: .55; cursor: default; }
|
||||
.rp-cell-flag { font-size: var(--fs-ui-xs); color: var(--warn-text); }
|
||||
.rp-linked { display: flex; gap: 4px; align-items: flex-start; margin: 0; font-size: var(--fs-ui-xs); color: var(--accent-text); }
|
||||
.rp-linked svg { flex: none; margin-top: 1px; }
|
||||
.rp-focus-table td { vertical-align: top; }
|
||||
.rp-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.rp-preview { display: flex; flex-direction: column; gap: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-raised); font-size: var(--fs-ui-sm); }
|
||||
.rp-preview-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.rp-preview-role { display: flex; flex-direction: column; gap: 4px; padding-top: 8px; border-top: 1px solid var(--border-soft); }
|
||||
.rp-preview-title { font-weight: 600; color: var(--text); }
|
||||
.rp-warn-text { font-weight: normal; color: var(--warn-text); }
|
||||
.rp-conflict { display: flex; gap: 4px; align-items: flex-start; margin: 0; color: var(--err-text); font-size: var(--fs-ui-sm); }
|
||||
.rp-conflict svg { flex: none; margin-top: 2px; }
|
||||
.rp-savebar { position: sticky; bottom: 0; z-index: 4; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-panel); box-shadow: var(--shadow-md); }
|
||||
.rp-savebar-summary { flex: 1; min-width: 160px; font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.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; }
|
||||
.rp-members { display: flex; flex-direction: column; gap: 10px; }
|
||||
.rp-members-link { border: none; background: none; padding: 0; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; text-decoration: underline; }
|
||||
.rp-members-link:disabled { color: var(--text-dim); cursor: default; text-decoration: none; }
|
||||
.rp-members-link:focus-visible { outline: none; border-radius: var(--radius-xs); box-shadow: var(--ring); }
|
||||
.au-select-col { width: 32px; }
|
||||
.au-select-col input:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||
.au-name-cell { display: flex; flex-direction: column; gap: 2px; }
|
||||
.au-same-name { align-self: flex-start; font-size: var(--fs-ui-xs); color: var(--warn-text); background: var(--warn-soft); border-radius: var(--radius-xs); padding: 1px 6px; }
|
||||
.au-grow { flex: 1; min-width: 0; }
|
||||
.au-batch .lg-input { height: 34px; }
|
||||
.au-preview-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
|
||||
.au-preview-list li { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; padding-top: 6px; border-top: 1px solid var(--border-soft); }
|
||||
.au-copy { display: flex; flex-direction: column; gap: 10px; }
|
||||
.au-copy-group { margin: 0; padding: 8px 10px; border: 1px solid var(--border-soft); border-radius: var(--radius); display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 6px 12px; }
|
||||
.au-copy-group legend { padding: 0 4px; font-size: var(--fs-ui-sm); font-weight: 600; color: var(--text-dim); }
|
||||
.au-copy-row { display: flex; gap: 8px; align-items: center; font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.au-copy-row > span:first-child { flex: 1; min-width: 0; }
|
||||
@media (max-width: 900px) {
|
||||
.rp-single { grid-template-columns: 1fr; }
|
||||
.rp-role-list { max-height: 220px; overflow-y: auto; }
|
||||
.rp-matrix-wrap { max-height: 60vh; }
|
||||
.rp-savebar .ad-mini, .rp-savebar .lg-submit { flex: 1; justify-content: center; }
|
||||
}
|
||||
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.ad-dim { color: var(--text-dim); }
|
||||
.ad-cond-note { display: block; margin-top: 2px; font-size: var(--fs-ui-xs); }
|
||||
.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-warn { background: var(--warn-soft); color: var(--warn-text); }
|
||||
@@ -625,12 +698,17 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; }
|
||||
.rdm-audit-opgroup-head:hover { background: var(--bg-hover); }
|
||||
.rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); }
|
||||
.rdm-audit-opgroup-action { color: var(--text-dim); }
|
||||
.rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); }
|
||||
.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
|
||||
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; }
|
||||
.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; }
|
||||
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
|
||||
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||||
.rdm-audit-event { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
|
||||
.rdm-audit-event-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-dim); }
|
||||
.rdm-audit-event-action { color: var(--text); }
|
||||
.rdm-audit-event-summary { color: var(--text); }
|
||||
.rdm-audit-event-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; }
|
||||
.rdm-audit-event-fields > div { min-width: 0; padding: 8px; background: var(--bg-raised); border-radius: var(--radius-sm); }
|
||||
.rdm-audit-event-fields dt { color: var(--text-dim); font-size: var(--fs-ui-xs); }
|
||||
.rdm-audit-event-fields dd { margin: 3px 0 0; color: var(--text); overflow-wrap: anywhere; }
|
||||
.rdm-audit-coverage { margin-top: 12px; }
|
||||
.rdm-audit-export { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.rdm-audit-export-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
@@ -644,6 +722,149 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
|
||||
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
|
||||
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
|
||||
.rdm-audit-materials-side[hidden] { display: none; }
|
||||
.rdm-audit-materials > .drw { flex: 1; }
|
||||
.rdm-audit-tree-row { display: flex; align-items: center; gap: 4px; }
|
||||
.rdm-audit-tree-row .rdm-audit-tree-node { flex: 1; min-width: 0; }
|
||||
.rdm-audit-tree-open { flex: none; font-size: var(--fs-ui-sm); padding: 4px 8px; }
|
||||
/* 统一渲染 Task 11:文档阅读与交付工作区(结构画布 / 阅读视图 + 规则、预览、审阅、下载) */
|
||||
.drw { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
.drw-header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.drw-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-lg); color: var(--text); }
|
||||
.drw-view-tabs { flex: none; }
|
||||
.drw-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); gap: 16px; align-items: start; }
|
||||
.drw-main, .drw-side { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.drw-filters { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.drw-search { flex: 1; min-width: 140px; height: 32px; font-size: var(--fs-ui-sm); }
|
||||
.drw-select { height: 32px; font-size: var(--fs-ui-sm); max-width: 100%; }
|
||||
.drw-canvas { position: relative; height: min(60vh, 520px); overflow: auto; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); cursor: grab; touch-action: none; }
|
||||
.drw-canvas.is-panning { cursor: grabbing; user-select: none; }
|
||||
.drw-canvas-stage { position: relative; }
|
||||
.drw-canvas-edges { position: absolute; inset: 0; pointer-events: none; }
|
||||
.drw-canvas-edge { fill: none; stroke: var(--border-strong); stroke-width: 1.5; }
|
||||
.drw-canvas-node { position: absolute; display: flex; align-items: center; gap: 6px; padding: 0 10px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-dim); font-size: var(--fs-ui-sm); text-align: left; cursor: pointer; }
|
||||
.drw-canvas-node:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.drw-canvas-node.depth-0 { color: var(--text); font-weight: 600; border-color: var(--border-strong); }
|
||||
.drw-canvas-node.sel { background: var(--bg-active); color: var(--text); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
.drw-canvas-node:focus-visible, .drw-reader-title:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||
.drw-canvas-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.drw-reader { max-height: min(60vh, 520px); overflow: auto; padding: 4px 16px 16px; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius); }
|
||||
.drw-reader-node { padding: 4px 8px; border-radius: var(--radius-sm); scroll-margin-top: 8px; }
|
||||
.drw-reader-node.sel { background: var(--bg-active); }
|
||||
.drw-reader-heading { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; font-weight: 600; color: var(--text); }
|
||||
h2.drw-reader-heading { font-size: var(--fs-ui-xl); }
|
||||
h3.drw-reader-heading { font-size: var(--fs-ui-lg); }
|
||||
h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); }
|
||||
.drw-reader-title { background: none; border: none; padding: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: var(--radius-xs); overflow-wrap: anywhere; }
|
||||
.drw-reader-title[data-anchor-node] { cursor: text; }
|
||||
.drw-anchor { color: var(--text-faint); font-size: var(--fs-ui-sm); text-decoration: none; }
|
||||
.drw-anchor:hover { color: var(--accent-text); }
|
||||
.drw-badges { display: inline-flex; gap: 4px; flex: none; }
|
||||
.drw-badge { min-width: 18px; padding: 0 5px; border-radius: var(--radius-full); font-size: var(--fs-ui-xs); font-weight: 600; line-height: 16px; text-align: center; color: var(--text-on-semantic); }
|
||||
.drw-badge.sev-error { background: var(--err-text); }
|
||||
.drw-badge.sev-warning { background: var(--warn-text); }
|
||||
.drw-badge.review { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.drw-selected { padding: 12px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); }
|
||||
.drw-selected-title { font-size: var(--fs-ui-base); color: var(--text); margin-bottom: 6px; overflow-wrap: anywhere; }
|
||||
.drw-facts { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); }
|
||||
.drw-fact { display: flex; gap: 8px; align-items: baseline; }
|
||||
.drw-fact-k { flex: none; min-width: 4.5em; color: var(--text-dim); }
|
||||
.drw-fact-v { flex: 1; min-width: 0; color: var(--text); overflow-wrap: anywhere; }
|
||||
.drw-diff-del, .drw-diff-add { padding: 4px 8px; border-radius: var(--radius-xs); font-size: var(--fs-ui-sm); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.drw-diff-del { background: var(--diff-remove); color: var(--diff-remove-text); text-decoration: line-through; }
|
||||
.drw-diff-add { background: var(--diff-add); color: var(--diff-add-text); }
|
||||
.drw-diff-del + .drw-diff-add { margin-top: 4px; }
|
||||
.drw-conflict { color: var(--warn-text); }
|
||||
.drw-byline { display: inline-flex; gap: 6px; align-items: baseline; min-width: 0; }
|
||||
.drw-byline-name { color: var(--text); font-weight: 550; }
|
||||
.drw-byline-time { color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.drw-panel { display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); min-width: 0; }
|
||||
.drw-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
|
||||
.drw-row-between { justify-content: space-between; }
|
||||
.drw-muted { font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.drw-mono { font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
|
||||
.drw-ok { font-size: var(--fs-ui-sm); color: var(--ok-text); }
|
||||
.drw-op { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; font-size: var(--fs-ui-sm); }
|
||||
.drw-receipt { display: flex; flex-direction: column; gap: 6px; }
|
||||
.drw-filename { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
|
||||
.drw-failure { display: flex; flex-direction: column; gap: 4px; }
|
||||
.drw-failure-msg { font-size: var(--fs-ui-sm); color: var(--err-text); overflow-wrap: anywhere; }
|
||||
.drw-notice { padding: 8px 10px; background: var(--wait-soft); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.drw-profiles { display: flex; flex-direction: column; gap: 8px; }
|
||||
.drw-profile-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.drw-profile { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.drw-profile:hover { background: var(--bg-hover); }
|
||||
.drw-profile.sel { background: var(--bg-active); }
|
||||
.drw-profile.off { cursor: not-allowed; }
|
||||
.drw-profile input { margin-top: 3px; flex: none; }
|
||||
.drw-profile-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.drw-profile-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
|
||||
.drw-profile-source { color: var(--text); font-weight: 600; }
|
||||
.drw-profile-id { max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
|
||||
.drw-profile-ver { color: var(--text-dim); }
|
||||
.drw-profile-reason { font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.drw-diags, .drw-annos { display: flex; flex-direction: column; gap: 6px; }
|
||||
.drw-group-trigger { display: flex; width: 100%; padding: 4px 0; background: none; border: none; cursor: pointer; text-align: left; }
|
||||
.drw-group-label { font-size: var(--fs-ui-sm); font-weight: 600; color: var(--text); }
|
||||
.drw-group-label.sev-error { color: var(--err-text); }
|
||||
.drw-group-label.sev-warning { color: var(--warn-text); }
|
||||
.drw-diag-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }
|
||||
.drw-diag { display: flex; flex-wrap: wrap; gap: 4px 8px; padding: 6px 8px; border-left: 2px solid var(--border); font-size: var(--fs-ui-sm); }
|
||||
.drw-diag.sev-error { border-left-color: var(--err); }
|
||||
.drw-diag.sev-warning { border-left-color: var(--warn); }
|
||||
.drw-diag-node { max-width: 16em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||
.drw-diag-node.unlocated { color: var(--text-dim); cursor: default; }
|
||||
.drw-diag-msg { flex: 1 1 12em; min-width: 0; color: var(--text); overflow-wrap: anywhere; }
|
||||
.drw-diag-code { font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
|
||||
.drw-diag-hint { flex-basis: 100%; color: var(--text-dim); }
|
||||
.drw-more { align-self: flex-start; font-size: var(--fs-ui-sm); }
|
||||
.drw-anno-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.drw-anno-node { align-self: flex-start; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); font-weight: 600; cursor: pointer; }
|
||||
.drw-anno, .drw-rev { padding: 6px 8px; background: var(--bg-raised); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); }
|
||||
.drw-anno.resolved { opacity: .8; }
|
||||
.drw-anno-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 2px; }
|
||||
.drw-anno-text { color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.drw-anno-replies { margin-top: 6px; padding-left: 12px; border-left: 2px solid var(--border-soft); display: flex; flex-direction: column; gap: 6px; }
|
||||
.drw-anno-orphan-note { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 4px; }
|
||||
.drw-anno.needs-reanchor { border-color: var(--warn); }
|
||||
.drw-anno-quote { margin: 2px 0 4px; padding: 0 8px; border-left: 2px solid var(--border-strong); color: var(--text-dim); white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.drw-anno-reanchor { margin-bottom: 4px; font-size: var(--fs-ui-sm); color: var(--warn-text); }
|
||||
.drw-rev .drw-diff-del, .drw-rev .drw-diff-add { margin-top: 4px; }
|
||||
.drw-traces { display: flex; flex-direction: column; gap: 6px; }
|
||||
.drw-trace { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 6px 8px; border-left: 2px solid var(--border); font-size: var(--fs-ui-sm); }
|
||||
.drw-trace.masked { border-left-style: dashed; }
|
||||
.drw-trace-type { font-weight: 600; color: var(--text); }
|
||||
.drw-trace-subject { min-width: 0; color: var(--text-dim); overflow-wrap: anywhere; }
|
||||
.drw-trace-masked { padding: 0 6px; border: 1px dashed var(--border-strong); border-radius: var(--radius-xs); color: var(--text-dim); }
|
||||
.drw-trace-body { flex-basis: 100%; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.drw-decisions { display: flex; flex-direction: column; gap: 4px; }
|
||||
.drw-decision { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
|
||||
.drw-downloads { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.drw-download { font-size: var(--fs-ui-sm); padding: 4px 8px; }
|
||||
.drw-bind-rules { display: flex; flex-direction: column; gap: 4px; }
|
||||
.drw-bind-rule { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.drw-bind-rule:hover { background: var(--bg-hover); }
|
||||
.drw-bind-rule.sel { background: var(--bg-active); }
|
||||
.drw-bind-row { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--border-soft); font-size: var(--fs-ui-sm); }
|
||||
.drw-composer { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--border-soft); }
|
||||
.drw-composer-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
|
||||
.drw-composer-node { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||
.drw-composer-reply { display: flex; gap: 8px; align-items: center; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.drw-composer-scope { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
|
||||
.drw-composer-quote { min-width: 0; color: var(--text); overflow-wrap: anywhere; }
|
||||
.drw-anchor-notice { font-size: var(--fs-ui-sm); color: var(--warn-text); }
|
||||
.drw-textarea { min-height: 64px; resize: vertical; font-size: var(--fs-ui-sm); }
|
||||
.drw-inline-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
|
||||
.drw-link { padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||
.drw-link:disabled { color: var(--text-dim); cursor: default; }
|
||||
.drw-deliveries { display: flex; flex-direction: column; gap: 6px; }
|
||||
.drw-delivery { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); }
|
||||
.drw-delivery.sel { background: var(--bg-active); }
|
||||
@media (max-width: 860px) {
|
||||
.drw-body { grid-template-columns: minmax(0, 1fr); }
|
||||
.drw-canvas, .drw-reader { height: auto; max-height: 50vh; }
|
||||
.drw-canvas { min-height: 240px; }
|
||||
}
|
||||
.duty-matrix-wrap { overflow: auto; }
|
||||
.duty-matrix .duty-sticky { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
|
||||
.duty-matrix thead th { position: sticky; top: 0; background: var(--bg-panel); z-index: 2; }
|
||||
@@ -721,3 +942,7 @@ button.cockpit-tree-more { cursor: pointer; text-decoration: underline; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { credentialLabel } from '../src/components/AccountUsersPanel'
|
||||
import { accountLabel, accountName, duplicateNames, roleColumnLabel, sameNameHint } from '../src/roleMembers'
|
||||
|
||||
const account = (userId: string, owner: string, name: string) => ({ userId, owner, profile: { name } })
|
||||
|
||||
describe('account directory credential state', () => {
|
||||
it('shows pending trusted-owner initialization before password state', () => {
|
||||
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待可信 owner 初始化')
|
||||
expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).toBe('已设置密码')
|
||||
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('未设置密码')
|
||||
})
|
||||
})
|
||||
|
||||
describe('account directory names and roles', () => {
|
||||
it('falls back to the login account when the name is missing', () => {
|
||||
expect(accountName(account('u1', 'zhang.san', ''))).toBe('zhang.san')
|
||||
expect(accountName(account('u1', 'zhang.san', ' '))).toBe('zhang.san')
|
||||
expect(accountName(account('u1', 'zhang.san', '张三'))).toBe('张三')
|
||||
expect(accountLabel(account('u1', 'zhang.san', '张三'))).toBe('张三(zhang.san)')
|
||||
expect(accountLabel(account('u1', 'zhang.san', ''))).toBe('zhang.san')
|
||||
expect(accountName({ userId: 'u2', owner: 'li.si', name: '李四' })).toBe('李四')
|
||||
})
|
||||
|
||||
it('asks to tell same-name users apart by account', () => {
|
||||
const accounts = [account('u1', 'zhang.san', '张三'), account('u2', 'zhang.san2', '张三'), account('u3', 'li.si', '李四'), account('u4', 'anon', '')]
|
||||
expect([...duplicateNames(accounts)]).toEqual(['张三'])
|
||||
expect(sameNameHint(accounts)).toBe('存在同名用户「张三」:zhang.san、zhang.san2,请按账号辨识')
|
||||
expect(sameNameHint(accounts.slice(2))).toBe('')
|
||||
})
|
||||
|
||||
it('labels the current role projection', () => {
|
||||
expect(roleColumnLabel(undefined)).toBe('—')
|
||||
expect(roleColumnLabel(null)).toBe('未分配')
|
||||
expect(roleColumnLabel({ roleId: 'r1', name: '软件工程师', enabled: true })).toBe('软件工程师')
|
||||
expect(roleColumnLabel({ roleId: 'r1', name: '软件工程师', enabled: false })).toBe('软件工程师(已停用)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
// 统一渲染 Task 11(审签来源):整文审签先在项目对话写一条已提交的用户消息作为来源,
|
||||
// 再以该消息为锚点分发命令。覆盖:调用顺序、顶层锚点字段、复用/新建记录对话、
|
||||
// 每一步失败的可读拒绝(失败即不分发)、同一动作的重试幂等编号、双击只提交一次。
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Conversation, Message, Project } from '../src/contracts'
|
||||
import { commandRequestBody, rdmProjectCommandError } from '../src/rdmManagement'
|
||||
import { createSingleFlight, describeRenderFailure, decisionPayload } from '../src/documentRenderPresentation'
|
||||
import {
|
||||
DECISION_RECORD_TITLE, commitDecision, decisionSourceText, hostProjectFor,
|
||||
type DecisionPort, type DecisionRequest,
|
||||
} from '../src/documentDecision'
|
||||
|
||||
const conv = (id: string, over: Partial<Conversation> = {}): Conversation => ({
|
||||
id, projectId: 'host-1', title: DECISION_RECORD_TITLE, createdAt: '2026-10-17T00:00:00Z', updatedAt: '2026-10-17T00:00:00Z', ...over,
|
||||
})
|
||||
const userMsg = (id: string, conversationId: string): Message => ({
|
||||
id, conversationId, role: 'user', parts: [], createdAt: '2026-10-17T00:00:01Z',
|
||||
})
|
||||
const hostProject = (over: Partial<Project> = {}): Project => ({
|
||||
id: 'host-1', name: '宿主项目', createdAt: '2026-10-17T00:00:00Z', updatedAt: '2026-10-17T00:00:00Z',
|
||||
settings: { 'rdm.state': 'ready', 'rdm.projectId': 'rdm-1' }, ...over,
|
||||
})
|
||||
|
||||
function harness(over: Partial<DecisionPort> = {}) {
|
||||
const calls: string[] = []
|
||||
const port: DecisionPort = {
|
||||
listConversations: vi.fn(async () => { calls.push('list'); return [conv('c-1')] }),
|
||||
createConversation: vi.fn(async (_pid: string, title?: string) => { calls.push('create'); return conv('c-new', { title: title ?? '' }) }),
|
||||
sendMessage: vi.fn(async (convId: string) => { calls.push('send'); return { message: userMsg('m-1', convId) } }),
|
||||
rdmCommand: vi.fn(async () => { calls.push('command'); return { receipt: { state: 'committed' } } }),
|
||||
...over,
|
||||
}
|
||||
return { port, calls }
|
||||
}
|
||||
|
||||
/** 断言并取出拒绝原因;若意外成功则失败。 */
|
||||
async function failureOf(p: Promise<unknown>): Promise<Error> {
|
||||
try { await p } catch (e) { return e as Error }
|
||||
throw new Error('expected the decision to be refused')
|
||||
}
|
||||
|
||||
function request(over: Partial<DecisionRequest> = {}): DecisionRequest {
|
||||
const payload = decisionPayload('approve', 'rdm-1', 'doc-9', '同意发布', 2)
|
||||
return {
|
||||
host: hostProject(), operationId: 'op_a', decision: 'approve',
|
||||
doc: { id: 'doc-9', title: '采购流程说明' }, round: 2, note: '同意发布',
|
||||
command: payload.command, params: payload.params, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('decisionSourceText', () => {
|
||||
it('states the decision, document, round and note in Chinese without identity fields', () => {
|
||||
const text = decisionSourceText({ decision: 'approve', doc: { id: 'doc-9', title: '采购流程说明' }, round: 2, note: '同意发布' })
|
||||
expect(text).toContain('审签决定:审核通过')
|
||||
expect(text).toContain('文档:采购流程说明(doc-9)')
|
||||
expect(text).toContain('轮次:第 2 轮')
|
||||
expect(text).toContain('说明:同意发布')
|
||||
expect(text.startsWith('/')).toBe(false)
|
||||
expect(text).not.toMatch(/owner|actor|author|identity/i)
|
||||
})
|
||||
|
||||
it('labels a rejection reason and writes 无 when the note is empty', () => {
|
||||
const reject = decisionSourceText({ decision: 'reject', doc: { id: 'd', title: '' }, round: 1, note: '依据不足' })
|
||||
expect(reject).toContain('审签决定:驳回')
|
||||
expect(reject).toContain('驳回原因:依据不足')
|
||||
expect(reject).toContain('文档:d')
|
||||
const final = decisionSourceText({ decision: 'final', doc: { id: 'd', title: 'T' }, round: 1, note: ' ' })
|
||||
expect(final).toContain('审签决定:终批')
|
||||
expect(final).toContain('说明:无')
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitDecision', () => {
|
||||
it('posts the committed source message first, then dispatches with it as the top-level anchor', async () => {
|
||||
const { port, calls } = harness()
|
||||
const req = request()
|
||||
await commitDecision(port, req)
|
||||
expect(calls).toEqual(['list', 'send', 'command'])
|
||||
expect(port.sendMessage).toHaveBeenCalledWith('c-1', decisionSourceText(req), [], 'decision:op_a')
|
||||
expect(port.rdmCommand).toHaveBeenCalledWith(req.command, req.params, { conversationId: 'c-1', anchorMessageId: 'm-1', entrance: 'button' })
|
||||
})
|
||||
|
||||
it('keeps the command params free of conversation fields and identity', async () => {
|
||||
const { port } = harness()
|
||||
await commitDecision(port, request())
|
||||
const [, params] = vi.mocked(port.rdmCommand).mock.calls[0]
|
||||
expect(params).toEqual({ projectId: 'rdm-1', docId: 'doc-9', note: '同意发布' })
|
||||
expect(params).not.toHaveProperty('conversationId')
|
||||
expect(params).not.toHaveProperty('anchorMessageId')
|
||||
expect(params).not.toHaveProperty('entrance')
|
||||
})
|
||||
|
||||
it('reuses the titled, non-archived decision record conversation of the host project', async () => {
|
||||
const { port } = harness({
|
||||
listConversations: vi.fn(async () => [
|
||||
conv('c-other', { title: '闲聊' }),
|
||||
conv('c-archived', { archived: true }),
|
||||
conv('c-record'),
|
||||
]),
|
||||
})
|
||||
await commitDecision(port, request())
|
||||
expect(port.listConversations).toHaveBeenCalledWith('host-1')
|
||||
expect(port.createConversation).not.toHaveBeenCalled()
|
||||
expect(port.sendMessage).toHaveBeenCalledWith('c-record', expect.any(String), [], 'decision:op_a')
|
||||
})
|
||||
|
||||
it('creates the decision record conversation when the host project has none', async () => {
|
||||
const { port } = harness({ listConversations: vi.fn(async () => []) })
|
||||
await commitDecision(port, request())
|
||||
expect(port.createConversation).toHaveBeenCalledWith('host-1', DECISION_RECORD_TITLE)
|
||||
expect(port.sendMessage).toHaveBeenCalledWith('c-new', expect.any(String), [], 'decision:op_a')
|
||||
})
|
||||
|
||||
it('anchors on the message id returned by the committed post, never on a different conversation', async () => {
|
||||
const { port } = harness({
|
||||
sendMessage: vi.fn(async () => ({ message: userMsg('m-9', 'other-conv') })),
|
||||
})
|
||||
await expect(commitDecision(port, request())).rejects.toThrow('未能提交,审签未执行')
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses when the response is not a committed user message', async () => {
|
||||
const assistant = { ...userMsg('m-2', 'c-1'), role: 'assistant' as const }
|
||||
const { port } = harness({ sendMessage: vi.fn(async () => ({ message: assistant })) })
|
||||
await expect(commitDecision(port, request())).rejects.toThrow('审签未执行')
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses when the post was treated as a command rather than recorded as source text', async () => {
|
||||
const { port } = harness({
|
||||
sendMessage: vi.fn(async (convId: string) => ({ message: userMsg('m-1', convId), command: 'system.intent' })),
|
||||
})
|
||||
await expect(commitDecision(port, request())).rejects.toThrow('审签未执行')
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses without dispatching when the host project is not found', async () => {
|
||||
const { port } = harness()
|
||||
await expect(commitDecision(port, request({ host: undefined }))).rejects.toThrow('未找到该研发项目对应的对话项目')
|
||||
expect(port.listConversations).not.toHaveBeenCalled()
|
||||
expect(port.sendMessage).not.toHaveBeenCalled()
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses without any write when the host project is not ready', async () => {
|
||||
const { port } = harness()
|
||||
const host = hostProject({ settings: { 'rdm.state': 'provisioning' } })
|
||||
await expect(commitDecision(port, request({ host }))).rejects.toThrow('项目正在准备')
|
||||
expect(port.listConversations).not.toHaveBeenCalled()
|
||||
expect(port.sendMessage).not.toHaveBeenCalled()
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a readable refusal and does not dispatch when the session has lapsed', async () => {
|
||||
const { port } = harness({ listConversations: vi.fn(async () => { throw new Error('401: unauthorized') }) })
|
||||
const err = await failureOf(commitDecision(port, request()))
|
||||
expect(err.message).toContain('登录已失效')
|
||||
expect(port.sendMessage).not.toHaveBeenCalled()
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a busy conversation as retryable and does not dispatch', async () => {
|
||||
const { port } = harness({ sendMessage: vi.fn(async () => { throw new Error('409: conversation busy') }) })
|
||||
const err = await failureOf(commitDecision(port, request()))
|
||||
expect(err.message).toContain('会话正在处理其他任务,请稍后重试')
|
||||
expect(err.message).toContain('审签未执行')
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a forbidden source post as a refusal and does not dispatch', async () => {
|
||||
const { port } = harness({ sendMessage: vi.fn(async () => { throw new Error('403: forbidden') }) })
|
||||
const err = await failureOf(commitDecision(port, request()))
|
||||
expect(err.message).toContain('当前账号无权在该对话中记录审签')
|
||||
expect(port.rdmCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a source-preflight rejection from the command step as a readable refusal', async () => {
|
||||
const { port } = harness({
|
||||
rdmCommand: vi.fn(async () => { throw new Error('project source preflight: formal project command requires an authenticated conversation source') }),
|
||||
})
|
||||
const err = await failureOf(commitDecision(port, request()))
|
||||
const view = describeRenderFailure(err)
|
||||
expect(view.message).toContain('审签来源尚未通过校验')
|
||||
expect(view.message.startsWith('{')).toBe(false)
|
||||
})
|
||||
|
||||
it('retries the same operation with the same request id so the server returns the same source message', async () => {
|
||||
let fail = true
|
||||
const { port } = harness({
|
||||
sendMessage: vi.fn(async (convId: string) => {
|
||||
if (fail) { fail = false; throw new Error('503: unavailable') }
|
||||
return { message: userMsg('m-1', convId) }
|
||||
}),
|
||||
})
|
||||
await expect(commitDecision(port, request())).rejects.toThrow()
|
||||
await commitDecision(port, request())
|
||||
const ids = vi.mocked(port.sendMessage).mock.calls.map(c => c[3])
|
||||
expect(ids).toEqual(['decision:op_a', 'decision:op_a'])
|
||||
expect(port.rdmCommand).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('double click', () => {
|
||||
it('runs one source post and one command when the action fires twice before settling', async () => {
|
||||
const { port } = harness()
|
||||
const flight = createSingleFlight()
|
||||
const click = () => {
|
||||
if (flight.busy()) return
|
||||
void flight.run(() => commitDecision(port, request())).catch(() => undefined)
|
||||
}
|
||||
click()
|
||||
click()
|
||||
await vi.waitFor(() => expect(port.rdmCommand).toHaveBeenCalledTimes(1))
|
||||
expect(port.sendMessage).toHaveBeenCalledTimes(1)
|
||||
expect(flight.busy()).toBe(false)
|
||||
})
|
||||
|
||||
it('releases the flight after a failure so the readable refusal can be retried', async () => {
|
||||
const flight = createSingleFlight()
|
||||
await expect(flight.run(() => Promise.reject(new Error('x')))).rejects.toThrow('x')
|
||||
expect(flight.busy()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hostProjectFor', () => {
|
||||
it('finds the host project bound to the RDM project id and ignores unbound projects', () => {
|
||||
const bound = hostProject()
|
||||
const unbound = hostProject({ id: 'host-2', settings: {} })
|
||||
expect(hostProjectFor([unbound, bound], 'rdm-1')).toBe(bound)
|
||||
expect(hostProjectFor([unbound], 'rdm-1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('command request body', () => {
|
||||
it('keeps the plain body without a source', () => {
|
||||
expect(commandRequestBody({ a: 1 })).toEqual({ params: { a: 1 } })
|
||||
})
|
||||
|
||||
it('places the source fields at the top level and leaves params untouched', () => {
|
||||
expect(commandRequestBody({ a: 1 }, { conversationId: 'c', anchorMessageId: 'm', entrance: 'button' }))
|
||||
.toEqual({ params: { a: 1 }, conversationId: 'c', anchorMessageId: 'm', entrance: 'button' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rdmProjectCommandError for decisions', () => {
|
||||
it('gates decision commands on project work readiness', () => {
|
||||
expect(rdmProjectCommandError(hostProject(), 'rdm.review.doc.approve')).toBeNull()
|
||||
expect(rdmProjectCommandError(hostProject({ settings: { 'rdm.state': 'closed' } }), 'rdm.review.doc.approve')).toContain('项目已关闭')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
// 统一渲染 Task 11(续):整文审签入口、完整包下载、规则绑定与解绑、诊断代码分组。
|
||||
// 入口只在服务端冻结职责允许时出现;载荷只含 projectId/docId 与 note|reason|round;
|
||||
// 拒绝以可读文案显示,原始回执只进折叠区。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import {
|
||||
RDM_RENDER_COMMANDS, NO_RENDER_CAPABILITIES, renderCapabilities,
|
||||
normalizeDocSubmission, decisionEntries, decisionPayload,
|
||||
normalizeDeliveries, downloadOptions,
|
||||
describeRenderFailure, diagnosticCodeLabel, normalizeDiagnostics,
|
||||
normalizeCandidateRules, bindableRules, currentBindings, profileBindPayload, profileUnbindPayload,
|
||||
projectSettingsAllowed, createRequestGuard, normalizeProfiles,
|
||||
type DecisionEntries,
|
||||
} from '../src/documentRenderPresentation'
|
||||
import { ReviewDecisionPanel, ProfileBindingPanel } from '../src/components/DocumentRenderActions'
|
||||
import { DiagnosticsList } from '../src/components/DocumentRenderWorkspace'
|
||||
|
||||
const cmd = (id: string, enabled = true) => ({ id, enabled })
|
||||
const idle = { op: null, status: 'idle' as const, error: '' }
|
||||
|
||||
const statusPayload = {
|
||||
docId: 'd1',
|
||||
submission: {
|
||||
submissionId: 'ds_d1_2', round: 2, state: 'submitted', createdBy: 'author-1',
|
||||
// Go 结构体 domain.Roles 无 json 标签:字段名原样输出为 Author/Reviewers/Approver。
|
||||
duties: { Author: 'author-1', Reviewers: ['rev-1', 'rev-2'], Approver: 'appr-1' },
|
||||
reviews: { 'rev-1': true, 'rev-2': false },
|
||||
},
|
||||
}
|
||||
const sub = normalizeDocSubmission(statusPayload)!
|
||||
const allowedCaps = { approve: true, reject: true, final: true }
|
||||
const none: DecisionEntries = { approve: false, reject: false, final: false }
|
||||
|
||||
describe('审签与绑定能力门控(命令清单逐项判定)', () => {
|
||||
it('每项审签/绑定命令独立门控,核心渲染能力不受影响', () => {
|
||||
const all = RDM_RENDER_COMMANDS.map(id => cmd(id))
|
||||
expect(renderCapabilities(all)).toMatchObject({ ready: true, approve: true, reject: true, final: true, bind: true, unbind: true })
|
||||
const noFinal = renderCapabilities(all.filter(c => c.id !== 'rdm.review.doc.final'))
|
||||
expect(noFinal).toMatchObject({ ready: true, approve: true, reject: true, final: false })
|
||||
expect(renderCapabilities(all.filter(c => c.id !== 'rdm.render.profile.unbind'))).toMatchObject({ bind: true, unbind: false })
|
||||
expect(NO_RENDER_CAPABILITIES).toMatchObject({ approve: false, reject: false, final: false, bind: false, unbind: false })
|
||||
expect(renderCapabilities(RDM_RENDER_COMMANDS.map(id => cmd(id, false))).final).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('整文提交归一化(rdm.review.doc.status)', () => {
|
||||
it('兼容服务端 Go 字段名 Author/Reviewers/Approver,并保留冻结的审核进度', () => {
|
||||
expect(sub).toEqual({
|
||||
submissionId: 'ds_d1_2', round: 2, state: 'submitted', author: 'author-1',
|
||||
reviewers: ['rev-1', 'rev-2'], approver: 'appr-1', reviews: { 'rev-1': true, 'rev-2': false },
|
||||
})
|
||||
})
|
||||
it('无提交或轮次非法即无记录,不补造', () => {
|
||||
expect(normalizeDocSubmission({ docId: 'd1', submission: null })).toBeNull()
|
||||
expect(normalizeDocSubmission({ submission: { round: 0, submissionId: 'x' } })).toBeNull()
|
||||
expect(normalizeDocSubmission({ submission: { round: 1 } })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('审签入口:只在服务端冻结职责允许时出现', () => {
|
||||
it('审核人未审时可通过;已通过的审核人仅可驳回;编制人不可自审', () => {
|
||||
expect(decisionEntries(sub, 'rev-2', allowedCaps)).toEqual({ approve: true, reject: true, final: false })
|
||||
expect(decisionEntries(sub, 'rev-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
|
||||
expect(decisionEntries(sub, 'author-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
|
||||
})
|
||||
it('批准人仅在全员审核通过后可终批;批准人本身可驳回', () => {
|
||||
expect(decisionEntries(sub, 'appr-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
|
||||
const allDone = { ...sub, reviews: { 'rev-1': true, 'rev-2': true } }
|
||||
expect(decisionEntries(allDone, 'appr-1', allowedCaps)).toEqual({ approve: false, reject: true, final: true })
|
||||
})
|
||||
it('非冻结名单成员、无身份、非在途状态、能力未开一律无入口', () => {
|
||||
expect(decisionEntries(sub, 'outsider', allowedCaps)).toEqual(none)
|
||||
expect(decisionEntries(sub, undefined, allowedCaps)).toEqual(none)
|
||||
expect(decisionEntries({ ...sub, state: 'final' }, 'rev-2', allowedCaps)).toEqual(none)
|
||||
expect(decisionEntries(sub, 'rev-2', { approve: false, reject: true, final: true })).toEqual({ approve: false, reject: true, final: false })
|
||||
expect(decisionEntries(null, 'rev-2', allowedCaps)).toEqual(none)
|
||||
})
|
||||
})
|
||||
|
||||
describe('审签载荷:只含 projectId/docId 与 note|reason|round', () => {
|
||||
it('approve 的 note、reject 的 reason、final 的 round/reason;空说明不携带', () => {
|
||||
expect(decisionPayload('approve', 'p1', 'd1', ' 通过 ', 2)).toEqual({ command: 'rdm.review.doc.approve', params: { projectId: 'p1', docId: 'd1', note: '通过' } })
|
||||
expect(decisionPayload('approve', 'p1', 'd1', ' ', 2)).toEqual({ command: 'rdm.review.doc.approve', params: { projectId: 'p1', docId: 'd1' } })
|
||||
expect(decisionPayload('reject', 'p1', 'd1', '依据不足', 2)).toEqual({ command: 'rdm.review.doc.reject', params: { projectId: 'p1', docId: 'd1', reason: '依据不足' } })
|
||||
expect(decisionPayload('final', 'p1', 'd1', '', 2)).toEqual({ command: 'rdm.review.doc.final', params: { projectId: 'p1', docId: 'd1', round: 2 } })
|
||||
expect(decisionPayload('final', 'p1', 'd1', '终批', 2)).toEqual({ command: 'rdm.review.doc.final', params: { projectId: 'p1', docId: 'd1', round: 2, reason: '终批' } })
|
||||
})
|
||||
it('任何审签载荷都不携带身份字段(身份只认会话)', () => {
|
||||
const identityKey = /actor|user|owner|identity|approvedBy|reviewer|approver|author|signature/i
|
||||
for (const kind of ['approve', 'reject', 'final'] as const) {
|
||||
const keys = Object.keys(decisionPayload(kind, 'p1', 'd1', 'x', 2).params)
|
||||
expect(keys.filter(k => identityKey.test(k))).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('完整交付包(rdm.render.download kind=package)', () => {
|
||||
const ready = normalizeDeliveries({
|
||||
items: [{ deliveryId: 'd9', mode: 'approved', status: 'ready', profileId: 'x', profileVersion: 1, createdAt: '', availableKinds: ['manifest', 'docx', 'review-docx', 'markdown'] }],
|
||||
}).items[0]
|
||||
it('交付已生成(ready)时完整包位于单件下载之后', () => {
|
||||
expect(downloadOptions(ready).map(o => o.kind)).toEqual(['markdown', 'docx', 'review-docx', 'manifest', 'package'])
|
||||
expect(downloadOptions(ready).at(-1)?.label).toBe('完整交付包(ZIP)')
|
||||
})
|
||||
it('非 ready 的交付不提供完整包', () => {
|
||||
expect(downloadOptions({ ...ready, status: 'generation-failed' }).map(o => o.kind)).not.toContain('package')
|
||||
expect(downloadOptions({ ...ready, status: 'frozen' }).map(o => o.kind)).not.toContain('package')
|
||||
})
|
||||
})
|
||||
|
||||
describe('服务端拒绝码:翻译为可读文案,原始回执保留', () => {
|
||||
it('受控、未发布、绑定变化三类拒绝', () => {
|
||||
const controlled = describeRenderFailure(new Error('400: controlled_requires_exact_profile: controlled document d1 requires its exact template profile'))
|
||||
expect(controlled.message).toContain('受控文档')
|
||||
expect(controlled.raw).toContain('controlled_requires_exact_profile')
|
||||
expect(describeRenderFailure(new Error('400: profile_not_published: x@1 is not published (missing)')).message).toContain('尚未发布')
|
||||
expect(describeRenderFailure(new Error('400: binding_mismatch: document d1 is bound to a@1')).message).toContain('刷新')
|
||||
})
|
||||
it('审签拒绝保持服务端原文(去掉状态码前缀)', () => {
|
||||
expect(describeRenderFailure(new Error('400: 仅本轮(第 2 轮)冻结名单内的审核人可审批')))
|
||||
.toMatchObject({ message: '仅本轮(第 2 轮)冻结名单内的审核人可审批' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('诊断:八类代码的中文标签与严重度分组', () => {
|
||||
const codes = ['required_content_missing', 'intentionally_empty', 'template_placeholder_left', 'mapping_fallback',
|
||||
'signature_data_missing', 'mapping_unresolved', 'mapping_ambiguous', 'approval_invalid']
|
||||
it('已知代码显示中文标签,未知代码原样保留', () => {
|
||||
for (const code of codes) expect(diagnosticCodeLabel(code)).not.toBe(code)
|
||||
expect(diagnosticCodeLabel('custom_x')).toBe('custom_x')
|
||||
})
|
||||
it('错误在前、警告在后;节点按标题定位,代码标签可见', () => {
|
||||
const diags = normalizeDiagnostics([
|
||||
{ code: 'mapping_unresolved', severity: 'error', message: '映射未解析', nodeId: 'n1', suggestion: '补充映射' },
|
||||
{ code: 'template_placeholder_left', severity: 'warning', message: '占位符未替换', nodeId: 'n2' },
|
||||
])
|
||||
const html = renderToStaticMarkup(createElement(DiagnosticsList, {
|
||||
diagnostics: diags, nodeTitle: id => `章节 ${id}`, onSelectNode: () => {}, idPrefix: 'p',
|
||||
}))
|
||||
expect(html.indexOf('错误(1)')).toBeGreaterThanOrEqual(0)
|
||||
expect(html.indexOf('错误(1)')).toBeLessThan(html.indexOf('警告(1)'))
|
||||
expect(html).toContain(diagnosticCodeLabel('mapping_unresolved'))
|
||||
expect(html).toContain('章节 n1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则绑定:候选与服务端选项合并、当前绑定、载荷', () => {
|
||||
const profiles = normalizeProfiles({
|
||||
projectId: 'p1', docId: 'd1', controlled: false, fallbackAllowed: true,
|
||||
options: [
|
||||
{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true },
|
||||
{ source: 'document', profileId: 'doc', version: '2', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true },
|
||||
{ source: 'project-default', profileId: 'prj', version: '5', state: 'blocked', publicationAllowed: true, controlled: false, fallbackAllowed: true, blockedReason: '资产校验未通过' },
|
||||
],
|
||||
})
|
||||
const candidates = normalizeCandidateRules({
|
||||
projectId: 'p1',
|
||||
candidates: [
|
||||
{ candidateId: 'c1', state: 'published', publication: { profileId: 'prj.std', version: '2', decision: 'ok' } },
|
||||
{ candidateId: 'c2', state: 'draft' },
|
||||
{ candidateId: 'c3', state: 'published', publication: { profileId: 'prj.std', version: '2' } },
|
||||
],
|
||||
})
|
||||
it('可绑定规则 = 服务端选项中已发布项 ∪ 项目已发布候选(去重,保序)', () => {
|
||||
expect(bindableRules(profiles, candidates).map(r => `${r.profileId}@${r.version}`)).toEqual(['org@3', 'doc@2', 'prj.std@2'])
|
||||
})
|
||||
it('当前绑定只取服务端选项中的绑定来源', () => {
|
||||
expect(currentBindings(profiles)).toEqual({
|
||||
document: { profileId: 'doc', version: '2' },
|
||||
'project-default': { profileId: 'prj', version: '5' },
|
||||
})
|
||||
})
|
||||
it('绑定载荷:key 按作用域取值;文档类型缺失则不生成载荷', () => {
|
||||
const rule = { profileId: 'prj.std', version: '2' }
|
||||
expect(profileBindPayload({ projectId: 'p1', source: 'document', docId: 'd1', typeKey: 'SDD', rule }))
|
||||
.toEqual({ projectId: 'p1', source: 'document', key: 'd1', profileId: 'prj.std', version: '2' })
|
||||
expect(profileBindPayload({ projectId: 'p1', source: 'document-type', docId: 'd1', typeKey: 'SDD', rule }))
|
||||
.toEqual({ projectId: 'p1', source: 'document-type', key: 'SDD', profileId: 'prj.std', version: '2' })
|
||||
expect(profileBindPayload({ projectId: 'p1', source: 'project-default', docId: 'd1', typeKey: undefined, rule }))
|
||||
.toEqual({ projectId: 'p1', source: 'project-default', key: 'p1', profileId: 'prj.std', version: '2' })
|
||||
expect(profileBindPayload({ projectId: 'p1', source: 'document-type', docId: 'd1', typeKey: undefined, rule })).toBeUndefined()
|
||||
})
|
||||
it('解绑带前置条件(profileId+version 成对);无绑定时不带前置条件', () => {
|
||||
expect(profileUnbindPayload({ projectId: 'p1', source: 'document', docId: 'd1', typeKey: 'SDD', precondition: { profileId: 'doc', version: '2' } }))
|
||||
.toEqual({ projectId: 'p1', source: 'document', key: 'd1', profileId: 'doc', version: '2' })
|
||||
expect(profileUnbindPayload({ projectId: 'p1', source: 'project-default', docId: 'd1', typeKey: 'SDD' }))
|
||||
.toEqual({ projectId: 'p1', source: 'project-default', key: 'p1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('项目设置权限:以服务端 rdm.perm.check 的 project.settings 为准', () => {
|
||||
it('仅 allowed=true 的 project.settings 才放行', () => {
|
||||
expect(projectSettingsAllowed({ projectId: 'p1', member: 'u', role: 'member', profRole: null, actions: [{ action: 'project.settings', allowed: true, source: 'override' }] })).toBe(true)
|
||||
expect(projectSettingsAllowed({ actions: [{ action: 'project.settings', allowed: false, source: 'x' }] })).toBe(false)
|
||||
expect(projectSettingsAllowed({ actions: [{ action: 'doc.read', allowed: true, source: 'x' }] })).toBe(false)
|
||||
expect(projectSettingsAllowed(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('请求守卫:过期的审签/绑定结果被丢弃', () => {
|
||||
it('同一作用域的新请求使旧结果失效;取消后全部失效', () => {
|
||||
const g = createRequestGuard()
|
||||
const first = g.begin('scope')
|
||||
const second = g.begin('scope')
|
||||
expect(g.current(first, 'scope')).toBe(false)
|
||||
expect(g.current(second, 'scope')).toBe(true)
|
||||
g.cancel()
|
||||
expect(g.current(second, 'scope')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('审签面板:入口门控与拒绝显示', () => {
|
||||
const noop = () => {}
|
||||
it('无可用入口时不出现任何动作按钮,只显示轮次与进度', () => {
|
||||
const html = renderToStaticMarkup(createElement(ReviewDecisionPanel, {
|
||||
sub, entries: none, busy: false, status: idle, onRetry: noop, onDecide: noop,
|
||||
}))
|
||||
expect(html).toContain('第 2 轮')
|
||||
expect(html).not.toContain('审核通过')
|
||||
expect(html).not.toContain('驳回')
|
||||
expect(html).not.toContain('终批')
|
||||
})
|
||||
it('只渲染服务端允许的入口;失败以可读文案显示并保留原始回执', () => {
|
||||
const entries: DecisionEntries = { approve: true, reject: true, final: false }
|
||||
const html = renderToStaticMarkup(createElement(ReviewDecisionPanel, {
|
||||
sub, entries, busy: false,
|
||||
status: { op: { kind: 'reject', operationId: 'op_1' }, status: 'failed', error: '400: 当前身份无权驳回' },
|
||||
onRetry: noop, onDecide: noop,
|
||||
}))
|
||||
expect(html).toContain('审核通过')
|
||||
expect(html).toContain('驳回')
|
||||
expect(html).not.toContain('终批')
|
||||
expect(html).toContain('当前身份无权驳回')
|
||||
expect(html).toContain('原始回执')
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则绑定面板:当前绑定、解绑入口与文档类型禁用', () => {
|
||||
const noop = () => {}
|
||||
const rules = [{ profileId: 'prj.std', version: '2', label: 'prj.std v2' }]
|
||||
it('显示三个作用域的当前绑定;可解绑已绑定项;未选规则时绑定按钮禁用', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProfileBindingPanel, {
|
||||
rules, bindings: { document: { profileId: 'doc', version: '2' } }, typeKey: undefined,
|
||||
busy: false, status: idle, onRetry: noop, onBind: noop, onUnbind: noop,
|
||||
}))
|
||||
expect(html).toContain('本文档')
|
||||
expect(html).toContain('文档类型')
|
||||
expect(html).toContain('项目默认')
|
||||
expect(html).toContain('doc@2')
|
||||
expect(html).toContain('解绑')
|
||||
expect(html).toContain('disabled')
|
||||
})
|
||||
it('文档类型未知时,文档类型行提示无法绑定', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProfileBindingPanel, {
|
||||
rules, bindings: {}, typeKey: undefined, busy: false, status: idle, onRetry: noop, onBind: noop, onUnbind: noop,
|
||||
}))
|
||||
expect(html).toContain('文档类型未就绪')
|
||||
})
|
||||
})
|
||||
|
||||
describe('能力表与类型守卫', () => {
|
||||
it('未知 source 的绑定不进入当前绑定', () => {
|
||||
const p = normalizeProfiles({ options: [{ source: 'template', profileId: 'company.TPL', version: '1', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false }] })
|
||||
expect(currentBindings(p)).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,916 @@
|
||||
// 统一渲染 Task 11:规则选择、预览诊断、双视图文档树、审阅与下载的纯展示逻辑。
|
||||
// 组件只消费这里的函数;服务端字段一律视为可能被裁剪,缺失即不显示,不补造。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import {
|
||||
RDM_RENDER_COMMANDS, renderCapabilities,
|
||||
normalizeProfiles, profileOptionViews, controlledNotice, defaultProfileKey, profileKey,
|
||||
describeRenderFailure, displayText,
|
||||
normalizeDiagnostics, groupDiagnostics, diagnosticsSummaryText, diagnosticCountsByNode, locateDiagnostic,
|
||||
nextOperation, createRequestGuard, renderScope,
|
||||
buildDocTree, visibleNodeIds, visibleOrder, layoutMindMap, moveSelection, nodeAnchorId,
|
||||
nodeFacts, hasFacts,
|
||||
normalizeReviewView, partitionAnnotations, partitionRevisions, annotatePayload, revisePayload, validateReviewText,
|
||||
locateNodeId, reviewTargetNode, normalizeRenderTrace, traceAnchor, traceTypeLabel, traceEndpointLabel,
|
||||
normalizeDeliveries, mergeDeliveries, downloadOptions, normalizeDownload, decodeBase64,
|
||||
truncateMiddle, latestSubmissionId, formatRenderTime,
|
||||
submissionIdOf, currentSubmissionId, loadDeliveryDiagnostics, receiptStateView, walkDocTree, DOC_TREE_LIMITS,
|
||||
type RenderProfiles, type DocTree,
|
||||
} from '../src/documentRenderPresentation'
|
||||
import {
|
||||
ProfileOptionList, DiagnosticsList, AnnotationGroups, RevisionGroups, RenderTraceList, DownloadButtons, RenderReceipt,
|
||||
} from '../src/components/DocumentRenderWorkspace'
|
||||
import { RenderFailure } from '../src/components/renderFeedback'
|
||||
|
||||
const cmd = (id: string, enabled = true) => ({ id, enabled })
|
||||
|
||||
describe('渲染能力门控(fail-closed)', () => {
|
||||
it('核心命令齐备才 ready;审阅/批注各自独立门控', () => {
|
||||
const all = RDM_RENDER_COMMANDS.map(id => cmd(id))
|
||||
const caps = renderCapabilities(all)
|
||||
expect(caps).toEqual({
|
||||
ready: true, submit: true, review: true, annotate: true, revise: true, reviewExport: true, approvedExport: true,
|
||||
approve: true, reject: true, final: true, bind: true, unbind: true,
|
||||
})
|
||||
const noReview = renderCapabilities(all.filter(c => c.id !== 'rdm.render.review.view'))
|
||||
expect(noReview.ready).toBe(true)
|
||||
expect(noReview.review).toBe(false)
|
||||
expect(renderCapabilities(all.filter(c => c.id !== 'rdm.render.profiles')).ready).toBe(false)
|
||||
})
|
||||
it('null / 非数组 / disabled 一律未就绪', () => {
|
||||
expect(renderCapabilities(null).ready).toBe(false)
|
||||
expect(renderCapabilities('x' as never).ready).toBe(false)
|
||||
expect(renderCapabilities(RDM_RENDER_COMMANDS.map(id => cmd(id, false))).ready).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const profiles = (over: Partial<RenderProfiles> = {}): RenderProfiles => ({
|
||||
options: [
|
||||
{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false },
|
||||
{ source: 'template', profileId: 'tpl', version: '1', state: 'draft', publicationAllowed: false, controlled: true, fallbackAllowed: false },
|
||||
{ source: 'document', profileId: 'doc', version: '2', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false, snapshotHash: 'h1' },
|
||||
{ source: 'project-default', profileId: 'prj', version: '5', state: 'blocked', publicationAllowed: true, controlled: true, fallbackAllowed: false, blockedReason: '资产校验未通过' },
|
||||
],
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('规则选择(五级来源)', () => {
|
||||
it('按 文档 > 模板 > 项目默认 > 公司默认 排序并给出来源/版本/状态', () => {
|
||||
const views = profileOptionViews(profiles())
|
||||
expect(views.map(v => v.option.source)).toEqual(['document', 'template', 'project-default', 'organization-default'])
|
||||
expect(views.map(v => v.sourceLabel)).toEqual(['文档规则', '模板规则', '项目默认规则', '公司默认规则'])
|
||||
expect(views[0]).toMatchObject({ stateLabel: '已发布', previewAllowed: true, formalAllowed: true })
|
||||
expect(views[0].reason).toBeUndefined()
|
||||
})
|
||||
it('停用规则不可用,给出服务端原因;草稿只能预览', () => {
|
||||
const [, draft, blocked] = profileOptionViews(profiles())
|
||||
expect(blocked).toMatchObject({ previewAllowed: false, formalAllowed: false, reason: '资产校验未通过' })
|
||||
expect(draft).toMatchObject({ previewAllowed: true, formalAllowed: false })
|
||||
expect(draft.reason).toContain('草稿')
|
||||
})
|
||||
it('受控文档不回退到项目或公司默认,并给出明确说明', () => {
|
||||
const org = profileOptionViews(profiles())[3]
|
||||
expect(org).toMatchObject({ previewAllowed: false, formalAllowed: false })
|
||||
expect(org.reason).toContain('不回退到项目或公司默认')
|
||||
expect(controlledNotice(profiles())).toContain('不会回退到项目或公司默认规则')
|
||||
const free = profiles({ options: [{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true }] })
|
||||
expect(controlledNotice(free)).toBeUndefined()
|
||||
expect(profileOptionViews(free)[0].previewAllowed).toBe(true)
|
||||
})
|
||||
it('未获发布许可的已发布规则不能用于正式交付', () => {
|
||||
const p = profiles({ options: [{ source: 'project-default', profileId: 'p', version: '1', state: 'published', publicationAllowed: false, controlled: false, fallbackAllowed: true }] })
|
||||
expect(profileOptionViews(p)[0]).toMatchObject({ previewAllowed: true, formalAllowed: false })
|
||||
})
|
||||
it('默认选中服务端 selected;否则第一个可预览项', () => {
|
||||
const p = profiles({ selected: { source: 'template', profileId: 'tpl', version: '1', hash: 'x' } })
|
||||
expect(defaultProfileKey(p)).toBe(profileKey({ source: 'template', profileId: 'tpl', version: '1' }))
|
||||
expect(defaultProfileKey(profiles())).toBe('document:doc@2')
|
||||
expect(defaultProfileKey(profiles({ options: [] }))).toBe('')
|
||||
})
|
||||
it('归一化丢弃不完整或未知来源的选项', () => {
|
||||
const p = normalizeProfiles({ options: [{ source: 'galaxy', profileId: 'x', version: '1' }, { source: 'project', profileId: 'x', version: '1' }, { source: 'document', profileId: 'd' }, { source: 'document', profileId: 'd', version: 2, state: 'published' }] })
|
||||
expect(p.options).toHaveLength(1)
|
||||
expect(p.options[0]).toMatchObject({ profileId: 'd', version: '2', publicationAllowed: false, controlled: false, fallbackAllowed: false })
|
||||
expect(p.refusal).toBeUndefined()
|
||||
expect(normalizeProfiles(null).options).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// 以下夹具逐字段对照服务端 JSON(Agent-System-RDM internal/rdmapp/render_workspace.go
|
||||
// RenderProfileOptions / render_delivery.go RenderDeliveryReceipt / docsubmission.go
|
||||
// DocSubmissionRec / plugins/rdm/plugin.go rdmRenderDownload / inspection.go InspectTree)。
|
||||
|
||||
describe('服务端 rdm.render.profiles 真实载荷', () => {
|
||||
const uncontrolled = {
|
||||
projectId: 'p1', docId: 'd1', controlled: false, fallbackAllowed: true,
|
||||
options: [
|
||||
{ source: 'document-type', profileId: 'type.design', version: '2', state: 'published', publicationAllowed: true, snapshotHash: 'h2', controlled: false, fallbackAllowed: true },
|
||||
{ source: 'project-default', profileId: 'prj.std', version: '1', state: 'published', publicationAllowed: true, snapshotHash: 'h3', controlled: false, fallbackAllowed: true },
|
||||
{ source: 'organization-default', profileId: 'org.std', version: '4', state: 'published', publicationAllowed: true, snapshotHash: 'h4', controlled: false, fallbackAllowed: true },
|
||||
],
|
||||
selected: { source: 'document-type', profileId: 'type.design', version: '2', hash: 'h2' },
|
||||
}
|
||||
it('document-type / project-default 选项与 selected 都被保留,selected 为默认选择', () => {
|
||||
const p = normalizeProfiles(uncontrolled)
|
||||
expect(p.options.map(o => o.source)).toEqual(['document-type', 'project-default', 'organization-default'])
|
||||
expect(p.selected).toEqual({ source: 'document-type', profileId: 'type.design', version: '2', hash: 'h2' })
|
||||
expect(profileOptionViews(p).map(v => v.sourceLabel)).toEqual(['文档类型规则', '项目默认规则', '公司默认规则'])
|
||||
expect(defaultProfileKey(p)).toBe('document-type:type.design@2')
|
||||
})
|
||||
it('只有 project-default 命中时以它为默认选择', () => {
|
||||
const p = normalizeProfiles({ ...uncontrolled, options: uncontrolled.options.slice(1), selected: { source: 'project-default', profileId: 'prj.std', version: '1', hash: 'h3' } })
|
||||
expect(defaultProfileKey(p)).toBe('project-default:prj.std@1')
|
||||
expect(profileOptionViews(p)[0]).toMatchObject({ previewAllowed: true, formalAllowed: true, sourceLabel: '项目默认规则' })
|
||||
})
|
||||
it('五级来源按 文档 > 文档类型 > 模板 > 项目默认 > 公司默认 排序', () => {
|
||||
const opt = (source: string, profileId: string) => ({ source, profileId, version: '1', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true })
|
||||
const p = normalizeProfiles({ options: [opt('organization-default', 'o'), opt('project-default', 'p'), opt('template', 't'), opt('document-type', 'dt'), opt('document', 'd')] })
|
||||
expect(profileOptionViews(p).map(v => [v.option.source, v.sourceLabel])).toEqual([
|
||||
['document', '文档规则'], ['document-type', '文档类型规则'], ['template', '模板规则'], ['project-default', '项目默认规则'], ['organization-default', '公司默认规则'],
|
||||
])
|
||||
})
|
||||
it('受控文档:模板规则可选,项目默认与公司默认带服务端原因且不可用', () => {
|
||||
const reason = '受控文档必须使用精确绑定的规则,不得回退到项目或公司默认规则'
|
||||
const p = normalizeProfiles({
|
||||
projectId: 'p1', docId: 'd1', controlled: true, fallbackAllowed: false,
|
||||
options: [
|
||||
{ source: 'template', profileId: 'company.TPL-SDD', version: '3', state: 'published', publicationAllowed: true, snapshotHash: 'ht', controlled: true, fallbackAllowed: false },
|
||||
{ source: 'project-default', profileId: 'prj.std', version: '1', state: 'published', publicationAllowed: true, snapshotHash: 'h3', controlled: true, fallbackAllowed: false, blockedReason: reason },
|
||||
{ source: 'organization-default', profileId: 'org.std', version: '4', state: 'published', publicationAllowed: true, snapshotHash: 'h4', controlled: true, fallbackAllowed: false, blockedReason: reason },
|
||||
],
|
||||
refusal: { code: 'controlled_requires_exact_profile', message: '受控文档必须使用文档或文档类型精确绑定的规则,不得回退到项目或公司默认规则' },
|
||||
})
|
||||
const views = profileOptionViews(p)
|
||||
expect(views.map(v => [v.sourceLabel, v.previewAllowed])).toEqual([['模板规则', true], ['项目默认规则', false], ['公司默认规则', false]])
|
||||
expect(views[1].reason).toBe(reason)
|
||||
expect(defaultProfileKey(p)).toBe('template:company.TPL-SDD@3')
|
||||
})
|
||||
it('已验证未发布的模板规则:服务端 blockedReason 只挡正式交付,仍可预览且为默认选择', () => {
|
||||
const blockedReason = '规则 company.dhf.2750fbaf1f67d10c@1.0.0 处于 validated 状态,未发布,不能用于正式审阅或批准交付'
|
||||
const p = normalizeProfiles({
|
||||
projectId: 'p1', docId: 'doc-33', controlled: true, fallbackAllowed: false,
|
||||
options: [{ source: 'template', profileId: 'company.dhf.2750fbaf1f67d10c', version: '1.0.0', state: 'validated', publicationAllowed: false, snapshotHash: '5fba', controlled: true, fallbackAllowed: false, blockedReason }],
|
||||
refusal: { code: 'controlled_requires_exact_profile', message: '受控文档必须使用文档或文档类型精确绑定的规则,不得回退到项目或公司默认规则' },
|
||||
})
|
||||
expect(profileOptionViews(p)[0]).toMatchObject({ stateLabel: '已验证', previewAllowed: true, formalAllowed: false, reason: blockedReason })
|
||||
expect(defaultProfileKey(p)).toBe('template:company.dhf.2750fbaf1f67d10c@1.0.0')
|
||||
})
|
||||
it('没有已验证快照的规则(missing)不可预览也不可正式交付', () => {
|
||||
const blockedReason = '规则 company.dhf.x@2.0.0 尚无已验证的快照,不能用于正式审阅或批准交付'
|
||||
const p = normalizeProfiles({ options: [{ source: 'template', profileId: 'company.dhf.x', version: '2.0.0', state: 'missing', publicationAllowed: false, controlled: true, fallbackAllowed: false, blockedReason }] })
|
||||
expect(profileOptionViews(p)[0]).toMatchObject({ stateLabel: '无快照', previewAllowed: false, formalAllowed: false, reason: blockedReason })
|
||||
expect(defaultProfileKey(p)).toBe('')
|
||||
})
|
||||
it('refusal 对象 {code,message} 显示 message', () => {
|
||||
const message = '受控文档必须使用文档或文档类型精确绑定的规则,不得回退到项目或公司默认规则'
|
||||
const p = normalizeProfiles({ projectId: 'p1', docId: 'd1', controlled: true, fallbackAllowed: false, options: [], refusal: { code: 'controlled_requires_exact_profile', message } })
|
||||
expect(p.refusal).toBe(message)
|
||||
const html = renderToStaticMarkup(createElement(ProfileOptionList, { profiles: p, selectedKey: '', onSelect: () => {} }))
|
||||
expect(html).toContain(message)
|
||||
expect(html).toContain('drw-failure')
|
||||
expect(html).not.toContain('没有可用的渲染规则')
|
||||
})
|
||||
it('refusal 缺 message 时退到 code', () => {
|
||||
const p = normalizeProfiles({ options: [], refusal: { code: 'profile_not_found', message: '' } })
|
||||
expect(p.refusal).toBe('profile_not_found')
|
||||
expect(normalizeProfiles({ options: [], refusal: {} }).refusal).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('服务端 rdm.render.submit 真实回执', () => {
|
||||
it('从 DocSubmissionRec 取 submissionId', () => {
|
||||
const rec = {
|
||||
submissionId: 'sub-9', projectId: 'p1', docId: 'd1', round: 2, sections: { s1: 3 },
|
||||
duties: { author: 'amy', reviewer: 'bob', approver: 'carol' }, digest: 'dg', state: 'pending', reviews: {},
|
||||
createdBy: 'amy', createdAt: '2026-10-15T00:00:00Z',
|
||||
}
|
||||
expect(submissionIdOf(rec)).toBe('sub-9')
|
||||
expect(submissionIdOf({ id: 'legacy' })).toBeUndefined()
|
||||
expect(submissionIdOf(null)).toBeUndefined()
|
||||
})
|
||||
it('当前提交:本会话选择 > rdm.review.doc.status 最新提交 > 交付记录推断', () => {
|
||||
const delivered = [{ submissionId: 'sub-old', createdAt: '2026-10-15T00:00:00Z' }] as Parameters<typeof latestSubmissionId>[0]
|
||||
const status = { docId: 'd1', submission: { submissionId: 'sub-new', round: 2, state: 'pending' } }
|
||||
expect(currentSubmissionId('sub-mine', status, delivered)).toBe('sub-mine')
|
||||
expect(currentSubmissionId(null, status, delivered)).toBe('sub-new')
|
||||
expect(currentSubmissionId(null, { docId: 'd1', submission: null }, delivered)).toBe('sub-old')
|
||||
expect(currentSubmissionId(null, null, [])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64')
|
||||
const readyReceipt = {
|
||||
jobId: 'rjob-1', deliveryId: 'rdlv-1', projectId: 'p1', docId: 'd1', operationId: 'op_1', mode: 'preview', state: 'ready',
|
||||
sourceVersionId: 'sv1', profileSnapshotHash: 'ph', reviewSnapshotHash: '', artifactSha256: 'a', manifestSha256: 'm',
|
||||
fileName: 'd1.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', createdAt: '2026-10-15T00:00:00Z',
|
||||
}
|
||||
|
||||
describe('预览/导出后读取诊断(rdm.render.download kind=diagnostics)', () => {
|
||||
const diagnosticsJSON = JSON.stringify([
|
||||
{ code: 'heading.skip', severity: 'warning', message: '标题层级跳跃', nodeId: 's2', rulePath: 'headings' },
|
||||
{ code: 'section.required', severity: 'error', message: '缺少必填章节', nodeId: 's1', suggestion: '补写范围' },
|
||||
])
|
||||
const download = (data: string) => ({
|
||||
deliveryId: 'rdlv-1', kind: 'diagnostics', fileName: 'd1-diagnostics.json', mime: 'application/json', sha256: 'x', dataBase64: b64(data),
|
||||
})
|
||||
it('按回执 deliveryId 请求诊断并解码 UTF-8 JSON', async () => {
|
||||
const calls: [string, Record<string, unknown>][] = []
|
||||
const out = await loadDeliveryDiagnostics(async (command, params) => { calls.push([command, params]); return download(diagnosticsJSON) }, 'p1', readyReceipt, () => true)
|
||||
expect(calls).toEqual([['rdm.render.download', { projectId: 'p1', deliveryId: 'rdlv-1', kind: 'diagnostics' }]])
|
||||
expect(out).toMatchObject({ deliveryId: 'rdlv-1' })
|
||||
const list = out && 'diagnostics' in out ? out.diagnostics : []
|
||||
expect(list.map(d => [d.code, d.severity, d.nodeId])).toEqual([['heading.skip', 'warning', 's2'], ['section.required', 'error', 's1']])
|
||||
expect(list[1].message).toBe('缺少必填章节')
|
||||
})
|
||||
it('服务端 nil 诊断(JSON null)视为无问题', async () => {
|
||||
const out = await loadDeliveryDiagnostics(async () => download('null'), 'p1', readyReceipt, () => true)
|
||||
expect(out).toEqual({ deliveryId: 'rdlv-1', diagnostics: [] })
|
||||
})
|
||||
it('请求失败或内容无法解析给出可读文案,不抛出', async () => {
|
||||
const failed = await loadDeliveryDiagnostics(async () => { throw new Error('400: delivery blob is not available: diagnostics') }, 'p1', readyReceipt, () => true)
|
||||
expect(failed).toEqual({ deliveryId: 'rdlv-1', error: 'delivery blob is not available: diagnostics' })
|
||||
const garbled = await loadDeliveryDiagnostics(async () => download('{not json'), 'p1', readyReceipt, () => true)
|
||||
expect(garbled).toEqual({ deliveryId: 'rdlv-1', error: '诊断报告无法解析' })
|
||||
const incomplete = await loadDeliveryDiagnostics(async () => ({ deliveryId: 'rdlv-1' }), 'p1', readyReceipt, () => true)
|
||||
expect(incomplete).toEqual({ deliveryId: 'rdlv-1', error: '下载内容不完整' })
|
||||
})
|
||||
it('回执缺 deliveryId 不发请求', async () => {
|
||||
let called = false
|
||||
const out = await loadDeliveryDiagnostics(async () => { called = true }, 'p1', { state: 'ready' }, () => true)
|
||||
expect(called).toBe(false)
|
||||
expect(out).toEqual({ deliveryId: '', error: '回执缺少交付编号,无法读取诊断' })
|
||||
})
|
||||
it('过期下载(守卫失效)被丢弃', async () => {
|
||||
let live = true
|
||||
const out = await loadDeliveryDiagnostics(async () => { live = false; return download(diagnosticsJSON) }, 'p1', readyReceipt, () => live)
|
||||
expect(out).toBeNull()
|
||||
const failedStale = await loadDeliveryDiagnostics(async () => { live = false; throw new Error('boom') }, 'p1', readyReceipt, () => live)
|
||||
expect(failedStale).toBeNull()
|
||||
})
|
||||
it('回执面板:中文状态、诊断加载中/失败可读/节点定位', () => {
|
||||
const render = (diagnostics: Parameters<typeof RenderReceipt>[0]['diagnostics']) => renderToStaticMarkup(createElement(RenderReceipt, {
|
||||
receipt: readyReceipt, diagnostics, nodeTitle: (id: string) => (id === 's1' ? '范围' : undefined), onSelectNode: () => {},
|
||||
}))
|
||||
const loading = render({ status: 'loading' })
|
||||
expect(loading).toContain('已生成')
|
||||
expect(loading).not.toContain('>ready<')
|
||||
expect(loading).toContain('诊断读取中')
|
||||
const failed = render({ status: 'error', error: '400: {"receipt":{"error":"诊断不可用"}}' })
|
||||
expect(failed).toContain('诊断不可用')
|
||||
expect(failed).toContain('drw-failure')
|
||||
const ready = render({ status: 'ready', items: normalizeDiagnostics([{ code: 'E1', severity: 'error', message: '缺少必填章节', nodeId: 's1' }]) })
|
||||
expect(ready).toContain('1 个错误')
|
||||
expect(ready).toContain('drw-diag-node')
|
||||
expect(ready).toContain('title="范围"')
|
||||
})
|
||||
it('回执 state 映射为中文标签', () => {
|
||||
expect(receiptStateView('ready')).toEqual({ label: '已生成', tone: 'ok' })
|
||||
expect(receiptStateView('running')).toEqual({ label: '生成中', tone: 'info' })
|
||||
expect(receiptStateView('frozen')).toEqual({ label: '排队中', tone: 'plain' })
|
||||
expect(receiptStateView('generation-failed')).toEqual({ label: '生成失败', tone: 'danger' })
|
||||
expect(receiptStateView('cancelled')).toEqual({ label: '已取消', tone: 'warn' })
|
||||
expect(receiptStateView('weird')).toEqual({ label: '状态未知', tone: 'plain' })
|
||||
expect(receiptStateView(undefined)).toEqual({ label: '状态未知', tone: 'plain' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('文档树逐层展开(rdm.inspect.tree 每次只返回一层)', () => {
|
||||
type Node = { id: string; title: string; children?: Node[] }
|
||||
const shellItem = (docId: string, parentId: string, n: Node, order: number) => ({
|
||||
kind: 'section', id: n.id, docId, parentId, order, title: n.title, templateBlockId: '', state: 'draft',
|
||||
hasChildren: (n.children?.length ?? 0) > 0, structureRevision: 1, contentRevision: 1,
|
||||
})
|
||||
/** 模拟 InspectTree:parent=docId 返回顶层(parentId 为空串),parent=nodeId 返回其直接子节点;按 limit 分页。 */
|
||||
function fakeServer(docId: string, roots: Node[], limit = 50) {
|
||||
const byParent = new Map<string, Node[]>([[docId, roots]])
|
||||
const visit = (list: Node[]) => { for (const n of list) { byParent.set(n.id, n.children ?? []); visit(n.children ?? []) } }
|
||||
visit(roots)
|
||||
const calls: string[] = []
|
||||
let inflight = 0
|
||||
let maxInflight = 0
|
||||
const fetchLevel = async (parent: string, cursor: string) => {
|
||||
calls.push(parent)
|
||||
inflight++
|
||||
maxInflight = Math.max(maxInflight, inflight)
|
||||
await new Promise(r => setTimeout(r, 1))
|
||||
inflight--
|
||||
const list = byParent.get(parent) ?? []
|
||||
const start = cursor ? Number(cursor) : 0
|
||||
const page = list.slice(start, start + limit)
|
||||
const hasMore = start + limit < list.length
|
||||
return {
|
||||
items: page.map((n, i) => shellItem(docId, parent === docId ? '' : parent, n, start + i)),
|
||||
hasMore, ...(hasMore ? { nextCursor: String(start + limit) } : {}),
|
||||
}
|
||||
}
|
||||
return { fetchLevel, calls, maxInflight: () => maxInflight }
|
||||
}
|
||||
const roots: Node[] = [
|
||||
{ id: 'n1', title: '范围', children: [{ id: 'n11', title: '目的', children: [{ id: 'n111', title: '背景' }] }, { id: 'n12', title: '适用' }] },
|
||||
{ id: 'n2', title: '设计', children: [{ id: 'n21', title: '架构' }] },
|
||||
]
|
||||
it('逐层请求子节点并按文档顺序构建完整嵌套树(非编号标题)', async () => {
|
||||
const s = fakeServer('doc1', roots)
|
||||
const out = await walkDocTree(s.fetchLevel, 'doc1', () => true)
|
||||
expect(out?.truncated).toBe(false)
|
||||
const t = buildDocTree({ id: 'doc1', title: '设计说明书' }, out!.items)
|
||||
expect(t.order).toEqual(['doc1', 'n1', 'n11', 'n111', 'n12', 'n2', 'n21'])
|
||||
expect(t.nodes.n1.children).toEqual(['n11', 'n12'])
|
||||
expect(t.nodes.n11.children).toEqual(['n111'])
|
||||
expect(t.nodes.n111.depth).toBe(3)
|
||||
expect(t.nodes.n21.parentId).toBe('n2')
|
||||
expect(s.calls).not.toContain('n12')
|
||||
expect(new Set(s.calls)).toEqual(new Set(['doc1', 'n1', 'n2', 'n11']))
|
||||
})
|
||||
it('同层分页读完', async () => {
|
||||
const many: Node[] = Array.from({ length: 7 }, (_, i) => ({ id: `m${i}`, title: `节${i}` }))
|
||||
const s = fakeServer('doc1', [{ id: 'root', title: '根', children: many }], 3)
|
||||
const out = await walkDocTree(s.fetchLevel, 'doc1', () => true)
|
||||
expect(out!.items.map(i => i.id)).toEqual(['root', ...many.map(n => n.id)])
|
||||
})
|
||||
it('并发不超过 4', async () => {
|
||||
const wide: Node[] = Array.from({ length: 12 }, (_, i) => ({ id: `w${i}`, title: `宽${i}`, children: [{ id: `w${i}c`, title: '子' }] }))
|
||||
const s = fakeServer('doc1', wide)
|
||||
const out = await walkDocTree(s.fetchLevel, 'doc1', () => true)
|
||||
expect(out!.items).toHaveLength(24)
|
||||
expect(s.maxInflight()).toBeLessThanOrEqual(4)
|
||||
expect(s.maxInflight()).toBeGreaterThan(1)
|
||||
})
|
||||
it('深度超过 12 截断并标记', async () => {
|
||||
let chain: Node = { id: 'c20', title: '底' }
|
||||
for (let i = 19; i >= 1; i--) chain = { id: `c${i}`, title: `层${i}`, children: [chain] }
|
||||
const out = await walkDocTree(fakeServer('doc1', [chain]).fetchLevel, 'doc1', () => true)
|
||||
expect(out!.items).toHaveLength(12)
|
||||
expect(out!.truncated).toBe(true)
|
||||
expect(DOC_TREE_LIMITS).toEqual({ maxDepth: 12, maxNodes: 2000, concurrency: 4, maxPages: 20 })
|
||||
})
|
||||
it('节点数超过上限截断并标记', async () => {
|
||||
const wide: Node[] = Array.from({ length: 30 }, (_, i) => ({ id: `x${i}`, title: `节${i}` }))
|
||||
const out = await walkDocTree(fakeServer('doc1', wide).fetchLevel, 'doc1', () => true, { ...DOC_TREE_LIMITS, maxNodes: 10 })
|
||||
expect(out!.items).toHaveLength(10)
|
||||
expect(out!.truncated).toBe(true)
|
||||
})
|
||||
it('守卫失效后停止继续请求并丢弃结果', async () => {
|
||||
const s = fakeServer('doc1', roots)
|
||||
let live = true
|
||||
const out = await walkDocTree(async (parent, cursor) => { const r = await s.fetchLevel(parent, cursor); live = false; return r }, 'doc1', () => live)
|
||||
expect(out).toBeNull()
|
||||
expect(s.calls).toEqual(['doc1'])
|
||||
})
|
||||
it('环与重复 id 不重复展开', async () => {
|
||||
const out = await walkDocTree(async parent => ({
|
||||
items: parent === 'doc1'
|
||||
? [{ kind: 'section', id: 'a', parentId: '', title: '甲', hasChildren: true }]
|
||||
: [{ kind: 'section', id: 'a', parentId: 'a', title: '甲', hasChildren: true }, { kind: 'section', id: 'doc1', parentId: 'a', title: '根', hasChildren: true }],
|
||||
}), 'doc1', () => true)
|
||||
expect(out!.items.map(i => i.id)).toEqual(['a'])
|
||||
})
|
||||
it('模板实例中无标题的块(封面、签署表、字段)以块类型与模板块编号标识,不渲染空白节点', async () => {
|
||||
const out = await walkDocTree(async parent => ({
|
||||
items: parent === 'doc-33'
|
||||
? [
|
||||
{ kind: 'section', id: 'dn-cover', parentId: '', title: '', templateBlockId: 'pp.cover', hasChildren: false },
|
||||
{ kind: 'table', id: 'dn-sign', parentId: '', title: ' ', templateBlockId: 'pp.identity-signoff.table', hasChildren: false },
|
||||
{ kind: 'field', id: 'dn-x', parentId: '', title: '', hasChildren: false },
|
||||
{ kind: 'section', id: 'dn-ctx', parentId: '', title: '项目内容、目标及意义', templateBlockId: 'pp.context', hasChildren: false },
|
||||
]
|
||||
: [],
|
||||
}), 'doc-33', () => true)
|
||||
expect(out!.items.map(i => i.title)).toEqual(['章节(pp.cover)', '表格(pp.identity-signoff.table)', '字段(dn-x)', '项目内容、目标及意义'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('拒绝与失败转为可读文案(原始回执另行折叠)', () => {
|
||||
it('非 2xx JSON 回执取 error 文本,不直出 JSON', () => {
|
||||
const f = describeRenderFailure(new Error('400: {"receipt":{"state":"rejected","error":"规则未发布,不能用于正式交付"}}'))
|
||||
expect(f.message).toBe('规则未发布,不能用于正式交付')
|
||||
expect(f.raw).toContain('"receipt"')
|
||||
})
|
||||
it('登录失效 / 能力缺失 / 无法解析', () => {
|
||||
expect(describeRenderFailure('401: x').message).toContain('登录已失效')
|
||||
expect(describeRenderFailure('400: unknown or disabled command: rdm.render.profiles').message).toContain('未提供文档渲染能力')
|
||||
expect(describeRenderFailure('500: {"weird":1}').message).toBe('操作失败,请稍后重试')
|
||||
expect(describeRenderFailure('{"a":1}').message).toBe('操作失败,请稍后重试')
|
||||
})
|
||||
it('纯文本拒绝原样(截断)显示;OOXML 不显示', () => {
|
||||
expect(describeRenderFailure(new Error('当前编制人不是你')).message).toBe('当前编制人不是你')
|
||||
expect(describeRenderFailure('x'.repeat(500)).message.length).toBeLessThanOrEqual(241)
|
||||
expect(describeRenderFailure('<w:document><w:body/></w:document>').message).toBe('操作失败,请稍后重试')
|
||||
})
|
||||
it('displayText 屏蔽原始文档格式', () => {
|
||||
expect(displayText('<?xml version="1.0"?><w:p/>')).toBe('(原始文档格式内容不显示)')
|
||||
expect(displayText(' ')).toBeUndefined()
|
||||
expect(displayText(12)).toBeUndefined()
|
||||
expect(displayText('正文')).toBe('正文')
|
||||
})
|
||||
it('RenderFailure 只在 Collapsible 内出现原始回执', () => {
|
||||
const html = renderToStaticMarkup(createElement(RenderFailure, { error: '400: {"receipt":{"error":"拒绝"}}', id: 't' }))
|
||||
expect(html).toContain('拒绝')
|
||||
expect(html).toContain('原始回执')
|
||||
expect(html).toContain('mk-col')
|
||||
expect(html).not.toContain('<details')
|
||||
})
|
||||
})
|
||||
|
||||
describe('预览诊断', () => {
|
||||
const raw = [
|
||||
{ code: 'W1', severity: 'warning', message: '标题层级跳跃', nodeId: 's2' },
|
||||
{ code: 'E1', severity: 'error', message: '缺少必填章节', nodeId: 's1', suggestion: '补写' },
|
||||
{ code: 'I1', severity: 'info', message: '提示' },
|
||||
{ severity: 'error' },
|
||||
{ code: 'E2', severity: 'fatal', message: '模板缺失', nodeId: 's1' },
|
||||
]
|
||||
it('归一化严重级并丢弃无消息条目', () => {
|
||||
const list = normalizeDiagnostics(raw)
|
||||
expect(list).toHaveLength(4)
|
||||
expect(list.find(d => d.code === 'E2')?.severity).toBe('error')
|
||||
expect(normalizeDiagnostics('nope')).toEqual([])
|
||||
})
|
||||
it('按 错误 > 警告 > 提示 分组,省略空组', () => {
|
||||
const groups = groupDiagnostics(normalizeDiagnostics(raw))
|
||||
expect(groups.map(g => [g.severity, g.items.length])).toEqual([['error', 2], ['warning', 1], ['info', 1]])
|
||||
expect(groups[0].label).toBe('错误')
|
||||
expect(groupDiagnostics([])).toEqual([])
|
||||
})
|
||||
it('摘要与节点计数', () => {
|
||||
expect(diagnosticsSummaryText({ errors: 2, warnings: 1 })).toBe('2 个错误 · 1 个警告')
|
||||
expect(diagnosticsSummaryText({ errors: 0, warnings: 0 })).toBe('无错误或警告')
|
||||
const counts = diagnosticCountsByNode(normalizeDiagnostics(raw))
|
||||
expect(counts.s1).toEqual({ errors: 2, warnings: 0 })
|
||||
expect(counts.s2).toEqual({ errors: 0, warnings: 1 })
|
||||
})
|
||||
it('诊断列表:可定位的可点击,未定位的标明且不可点;长列表分组折叠', () => {
|
||||
const list = normalizeDiagnostics(Array.from({ length: 120 }, (_, i) => ({ code: 'W', severity: 'warning', message: `问题 ${i}`, nodeId: i % 2 ? 's1' : 'ghost' })))
|
||||
const html = renderToStaticMarkup(createElement(DiagnosticsList, {
|
||||
diagnostics: list, nodeTitle: (id: string) => (id === 's1' ? '第一章' : undefined), onSelectNode: () => {}, idPrefix: 't',
|
||||
}))
|
||||
expect(html).toContain('警告(120)')
|
||||
expect(html).toContain('第一章')
|
||||
expect(html).toContain('未定位节点')
|
||||
expect(html).toContain('mk-col')
|
||||
})
|
||||
})
|
||||
|
||||
describe('诊断定位:treeNodeId 优先,nodeId 回退', () => {
|
||||
// 渲染器 nodeId 是 canonical/映射节点 id;RDM 另给工作区树节点 id treeNodeId。
|
||||
const known = new Set(['dn-cover', 's1'])
|
||||
const titles: Record<string, string> = { 'dn-cover': '封面', s1: '第一章' }
|
||||
const nodeTitle = (id: string) => (known.has(id) ? titles[id] : undefined)
|
||||
const raw = [
|
||||
{ code: 'required_content_missing', severity: 'warning', message: '缺少文档编号', nodeId: 'pp.cover.document-number.value', treeNodeId: 'dn-cover' },
|
||||
{ code: 'W', severity: 'warning', message: '旧载荷:只有树节点 nodeId', nodeId: 's1' },
|
||||
{ code: 'W', severity: 'error', message: 'treeNodeId 不在树中时回退 nodeId', nodeId: 's1', treeNodeId: 'dn-gone' },
|
||||
{ code: 'W', severity: 'warning', message: '只有映射 id', nodeId: 'pp.revision.entry.c01' },
|
||||
{ code: 'W', severity: 'warning', message: '两者都不在树中', nodeId: 'pp.x', treeNodeId: 'dn-gone' },
|
||||
{ code: 'W', severity: 'info', message: '无节点' },
|
||||
]
|
||||
const list = normalizeDiagnostics(raw)
|
||||
|
||||
it('归一化保留 treeNodeId,空串与非字符串丢弃', () => {
|
||||
expect(list[0]).toMatchObject({ nodeId: 'pp.cover.document-number.value', treeNodeId: 'dn-cover' })
|
||||
expect(list[1].treeNodeId).toBeUndefined()
|
||||
const odd = normalizeDiagnostics([{ message: 'm', treeNodeId: '' }, { message: 'm', treeNodeId: 7 }])
|
||||
expect(odd.every(d => !('treeNodeId' in d))).toBe(true)
|
||||
})
|
||||
it('locateDiagnostic:树节点 id 优先,缺省或未知时回退 nodeId,都未知则未定位', () => {
|
||||
expect(list.map(d => locateDiagnostic(d, nodeTitle))).toEqual([
|
||||
{ id: 'dn-cover', title: '封面' },
|
||||
{ id: 's1', title: '第一章' },
|
||||
{ id: 's1', title: '第一章' },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
])
|
||||
})
|
||||
it('节点计数按定位到的树节点归档;不在树中的不计入任何节点', () => {
|
||||
const counts = diagnosticCountsByNode(list, id => known.has(id))
|
||||
expect(counts).toEqual({ 'dn-cover': { errors: 0, warnings: 1 }, s1: { errors: 1, warnings: 1 } })
|
||||
})
|
||||
it('诊断列表:两种 id 形式都可点击定位,未定位的明确标出', () => {
|
||||
// 只有首组默认展开(SSR 不渲染折叠组内容),因此只取警告组。
|
||||
const warnings = list.filter(d => d.severity === 'warning')
|
||||
const html = renderToStaticMarkup(createElement(DiagnosticsList, { diagnostics: warnings, nodeTitle, onSelectNode: () => {}, idPrefix: 'loc' }))
|
||||
expect(html).toContain('title="封面"')
|
||||
expect(html).toContain('title="第一章"')
|
||||
expect(html.match(/<button[^>]*class="drw-diag-node"/g)).toHaveLength(2)
|
||||
expect(html.match(/drw-diag-node unlocated/g)).toHaveLength(2)
|
||||
expect(html).not.toContain('pp.cover.document-number.value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('操作编号:每次动作新编号,重试复用', () => {
|
||||
it('新动作铸新 id;同类重试复用;不同类不复用', () => {
|
||||
let n = 0
|
||||
const mint = () => `op_${++n}`
|
||||
const a = nextOperation('preview', mint, null)
|
||||
expect(a).toEqual({ kind: 'preview', operationId: 'op_1' })
|
||||
expect(nextOperation('preview', mint, a, true)).toBe(a)
|
||||
expect(nextOperation('preview', mint, a).operationId).toBe('op_2')
|
||||
expect(nextOperation('submit', mint, a, true).operationId).toBe('op_3')
|
||||
})
|
||||
it('默认铸造器生成非空唯一编号', () => {
|
||||
const x = nextOperation('preview', undefined, null)
|
||||
const y = nextOperation('preview', undefined, null)
|
||||
expect(x.operationId).toMatch(/^op_/)
|
||||
expect(x.operationId).not.toBe(y.operationId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('请求守卫:过期响应丢弃', () => {
|
||||
it('更新的请求使旧票据失效', () => {
|
||||
const g = createRequestGuard()
|
||||
const scope = renderScope(1, 'p', 'd')
|
||||
const t1 = g.begin(scope)
|
||||
const t2 = g.begin(scope)
|
||||
expect(g.current(t1, scope)).toBe(false)
|
||||
expect(g.current(t2, scope)).toBe(true)
|
||||
})
|
||||
it('项目/文档/身份切换使票据失效;cancel 使全部失效', () => {
|
||||
const g = createRequestGuard()
|
||||
const t = g.begin(renderScope(1, 'p', 'd'))
|
||||
expect(g.current(t, renderScope(1, 'p', 'other'))).toBe(false)
|
||||
expect(g.current(t, renderScope(2, 'p', 'd'))).toBe(false)
|
||||
expect(g.current(t, renderScope(1, 'q', 'd'))).toBe(false)
|
||||
expect(g.current(t, renderScope(1, 'p', 'd'))).toBe(true)
|
||||
g.cancel()
|
||||
expect(g.current(t, renderScope(1, 'p', 'd'))).toBe(false)
|
||||
})
|
||||
it('作用域键不因分隔符拼接而碰撞', () => {
|
||||
expect(renderScope(1, 'a|b', 'c')).not.toBe(renderScope(1, 'a', 'b|c'))
|
||||
})
|
||||
})
|
||||
|
||||
const doc = { id: 'doc1', title: '设计说明书' }
|
||||
const items = [
|
||||
{ kind: 'section', id: 's1', title: '1 范围' },
|
||||
{ kind: 'section', id: 's11', title: '1.1 目的' },
|
||||
{ kind: 'section', id: 's12', title: '1.2 适用', state: 'baselined' },
|
||||
{ kind: 'section', id: 's2', title: '2 设计' },
|
||||
{ kind: 'section', id: 's21', title: '细节', level: 2 },
|
||||
{ kind: 'section', id: 's1', title: '重复' },
|
||||
]
|
||||
function tree(): DocTree { return buildDocTree(doc, items) }
|
||||
|
||||
describe('文档树:构建、过滤、布局与键盘导航', () => {
|
||||
it('按编号或 level 推导层级,忽略重复 id', () => {
|
||||
const t = tree()
|
||||
expect(t.rootId).toBe('doc1')
|
||||
expect(t.order).toEqual(['doc1', 's1', 's11', 's12', 's2', 's21'])
|
||||
expect(t.nodes.doc1.children).toEqual(['s1', 's2'])
|
||||
expect(t.nodes.s1.children).toEqual(['s11', 's12'])
|
||||
expect(t.nodes.s21.parentId).toBe('s2')
|
||||
expect(t.nodes.s12.depth).toBe(2)
|
||||
expect(t.nodes.s1.title).toBe('1 范围')
|
||||
})
|
||||
it('显式 parentId 优先', () => {
|
||||
const t = buildDocTree(doc, [{ kind: 'section', id: 'a', title: '甲' }, { kind: 'section', id: 'b', title: '乙', parentId: 'a' }])
|
||||
expect(t.nodes.a.children).toEqual(['b'])
|
||||
})
|
||||
it('过滤保留命中节点及其祖先;问题/审阅模式按标记过滤', () => {
|
||||
const t = tree()
|
||||
const marks = { issues: new Set(['s21']), review: new Set(['s11']) }
|
||||
expect([...visibleNodeIds(t, { text: '适用', mode: 'all' }, marks)].sort()).toEqual(['doc1', 's1', 's12'])
|
||||
expect([...visibleNodeIds(t, { text: '', mode: 'issues' }, marks)].sort()).toEqual(['doc1', 's2', 's21'])
|
||||
expect([...visibleNodeIds(t, { text: '', mode: 'review' }, marks)].sort()).toEqual(['doc1', 's1', 's11'])
|
||||
expect(visibleNodeIds(t, { text: '', mode: 'all' }, marks).size).toBe(6)
|
||||
expect(visibleOrder(t, new Set(['doc1', 's2', 's21']))).toEqual(['doc1', 's2', 's21'])
|
||||
})
|
||||
it('思维导图布局:父节点居中于子节点,边连接父子,尺寸覆盖全部节点', () => {
|
||||
const t = tree()
|
||||
const layout = layoutMindMap(t, visibleNodeIds(t, { text: '', mode: 'all' }, { issues: new Set(), review: new Set() }))
|
||||
const at = (id: string) => layout.nodes.find(n => n.id === id)!
|
||||
expect(at('s11').x).toBeGreaterThan(at('s1').x)
|
||||
expect(at('s1').y).toBe((at('s11').y + at('s12').y) / 2)
|
||||
expect(layout.edges).toHaveLength(5)
|
||||
expect(layout.edges[0].d).toMatch(/^M[\d.]+ [\d.]+ C/)
|
||||
for (const n of layout.nodes) {
|
||||
expect(n.x + 180).toBeLessThanOrEqual(layout.width)
|
||||
expect(n.y + 32).toBeLessThanOrEqual(layout.height)
|
||||
}
|
||||
})
|
||||
it('键盘:上下按可见顺序、左到父、右到首子、Home/End,未处理返回 null', () => {
|
||||
const t = tree()
|
||||
const order = t.order
|
||||
expect(moveSelection(t, order, 's1', 'ArrowDown')).toBe('s11')
|
||||
expect(moveSelection(t, order, 's1', 'ArrowUp')).toBe('doc1')
|
||||
expect(moveSelection(t, order, 's12', 'ArrowLeft')).toBe('s1')
|
||||
expect(moveSelection(t, order, 's1', 'ArrowRight')).toBe('s11')
|
||||
expect(moveSelection(t, order, 's1', 'End')).toBe('s21')
|
||||
expect(moveSelection(t, order, 's21', 'Home')).toBe('doc1')
|
||||
expect(moveSelection(t, order, null, 'ArrowDown')).toBe('doc1')
|
||||
expect(moveSelection(t, order, 's21', 'ArrowDown')).toBe('s21')
|
||||
expect(moveSelection(t, order, 's1', 'x')).toBeNull()
|
||||
expect(moveSelection(t, ['doc1', 's2'], 's2', 'ArrowRight')).toBe('s2')
|
||||
})
|
||||
it('锚点 id 只含安全字符', () => {
|
||||
expect(nodeAnchorId('sec/1 a')).toBe('drw-node-sec_1_a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('节点事实:只显示服务端给出的字段', () => {
|
||||
it('空详情不产出任何事实', () => {
|
||||
const f = nodeFacts({ kind: 'section', id: 's1', relations: [], recentEvents: [] })
|
||||
expect(hasFacts(f)).toBe(false)
|
||||
expect(f).toEqual({ sources: [], impacts: [] })
|
||||
expect(hasFacts(nodeFacts(null))).toBe(false)
|
||||
})
|
||||
it('来源/影响/置信/冲突/作者时间/变更前后', () => {
|
||||
const f = nodeFacts({
|
||||
kind: 'section', id: 's1', confidence: 0.82, conflict: '与需求 R3 表述冲突',
|
||||
versions: [{ seq: 1, actor: 'amy', at: '2026-10-01T00:00:00Z' }, { seq: 3, actor: 'bob', at: '2026-10-03T00:00:00Z' }],
|
||||
change: { before: '旧文', after: '新文', impact: '影响验证计划' },
|
||||
relations: [
|
||||
{ layer: 'trace', state: 'active', from: { kind: 'req', id: 'r1' }, to: { kind: 'section', id: 's1' }, conf: 0.9 },
|
||||
{ layer: 'trace', state: 'active', from: { kind: 'note', id: 'secret', disclosure: 'private' }, to: { kind: 'section', id: 's1' } },
|
||||
{ layer: 'trace', state: 'active', from: { kind: 'section', id: 's1' }, to: { kind: 'test', id: 't1' } },
|
||||
],
|
||||
})
|
||||
expect(f.sources).toEqual(['req:r1(置信 90%)'])
|
||||
expect(f.impacts).toEqual(['test:t1'])
|
||||
expect(f.confidence).toBe('82%')
|
||||
expect(f.conflict).toBe('与需求 R3 表述冲突')
|
||||
expect(f.author).toBe('bob')
|
||||
expect(f.at).toBe('2026-10-03T00:00:00Z')
|
||||
expect(f.change).toEqual({ before: '旧文', after: '新文', impact: '影响验证计划' })
|
||||
expect(hasFacts(f)).toBe(true)
|
||||
})
|
||||
it('无版本时退到正式版作者;OOXML 变更不显示原文', () => {
|
||||
const f = nodeFacts({ id: 's1', formal: { actor: 'carol', at: '2026-09-01T00:00:00Z' }, change: { after: '<w:p>x</w:p>' } })
|
||||
expect(f.author).toBe('carol')
|
||||
expect(f.change?.after).toBe('(原始文档格式内容不显示)')
|
||||
expect(f.change?.before).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('审阅:批注、修订与载荷', () => {
|
||||
const view = normalizeReviewView({
|
||||
annotations: [
|
||||
{ id: 'a1', nodeId: 's1', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z', text: '请补充', replies: [{ id: 'a1r', nodeId: 's1', author: { id: 'u2', displayName: '李四' }, createdAt: '2026-10-02T00:00:00Z', text: '已补', resolved: false, orphaned: false }], resolved: false, orphaned: false },
|
||||
{ id: 'a2', nodeId: 's1', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z', text: '原位置已删除', replies: [], resolved: false, orphaned: true },
|
||||
{ id: 'a3', nodeId: 'gone', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z', text: '找不到节点', replies: [], resolved: true, orphaned: false },
|
||||
{ id: 'bad' },
|
||||
],
|
||||
revisions: [{ id: 'v1', nodeId: 's1', kind: 'replace', before: '旧', after: '新', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z' }, { id: 'v2', kind: 'explode' }],
|
||||
decisions: [{ actor: { id: 'u3', displayName: '王五' }, duty: 'reviewer', decision: 'approve', at: '2026-10-03T00:00:00Z' }],
|
||||
})
|
||||
it('归一化丢弃不完整条目', () => {
|
||||
expect(view.annotations.map(a => a.id)).toEqual(['a1', 'a2', 'a3'])
|
||||
expect(view.annotations[0].replies).toHaveLength(1)
|
||||
expect(view.revisions.map(r => r.id)).toEqual(['v1'])
|
||||
expect(view.decisions).toHaveLength(1)
|
||||
expect(normalizeReviewView(undefined)).toEqual({ annotations: [], revisions: [], decisions: [] })
|
||||
})
|
||||
it('孤立或节点不存在的批注进入「无法定位」,绝不挂到其他节点', () => {
|
||||
const { byNode, orphaned } = partitionAnnotations(view.annotations, new Set(['s1', 's2']))
|
||||
expect(byNode.get('s1')?.map(a => a.id)).toEqual(['a1'])
|
||||
expect(orphaned.map(a => a.id)).toEqual(['a2', 'a3'])
|
||||
})
|
||||
it('批注分组渲染:回复缩进、无法定位单独成组', () => {
|
||||
const { byNode, orphaned } = partitionAnnotations(view.annotations, new Set(['s1']))
|
||||
const html = renderToStaticMarkup(createElement(AnnotationGroups, { byNode, orphaned, nodeTitle: () => '第一章', onSelectNode: () => {} }))
|
||||
expect(html).toContain('无法定位(2)')
|
||||
expect(html).toContain('已补')
|
||||
expect(html).toContain('李四')
|
||||
expect(html.indexOf('第一章')).toBeLessThan(html.indexOf('无法定位'))
|
||||
})
|
||||
it('载荷只含业务字段,身份与时间由服务端填写', () => {
|
||||
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', text: ' 好 ', operationId: 'op_1' }))
|
||||
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '好', operationId: 'op_1' })
|
||||
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', text: '回', operationId: 'op_2', replyTo: 'a1' }))
|
||||
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '回', operationId: 'op_2', replyTo: 'a1' })
|
||||
expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', kind: 'insert', after: '新增', operationId: 'op_3' }))
|
||||
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'insert', after: '新增', operationId: 'op_3' })
|
||||
expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', kind: 'replace', before: 'a', after: 'b', operationId: 'op_4' }))
|
||||
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'replace', before: 'a', after: 'b', operationId: 'op_4' })
|
||||
})
|
||||
it('文本校验', () => {
|
||||
expect(validateReviewText(' ')).toBe('请输入内容')
|
||||
expect(validateReviewText('x'.repeat(4001))).toContain('4000')
|
||||
expect(validateReviewText('好')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('审阅按工作区树节点分组与定位(treeNodeId 优先,nodeId 回退)', () => {
|
||||
// RDM 契约:nodeId 恒为渲染器规范节点(pp.…),treeNodeId 为审阅人所选树节点(dn-/sec-/legacy-)。
|
||||
const wsTree = buildDocTree({ id: 'doc1', title: '文档' }, [
|
||||
{ kind: 'section', id: 'dn-head', title: '项目内容、目标及意义' },
|
||||
{ kind: 'field', id: 'dn-leaf', title: '目标', parentId: 'dn-head' },
|
||||
{ kind: 'section', id: 'sec-2', title: '第二章' },
|
||||
])
|
||||
const known = new Set(wsTree.order)
|
||||
const person = { id: 'u1', displayName: '审核人' }
|
||||
const view = normalizeReviewView({
|
||||
annotations: [
|
||||
{ id: 'a1', nodeId: 'pp.goal.value', treeNodeId: 'dn-head', author: person, createdAt: '', text: '按所选标题', replies: [] },
|
||||
{ id: 'a2', nodeId: 'pp.goal.leaf', treeNodeId: 'dn-head', author: person, createdAt: '', text: '同一树节点', replies: [] },
|
||||
{ id: 'a3', nodeId: 'sec-2', author: person, createdAt: '', text: '旧条目只有 nodeId', replies: [] },
|
||||
{ id: 'a4', nodeId: 'dn-leaf', treeNodeId: 'dn-gone', author: person, createdAt: '', text: 'treeNodeId 不在树中回退 nodeId', replies: [] },
|
||||
{ id: 'a5', nodeId: 'pp.x', treeNodeId: 'dn-gone', author: person, createdAt: '', text: '都不在树中', replies: [] },
|
||||
{ id: 'a6', nodeId: 'pp.y', treeNodeId: 'dn-head', author: person, createdAt: '', text: '服务端标孤立', replies: [], orphaned: true },
|
||||
{ id: 'a7', nodeId: 'pp.z', treeNodeId: '', author: person, createdAt: '', text: '空 treeNodeId 不保留', replies: [] },
|
||||
],
|
||||
revisions: [
|
||||
{ id: 'v1', nodeId: 'pp.goal.value', treeNodeId: 'dn-leaf', kind: 'replace', before: '旧', after: '新', author: person, createdAt: '' },
|
||||
{ id: 'v2', nodeId: 'pp.unknown', kind: 'insert', after: '无处可挂', author: person, createdAt: '' },
|
||||
],
|
||||
})
|
||||
|
||||
it('归一化保留 treeNodeId,nodeId 仍为规范节点', () => {
|
||||
expect(view.annotations[0]).toMatchObject({ nodeId: 'pp.goal.value', treeNodeId: 'dn-head' })
|
||||
expect(view.annotations.find(a => a.id === 'a7')).not.toHaveProperty('treeNodeId')
|
||||
expect(view.revisions[0]).toMatchObject({ nodeId: 'pp.goal.value', treeNodeId: 'dn-leaf' })
|
||||
})
|
||||
it('批注按 treeNodeId 分组,缺省或不在树中回退 nodeId,都不在或孤立进入无法定位', () => {
|
||||
const { byNode, orphaned } = partitionAnnotations(view.annotations, known)
|
||||
expect([...byNode.keys()]).toEqual(['dn-head', 'sec-2', 'dn-leaf'])
|
||||
expect(byNode.get('dn-head')?.map(a => a.id)).toEqual(['a1', 'a2'])
|
||||
expect(byNode.get('dn-leaf')?.map(a => a.id)).toEqual(['a4'])
|
||||
expect(orphaned.map(a => a.id)).toEqual(['a5', 'a6', 'a7'])
|
||||
})
|
||||
it('修订同规则分组', () => {
|
||||
const { byNode, orphaned } = partitionRevisions(view.revisions, known)
|
||||
expect([...byNode.keys()]).toEqual(['dn-leaf'])
|
||||
expect(orphaned.map(r => r.id)).toEqual(['v2'])
|
||||
})
|
||||
it('分组标题是树节点名并带定位目标(点击由工作区滚动到该节点)', () => {
|
||||
const nodeTitle = (id: string) => wsTree.nodes[id]?.title
|
||||
const anno = partitionAnnotations(view.annotations, known)
|
||||
const html = renderToStaticMarkup(createElement(AnnotationGroups, { ...anno, nodeTitle, onSelectNode: () => {}, onReply: () => {} }))
|
||||
expect(html).toContain('data-locate="dn-head"')
|
||||
expect(html).toContain('项目内容、目标及意义')
|
||||
expect(html).not.toContain('pp.goal.value')
|
||||
const rev = partitionRevisions(view.revisions, known)
|
||||
const revHtml = renderToStaticMarkup(createElement(RevisionGroups, { ...rev, total: view.revisions.length, nodeTitle, onSelectNode: () => {} }))
|
||||
expect(revHtml).toContain('data-locate="dn-leaf"')
|
||||
expect(revHtml).toContain('修订(2)')
|
||||
expect(revHtml).toContain('无法定位(1)')
|
||||
})
|
||||
it('从树上批注/修订发送所选树节点 id;文档根不是审阅目标', () => {
|
||||
const target = reviewTargetNode(wsTree, 'dn-leaf')
|
||||
expect(target?.id).toBe('dn-leaf')
|
||||
expect(reviewTargetNode(wsTree, 'doc1')).toBeUndefined()
|
||||
expect(reviewTargetNode(wsTree, null)).toBeUndefined()
|
||||
expect(reviewTargetNode(null, 'dn-leaf')).toBeUndefined()
|
||||
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: target!.id, text: '批', operationId: 'op' }).nodeId).toBe('dn-leaf')
|
||||
expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 'sec-2', kind: 'delete', before: '删', operationId: 'op' }).nodeId).toBe('sec-2')
|
||||
})
|
||||
it('locateNodeId 与诊断定位同规则', () => {
|
||||
expect(locateNodeId({ treeNodeId: 'dn-head', nodeId: 'sec-2' }, id => known.has(id))).toBe('dn-head')
|
||||
expect(locateNodeId({ treeNodeId: 'dn-gone', nodeId: 'sec-2' }, id => known.has(id))).toBe('sec-2')
|
||||
expect(locateNodeId({ nodeId: 'pp.x' }, id => known.has(id))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('渲染链条(rdm.inspect.relations layer=render)', () => {
|
||||
const wsTree = buildDocTree({ id: 'doc1', title: '文档' }, [
|
||||
{ kind: 'section', id: 'dn-head', title: '项目内容、目标及意义' },
|
||||
{ kind: 'section', id: 'sec-2', title: '第二章' },
|
||||
])
|
||||
const nodeTitle = (id: string) => wsTree.nodes[id]?.title
|
||||
const sub = { kind: 'render-submission', id: 'sub-1' }
|
||||
const full = normalizeRenderTrace({
|
||||
items: [
|
||||
{ layer: 'general', type: 'derives', from: { kind: 'section', id: 'a' }, to: { kind: 'section', id: 'b' } },
|
||||
{ layer: 'render', type: 'selected-profile', from: { kind: 'doc', id: 'doc1' }, to: { kind: 'render-profile', id: 'company.dhf@1', source: 'template' } },
|
||||
{ layer: 'render', type: 'annotated', from: sub, to: { kind: 'review-annotation', id: 'ann-1', nodeId: 'pp.goal.value', treeNodeId: 'dn-head', displayName: '审核人', at: '2026-10-17T00:00:00Z', text: '请补充' } },
|
||||
{ layer: 'render', type: 'anchored-to', from: { kind: 'review-annotation', id: 'ann-1' }, to: { kind: 'document-node', id: 'dn-head' } },
|
||||
{ layer: 'render', type: 'anchored-to', from: { kind: 'review-annotation', id: 'ann-2' }, to: { kind: 'document-node', id: 'dn-gone' } },
|
||||
{ layer: 'render', type: 'revised', from: sub, to: { kind: 'review-revision', id: 'rev-1', nodeId: 'sec-2', revisionKind: 'replace' } },
|
||||
{ layer: 'render', type: 'restricted-basis', from: { kind: 'render-delivery', id: 'd1' }, to: { kind: 'restricted-source', id: 'restricted' } },
|
||||
{ layer: 'render', type: 'future-edge', from: { kind: 'render-delivery', id: 'd1' }, to: { kind: 'x', id: 'y' } },
|
||||
{ layer: 'render', type: 'delivered', from: { kind: 'doc', id: 'doc1' } },
|
||||
],
|
||||
truncated: true,
|
||||
})
|
||||
|
||||
it('只取 layer=render 且端点完整的条目,未知类型保留原名', () => {
|
||||
expect(full.entries.map(e => e.rawType)).toEqual(['selected-profile', 'annotated', 'anchored-to', 'anchored-to', 'revised', 'restricted-basis', 'future-edge'])
|
||||
expect(full.entries[6].type).toBe('other')
|
||||
expect(traceTypeLabel(full.entries[6])).toBe('future-edge')
|
||||
expect(full.truncated).toBe(true)
|
||||
expect(full.masked).toBe(false)
|
||||
expect(normalizeRenderTrace(null)).toEqual({ entries: [], masked: false, truncated: false })
|
||||
})
|
||||
it('anchored-to 的目标是树节点 id(不回退规范 id);批注/修订 treeNodeId 优先、nodeId 回退', () => {
|
||||
const [, annotated, anchored, anchoredGone, revised, restricted] = full.entries
|
||||
expect(traceAnchor(anchored)).toMatchObject({ subject: { kind: 'review-annotation', id: 'ann-1' }, target: { treeNodeId: 'dn-head' } })
|
||||
expect(traceAnchor(anchored).target).not.toHaveProperty('nodeId')
|
||||
expect(traceAnchor(anchoredGone).target).toEqual({ treeNodeId: 'dn-gone' })
|
||||
expect(traceAnchor(annotated).target).toEqual({ treeNodeId: 'dn-head', nodeId: 'pp.goal.value' })
|
||||
expect(traceAnchor(revised).target).toEqual({ treeNodeId: undefined, nodeId: 'sec-2' })
|
||||
expect(traceAnchor(restricted).target).toBeUndefined()
|
||||
expect(traceEndpointLabel(restricted.to)).toBe('存在受限依据')
|
||||
})
|
||||
it('渲染:已定位的树节点可点击定位,未知树节点显示未定位', () => {
|
||||
const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: full, nodeTitle, onSelectNode: () => {} }))
|
||||
expect(html).toContain('批注锚定')
|
||||
expect(html.match(/data-locate="dn-head"/g)).toHaveLength(2)
|
||||
expect(html).toContain('data-locate="sec-2"')
|
||||
expect(html).toContain('未定位节点')
|
||||
expect(html).not.toContain('dn-gone"')
|
||||
expect(html).toContain('模板规则')
|
||||
expect(html).toContain('替换')
|
||||
expect(html).toContain('请补充')
|
||||
expect(html).toContain('已截断')
|
||||
expect(html).not.toContain('内容已遮蔽')
|
||||
})
|
||||
it('遮蔽条目只显示元数据:标「内容已遮蔽」,正文字段即使误发也不读取', () => {
|
||||
const masked = normalizeRenderTrace({
|
||||
items: [
|
||||
{ layer: 'render', type: 'annotated', contentMasked: true, from: sub, to: { kind: 'review-annotation', id: 'ann-1', treeNodeId: 'dn-head', displayName: '审核人', text: 'SECRET-TEXT', excerpt: 'SECRET-EXCERPT' } },
|
||||
{ layer: 'render', type: 'revised', contentMasked: true, from: sub, to: { kind: 'review-revision', id: 'rev-1', treeNodeId: 'sec-2', revisionKind: 'replace', before: 'SECRET-BEFORE', after: 'SECRET-AFTER' } },
|
||||
{ layer: 'render', type: 'anchored-to', contentMasked: true, from: { kind: 'review-annotation', id: 'ann-1' }, to: { kind: 'document-node', id: 'dn-head' } },
|
||||
],
|
||||
})
|
||||
expect(masked.masked).toBe(true)
|
||||
expect(masked.entries.every(e => e.masked && e.body === undefined)).toBe(true)
|
||||
const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: masked, nodeTitle, onSelectNode: () => {} }))
|
||||
expect(html.match(/内容已遮蔽/g)).toHaveLength(3)
|
||||
expect(html).toContain('正文、批注与修订文字已遮蔽')
|
||||
expect(html).toContain('审核人')
|
||||
expect(html).toContain('data-locate="dn-head"')
|
||||
expect(html).not.toContain('SECRET')
|
||||
})
|
||||
it('空链条有明确空态', () => {
|
||||
const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: normalizeRenderTrace({ items: [] }), nodeTitle, onSelectNode: () => {} }))
|
||||
expect(html).toContain('暂无渲染链条记录')
|
||||
})
|
||||
})
|
||||
|
||||
describe('交付记录与下载', () => {
|
||||
const page = normalizeDeliveries({
|
||||
items: [
|
||||
{ deliveryId: 'd2', mode: 'approved', submissionId: 'sub1', profileId: 'doc', profileVersion: '2', createdAt: '2026-10-03T00:00:00Z', requestedBy: { id: 'u1', displayName: '张三' }, status: 'succeeded', availableKinds: ['manifest', 'docx', 'review-docx', 'markdown', 'evil'], diagnosticsSummary: { errors: 0, warnings: 1 } },
|
||||
{ deliveryId: 'd1', mode: 'preview', profileId: 'doc', profileVersion: '2', createdAt: '2026-10-01T00:00:00Z', status: 'succeeded', availableKinds: ['docx', 'diagnostics'] },
|
||||
{ mode: 'preview' },
|
||||
],
|
||||
nextCursor: 'c2',
|
||||
})
|
||||
it('归一化与分页合并去重', () => {
|
||||
expect(page.items.map(d => d.deliveryId)).toEqual(['d2', 'd1'])
|
||||
expect(page.nextCursor).toBe('c2')
|
||||
expect(page.items[1].diagnosticsSummary).toEqual({ errors: 0, warnings: 0 })
|
||||
const merged = mergeDeliveries(page.items, [{ ...page.items[1] }, { ...page.items[0], deliveryId: 'd0' }])
|
||||
expect(merged.map(d => d.deliveryId)).toEqual(['d2', 'd1', 'd0'])
|
||||
expect(latestSubmissionId(page.items)).toBe('sub1')
|
||||
expect(latestSubmissionId([page.items[1]])).toBeUndefined()
|
||||
})
|
||||
it('下载选项按固定顺序,批准模式标注批准稿,忽略未知类型', () => {
|
||||
expect(downloadOptions(page.items[0]).map(o => [o.kind, o.label])).toEqual([
|
||||
['markdown', 'Markdown'], ['docx', '批准稿 DOCX'], ['review-docx', '审阅留痕 DOCX'], ['manifest', '清单(manifest)'],
|
||||
])
|
||||
expect(downloadOptions(page.items[1]).map(o => o.label)).toEqual(['预览 DOCX', '诊断报告'])
|
||||
})
|
||||
it('下载载荷接受 contentType/filename 或 mime/fileName;缺数据报错', () => {
|
||||
expect(normalizeDownload({ filename: 'a.md', contentType: 'text/markdown', dataBase64: 'QQ==', sha256: 'x' }))
|
||||
.toEqual({ fileName: 'a.md', contentType: 'text/markdown', dataBase64: 'QQ==', sha256: 'x' })
|
||||
expect(normalizeDownload({ fileName: 'b.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', dataBase64: 'QQ==', sha256: 'y' }).contentType)
|
||||
.toContain('wordprocessingml')
|
||||
expect(() => normalizeDownload({ fileName: 'c', sha256: 'z' })).toThrow('下载内容不完整')
|
||||
expect(() => normalizeDownload({ dataBase64: 'QQ==', sha256: 'z' })).toThrow('下载内容不完整')
|
||||
expect(normalizeDownload({ fileName: 'c', dataBase64: 'QQ==', sha256: 'z' }).contentType).toBe('application/octet-stream')
|
||||
})
|
||||
it('base64 解码', () => {
|
||||
expect([...decodeBase64('QUJD')]).toEqual([65, 66, 67])
|
||||
})
|
||||
it('长文件名中间截断并保留扩展名', () => {
|
||||
const long = '研发项目设计说明书-第三轮审阅留痕-包含全部批注与修订记录-最终版本.docx'
|
||||
const short = truncateMiddle(long, 24)
|
||||
expect(short.length).toBeLessThanOrEqual(24)
|
||||
expect(short.endsWith('.docx')).toBe(true)
|
||||
expect(short).toContain('…')
|
||||
expect(truncateMiddle('a.md', 24)).toBe('a.md')
|
||||
})
|
||||
it('下载按钮:截断名 + title 全名', () => {
|
||||
const html = renderToStaticMarkup(createElement(DownloadButtons, {
|
||||
delivery: { ...page.items[0], deliveryId: 'd2' }, busyKind: null, onDownload: () => {},
|
||||
}))
|
||||
expect(html).toContain('批准稿 DOCX')
|
||||
expect(html).toContain('审阅留痕 DOCX')
|
||||
expect(html).not.toContain('evil')
|
||||
})
|
||||
it('时间格式化:非法时间为空串', () => {
|
||||
expect(formatRenderTime('not-a-date')).toBe('')
|
||||
expect(formatRenderTime('2026-10-01T00:00:00Z')).not.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则列表渲染', () => {
|
||||
it('显示来源、版本、状态与不可用原因;受控说明可见', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProfileOptionList, {
|
||||
profiles: profiles(), selectedKey: 'document:doc@2', onSelect: () => {},
|
||||
}))
|
||||
for (const s of ['文档规则', '项目默认规则', '模板规则', '公司默认规则', 'v2', '已发布', '草稿', '资产校验未通过', '不会回退到项目或公司默认规则']) {
|
||||
expect(html).toContain(s)
|
||||
}
|
||||
expect(html).toContain('disabled')
|
||||
})
|
||||
it('refusal 以可读失败显示', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProfileOptionList, {
|
||||
profiles: profiles({ options: [], refusal: '{"weird":true}' }), selectedKey: '', onSelect: () => {},
|
||||
}))
|
||||
expect(html).toContain('操作失败,请稍后重试')
|
||||
expect(html).toContain('原始回执')
|
||||
})
|
||||
})
|
||||
@@ -69,5 +69,16 @@ describe('文档职责草稿', () => {
|
||||
docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'carol',
|
||||
expectedDutyVersion: 3, confirmInvalidation: true,
|
||||
})
|
||||
expect(one.docs[0]).not.toHaveProperty('accountIds')
|
||||
})
|
||||
|
||||
it('候选检索选中的 userId 随预览/批量保存以 accountIds{owner:userId} 携带,只含本文档职责人', () => {
|
||||
const known = { bob: 'u-bob', erin: 'u-erin' }
|
||||
const out = savePayload([
|
||||
doc({ dirty: true }),
|
||||
doc({ docId: 'd2', dirty: true, author: 'dave', reviewers: ['frank'], approver: 'gina' }),
|
||||
], false, known)
|
||||
expect(out.docs[0].accountIds).toEqual({ bob: 'u-bob' })
|
||||
expect(out.docs[1]).not.toHaveProperty('accountIds')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,10 +31,10 @@ describe('humanChangePasswordError(改密门)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
|
||||
describe('humanRedeemError(可信激活与本人恢复能力兑换)', () => {
|
||||
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
|
||||
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
|
||||
expect(humanRedeemError('409: login key already used')).toBe('该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toBe('能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化')
|
||||
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
|
||||
})
|
||||
it('未知错误剥状态码前缀透传', () => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LINKED_ACTION_LABELS, dutyConditionNote, linkedCapabilitySummary } from '../src/permissionExplanations'
|
||||
import { describeSource } from '../src/rdmManagement'
|
||||
|
||||
const LINKED = ['project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign']
|
||||
|
||||
describe('linked capability summary', () => {
|
||||
it('returns empty when the server omits the field (older backend)', () => {
|
||||
expect(linkedCapabilitySummary(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('explains held project-create eligibility and the self-created-project scope', () => {
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, source: 'grant', actions: LINKED })).toBe(
|
||||
'已具备创建项目资格(来源:管理员授予);创建本人项目后自动具备:管理项目设置、管理项目成员、管理项目权限、分配文档职责;'
|
||||
+ '这些资格仅对本人创建的项目生效,其他项目按项目授权判断')
|
||||
})
|
||||
|
||||
it('maps source variants and falls back to the raw source', () => {
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, source: 'role', actions: [] })).toBe('已具备创建项目资格(来源:角色)')
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, source: 'admin', actions: [] })).toBe('已具备创建项目资格(来源:系统管理员)')
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, source: 'admin-grant', actions: [] })).toContain('来源:管理员授予')
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, source: 'future', actions: [] })).toContain('来源:future')
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, actions: [] })).toBe('已具备创建项目资格')
|
||||
})
|
||||
|
||||
it('appends explicit conflicts with Chinese labels', () => {
|
||||
const text = linkedCapabilitySummary({
|
||||
projectCreate: true, source: 'role', actions: ['project.settings'], conflicts: ['project.members.manage', 'document.duties.assign'],
|
||||
})
|
||||
expect(text).toContain('创建本人项目后自动具备:管理项目设置')
|
||||
expect(text.endsWith('存在冲突:管理项目成员、分配文档职责 被明确禁止')).toBe(true)
|
||||
})
|
||||
|
||||
it('states missing eligibility without listing linked actions', () => {
|
||||
expect(linkedCapabilitySummary({ projectCreate: false, actions: [] })).toBe('未具备创建项目资格')
|
||||
expect(linkedCapabilitySummary({ projectCreate: false, actions: ['project.settings'], conflicts: ['project.settings'] })).toBe('未具备创建项目资格')
|
||||
})
|
||||
|
||||
it('labels every linked action and keeps unknown ids verbatim', () => {
|
||||
expect(LINKED.map(id => LINKED_ACTION_LABELS[id])).toEqual(['管理项目设置', '管理项目成员', '管理项目权限', '分配文档职责'])
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, actions: ['x.y'] })).toContain('自动具备:x.y')
|
||||
})
|
||||
})
|
||||
|
||||
describe('duty condition note', () => {
|
||||
it('explains the duty and state conditions for document author/review/approve', () => {
|
||||
expect(dutyConditionNote('document.author')).toBe('仍需被分配该文档的编制职责,且文档状态符合要求')
|
||||
expect(dutyConditionNote('document.review')).toBe('仍需被分配该文档的审核职责,且文档状态符合要求')
|
||||
expect(dutyConditionNote('document.approve')).toBe('仍需被分配该文档的批准职责,且文档状态符合要求')
|
||||
})
|
||||
|
||||
it('is empty for every other action', () => {
|
||||
for (const id of ['document.read', 'project.settings', 'project.create', '']) expect(dutyConditionNote(id)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeSource future sources', () => {
|
||||
it('maps creator and role sources, keeping the raw fallback', () => {
|
||||
expect(describeSource('creator')).toBe('本人项目创建者自动具备(仅本人创建的项目)')
|
||||
expect(describeSource('role')).toBe('职位角色')
|
||||
expect(describeSource('weird')).toBe('weird')
|
||||
})
|
||||
})
|
||||
@@ -59,6 +59,21 @@ describe('命令载荷构造(身份只经认证会话——载荷不含任何
|
||||
expect(memberAddPayload('p1', 'carol', 'owner')).toEqual({ projectId: 'p1', member: 'carol', role: 'owner' })
|
||||
expect(memberRemovePayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol' })
|
||||
})
|
||||
it('成员添加携带候选检索选中的 userId(缺省不携带)', () => {
|
||||
expect(memberAddPayload('p1', 'carol', 'member', 'u-carol'))
|
||||
.toEqual({ projectId: 'p1', member: 'carol', role: 'member', userId: 'u-carol' })
|
||||
expect(memberAddPayload('p1', 'carol', 'owner', '')).not.toHaveProperty('userId')
|
||||
})
|
||||
it('文档职责分配/预览携带 accountIds{owner:userId},只含本条职责涉及的人', () => {
|
||||
const known = { alice: 'u-a', bob: 'u-b', erin: 'u-e' }
|
||||
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob'], 'dave', { expectedDutyVersion: 2, accountIds: known }))
|
||||
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave',
|
||||
expectedDutyVersion: 2, accountIds: { alice: 'u-a', bob: 'u-b' } })
|
||||
expect(assignDocDutiesPayload('p1', 'd1', 'dave', ['carol'], 'frank', { accountIds: known })).not.toHaveProperty('accountIds')
|
||||
const doc = { docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2,
|
||||
confirmInvalidation: false, accountIds: { alice: 'u-a' } }
|
||||
expect(dutyPreviewPayload('p1', [doc])).toEqual({ projectId: 'p1', docs: [doc] })
|
||||
})
|
||||
it('文档职责与整文审签载荷', () => {
|
||||
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave'))
|
||||
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave' })
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// 项目成员页检索(R2 契约):服务端先过滤后分页,前端只拼参数、映射候选与追加分页。
|
||||
// node 环境无 DOM——决策逻辑全部落在 src/rdmManagement.ts 的纯函数上逐项断言。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Project } from '../src/contracts'
|
||||
import {
|
||||
INVALID_REASON, JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, NOT_MEMBER_REASON, RDM_MEMBER_COMMANDS,
|
||||
SEARCH_FIELDS, accountCandidatePager, accountListParams, accountSearchKey, accountsToPickItems, appendMemberPage,
|
||||
memberListParams, personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload,
|
||||
type RdmAccountPage, type RdmMemberRow,
|
||||
} from '../src/rdmManagement'
|
||||
|
||||
const row = (owner: string, name?: string): RdmMemberRow =>
|
||||
({ owner, name, role: 'member', addedBy: 'admin', addedAt: '' })
|
||||
|
||||
describe('accountListParams', () => {
|
||||
it('passes mode, searchFields, roleId and cursor to rdm.account.list', () => {
|
||||
expect(accountListParams('p1', {
|
||||
query: ' zhang ', cursor: 'c2', limit: 20, roleId: 'r-eng', mode: 'exact', searchFields: ['name'],
|
||||
})).toEqual({ projectId: 'p1', query: 'zhang', cursor: 'c2', limit: 20, roleId: 'r-eng', mode: 'exact', searchFields: ['name'] })
|
||||
})
|
||||
|
||||
it('passes docId for document duty candidates', () => {
|
||||
expect(accountListParams('p1', { query: 'li', limit: 20, docId: 'd1', searchFields: ['owner', 'name'] }))
|
||||
.toEqual({ projectId: 'p1', query: 'li', limit: 20, mode: 'fuzzy', searchFields: ['owner', 'name'], docId: 'd1' })
|
||||
})
|
||||
|
||||
it('never sends the legacy match alias next to mode', () => {
|
||||
const params = accountListParams('p1', { query: 'li', limit: 20, mode: 'exact' })
|
||||
expect(params).not.toHaveProperty('match')
|
||||
expect(params.mode).toBe('exact')
|
||||
})
|
||||
|
||||
it('omits empty filters, defaults to fuzzy and caps the page at 50', () => {
|
||||
expect(accountListParams('p1', { query: ' ', cursor: '', limit: 200, roleId: '', docId: '', searchFields: [] }))
|
||||
.toEqual({ projectId: 'p1', limit: 50, mode: 'fuzzy' })
|
||||
})
|
||||
|
||||
it('labels both match modes and the search field choices in Chinese', () => {
|
||||
expect(MATCH_MODE_LABELS).toEqual({ fuzzy: '模糊匹配', exact: '精确账号或姓名' })
|
||||
expect(SEARCH_FIELDS.both.fields).toEqual(['owner', 'name'])
|
||||
expect(SEARCH_FIELDS.owner.fields).toEqual(['owner'])
|
||||
expect(SEARCH_FIELDS.name.fields).toEqual(['name'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('accountCandidatePager', () => {
|
||||
const recorder = (pages: RdmAccountPage[]) => {
|
||||
const calls: Record<string, unknown>[] = []
|
||||
const run = async (params: Record<string, unknown>) => { calls.push(params); return pages.shift() ?? {} }
|
||||
return { calls, run }
|
||||
}
|
||||
const acct = (userId: string, owner: string) => ({ userId, owner, valid: true, joined: false })
|
||||
|
||||
it('does not query the directory for an empty search', async () => {
|
||||
const { calls, run } = recorder([])
|
||||
const page = accountCandidatePager(run, 'p1', { mode: 'fuzzy' })
|
||||
expect(await page(' ', '', 20)).toEqual({ items: [] })
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('pages with the opaque next cursor under the same query and options', async () => {
|
||||
const { calls, run } = recorder([{ accounts: [acct('u1', 'a1')], next: 'n1' }, { accounts: [acct('u2', 'a2')] }])
|
||||
const page = accountCandidatePager(run, 'p1', { mode: 'exact', searchFields: ['owner'], docId: 'd1' })
|
||||
const first = await page('zh', '', 20)
|
||||
expect(first.nextCursor).toBe('n1')
|
||||
await page('zh', 'n1', 20)
|
||||
expect(calls).toEqual([
|
||||
{ projectId: 'p1', query: 'zh', limit: 20, mode: 'exact', searchFields: ['owner'], docId: 'd1' },
|
||||
{ projectId: 'p1', query: 'zh', limit: 20, mode: 'exact', searchFields: ['owner'], docId: 'd1', cursor: 'n1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('resets the cursor when the query changes', async () => {
|
||||
const { calls, run } = recorder([{ accounts: [], next: 'n1' }, { accounts: [] }])
|
||||
const page = accountCandidatePager(run, 'p1', { mode: 'fuzzy' })
|
||||
await page('zh', '', 20)
|
||||
await page('zhang', 'n1', 20)
|
||||
expect(calls[1]).not.toHaveProperty('cursor')
|
||||
})
|
||||
|
||||
it('resets the cursor when mode or search fields change (new pager, new reload key)', async () => {
|
||||
const { calls, run } = recorder([{ accounts: [], next: 'n1' }, { accounts: [] }, { accounts: [] }])
|
||||
await accountCandidatePager(run, 'p1', { mode: 'fuzzy', searchFields: ['owner', 'name'] })('zh', '', 20)
|
||||
await accountCandidatePager(run, 'p1', { mode: 'exact', searchFields: ['owner', 'name'] })('zh', 'n1', 20)
|
||||
await accountCandidatePager(run, 'p1', { mode: 'fuzzy', searchFields: ['name'] })('zh', 'n1', 20)
|
||||
expect(calls[1]).not.toHaveProperty('cursor')
|
||||
expect(calls[2]).not.toHaveProperty('cursor')
|
||||
const key = (mode: 'fuzzy' | 'exact', fields: ('owner' | 'name')[], docId?: string) =>
|
||||
accountSearchKey('p1', { mode, searchFields: fields, docId })
|
||||
expect(new Set([key('fuzzy', ['owner', 'name']), key('exact', ['owner', 'name']), key('fuzzy', ['name']),
|
||||
key('fuzzy', ['owner', 'name'], 'd1')]).size).toBe(4)
|
||||
})
|
||||
|
||||
it('maps duty candidates so only current members are selectable', async () => {
|
||||
const { run } = recorder([{ accounts: [
|
||||
{ userId: 'u1', owner: 'm1', name: '成员', valid: true, joined: true },
|
||||
{ userId: 'u2', owner: 'x1', valid: true, joined: false },
|
||||
] }])
|
||||
const { items } = await accountCandidatePager(run, 'p1', { docId: 'd1' })('m', '', 20)
|
||||
expect(items).toEqual([
|
||||
{ id: 'u1', owner: 'm1', title: '成员', subtitle: 'm1' },
|
||||
{ id: 'u2', owner: 'x1', title: 'x1', subtitle: 'x1', disabled: true, disabledReason: NOT_MEMBER_REASON },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('memberListParams', () => {
|
||||
it('sends query, roleId and cursor with a page size of 50', () => {
|
||||
expect(memberListParams('p1', { query: 'li', roleId: 'r-qa', cursor: 'n1' }))
|
||||
.toEqual({ projectId: 'p1', query: 'li', roleId: 'r-qa', cursor: 'n1', limit: MEMBER_PAGE_LIMIT })
|
||||
expect(MEMBER_PAGE_LIMIT).toBe(50)
|
||||
})
|
||||
|
||||
it('loads the unfiltered first page when no filter is set', () => {
|
||||
expect(memberListParams('p1', { query: '', roleId: '', cursor: '' })).toEqual({ projectId: 'p1', limit: 50 })
|
||||
})
|
||||
|
||||
it('forwards the optional match and projectRole filters', () => {
|
||||
expect(memberListParams('p1', { match: 'exact', projectRole: 'owner', limit: 10 }))
|
||||
.toEqual({ projectId: 'p1', limit: 10, match: 'exact', projectRole: 'owner' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('roleOptionsPayload', () => {
|
||||
it('asks for member-purpose role options', () => {
|
||||
expect(roleOptionsPayload('p1')).toEqual({ projectId: 'p1', purpose: 'members' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('accountsToPickItems', () => {
|
||||
it('keys candidates by stable userId and drops rows without one', () => {
|
||||
const items = accountsToPickItems([
|
||||
{ userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: false },
|
||||
{ userId: '', owner: 'anon', valid: true, joined: false },
|
||||
])
|
||||
expect(items).toEqual([{ id: 'u-a', owner: 'a', title: '甲', subtitle: 'a' }])
|
||||
})
|
||||
|
||||
it('shows invalid accounts as not selectable', () => {
|
||||
const [it] = accountsToPickItems([{ userId: 'u-g', owner: 'gone', name: '离职', valid: false, joined: false }])
|
||||
expect(it).toMatchObject({ id: 'u-g', disabled: true, disabledReason: INVALID_REASON })
|
||||
})
|
||||
|
||||
it('disables joined candidates with the 已加入项目 reason instead of a badge', () => {
|
||||
const items = accountsToPickItems([
|
||||
{ userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: true },
|
||||
{ userId: 'u-b', owner: 'b', valid: true, joined: false },
|
||||
{ userId: 'u-c', owner: 'c', name: '丙', valid: true },
|
||||
], ['b'])
|
||||
expect(items).toEqual([
|
||||
{ id: 'u-a', owner: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON },
|
||||
{ id: 'u-b', owner: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' },
|
||||
{ id: 'u-c', owner: 'c', title: '丙', subtitle: 'c' },
|
||||
])
|
||||
expect(items.some(it => 'badge' in it)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps name as title and account as subtitle so the selection shows both', () => {
|
||||
const [it] = accountsToPickItems([{ userId: 'u-z', owner: 'zhangsan', name: '张三', valid: true, joined: false }])
|
||||
expect(it.title).toBe('张三')
|
||||
expect(it.subtitle).toBe('zhangsan')
|
||||
})
|
||||
|
||||
it('tolerates a missing account list', () => {
|
||||
expect(accountsToPickItems(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('personLabel', () => {
|
||||
it('renders name · account', () => {
|
||||
expect(personLabel({ owner: 'zhangsan', name: '张三' })).toBe('张三 · zhangsan')
|
||||
})
|
||||
|
||||
it('falls back to the account when there is no name', () => {
|
||||
expect(personLabel({ owner: 'zhangsan' })).toBe('zhangsan')
|
||||
expect(personLabel({ owner: 'zhangsan', name: ' ' })).toBe('zhangsan')
|
||||
expect(personLabel({ owner: 'zhangsan', name: 'zhangsan' })).toBe('zhangsan')
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendMemberPage', () => {
|
||||
it('appends the next page in order without duplicating accounts', () => {
|
||||
const first = [row('a', '甲'), row('b')]
|
||||
const got = appendMemberPage(first, [row('b', '乙-重复'), row('c', '丙')])
|
||||
expect(got.map(m => m.owner)).toEqual(['a', 'b', 'c'])
|
||||
expect(got[1].name).toBeUndefined()
|
||||
expect(first.map(m => m.owner)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('starts a fresh list from null and dedupes within a page', () => {
|
||||
expect(appendMemberPage(null, [row('a'), row('a')]).map(m => m.owner)).toEqual(['a'])
|
||||
expect(appendMemberPage(null, undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rdm.role.options command gating', () => {
|
||||
it('is a project read command, so archived projects can still load role filters', () => {
|
||||
const archived = { id: 'p1', settings: { 'rdm.state': 'archived', 'rdm.projectId': 'rdm-1' } } as unknown as Project
|
||||
expect(rdmProjectCommandError(archived, 'rdm.role.options')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not gate member page readiness', () => {
|
||||
expect(RDM_MEMBER_COMMANDS).not.toContain('rdm.role.options')
|
||||
expect(rdmMembersReady(RDM_MEMBER_COMMANDS.map(id => ({ id, enabled: true })))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
// U7 法规结构化引用:标记解析与编号、核验状态文案与样式、原文省略原因、审计树请求参数,
|
||||
// 以及正文渲染不经 HTML(契约:Dev 仓 specs/2026-10-18-regulations-citation-contract.md)。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import {
|
||||
CITATION_CHIP_TONE, citationListOrder, citationMarkText, citationStateLabel, citationStyle,
|
||||
excerptView, parseCitedBlocks, redactionLabel, regsTreeParams, safeWebHref, staleLabel,
|
||||
type CitationView,
|
||||
} from '../src/regCitations'
|
||||
import { CitationCard, CitedBody } from '../src/components/RegCitation'
|
||||
|
||||
const A = 'cit-0123456789ab'
|
||||
const B = 'cit-abcdef012345'
|
||||
const C = 'cit-ffffffffffff'
|
||||
|
||||
const cite = (id: string, over: Partial<CitationView> = {}): CitationView => ({
|
||||
id, batchId: 'rb-1', state: 'verified', docNo: 'GB 9706.1-2020', clauseNo: '4.2',
|
||||
title: '医用电气设备 第1部分', editionLabel: '2020 版', legalStatus: '现行', corpusRevision: 'rev-7',
|
||||
headingPath: ['4 通用要求', '4.2 风险管理'], excerpt: '应实施风险管理过程。', disclosure: 'excerpt', ...over,
|
||||
})
|
||||
|
||||
describe('parseCitedBlocks:标记解析与编号', () => {
|
||||
it('多个标记按首次出现编号,文本原样切分', () => {
|
||||
const { blocks, order } = parseCitedBlocks([`甲[${A}]乙[${B}]丙`])
|
||||
expect(order).toEqual([A, B])
|
||||
expect(blocks[0]).toEqual([
|
||||
{ kind: 'text', text: '甲' },
|
||||
{ kind: 'cite', id: A, n: 1 },
|
||||
{ kind: 'text', text: '乙' },
|
||||
{ kind: 'cite', id: B, n: 2 },
|
||||
{ kind: 'text', text: '丙' },
|
||||
])
|
||||
})
|
||||
|
||||
it('重复标记复用编号,编号跨段连续', () => {
|
||||
const { blocks, order } = parseCitedBlocks([`[${B}]开头[${A}]`, `再引[${B}]与[${C}][${A}]`])
|
||||
expect(order).toEqual([B, A, C])
|
||||
const nums = blocks.map(b => b.flatMap(s => (s.kind === 'cite' ? [`${s.id}:${s.n}`] : [])))
|
||||
expect(nums).toEqual([[`${B}:1`, `${A}:2`], [`${B}:1`, `${C}:3`, `${A}:2`]])
|
||||
})
|
||||
|
||||
it('未知 ID 也按出现顺序编号(渲染时判为无效引用)', () => {
|
||||
const { order } = parseCitedBlocks([`[${C}]`])
|
||||
expect(order).toEqual([C])
|
||||
})
|
||||
|
||||
it('没有标记的文本原样返回单个文本片段;空文本没有片段', () => {
|
||||
const { blocks, order } = parseCitedBlocks(['普通正文 [slc-1] 不变', ''])
|
||||
expect(order).toEqual([])
|
||||
expect(blocks).toEqual([[{ kind: 'text', text: '普通正文 [slc-1] 不变' }], []])
|
||||
})
|
||||
|
||||
it('形似标记但 ID 不合规的文本不解析', () => {
|
||||
const texts = [
|
||||
'[cit-0123456789a]', // 11 位
|
||||
'[cit-0123456789abc]', // 13 位
|
||||
'[cit-0123456789AB]', // 大写
|
||||
'[cit-0123456789ag]', // 非十六进制
|
||||
'cit-0123456789ab', // 无方括号
|
||||
'[cit 0123456789ab]',
|
||||
'[CIT-0123456789ab]',
|
||||
]
|
||||
const { blocks, order } = parseCitedBlocks(texts)
|
||||
expect(order).toEqual([])
|
||||
blocks.forEach((b, i) => expect(b).toEqual([{ kind: 'text', text: texts[i] }]))
|
||||
})
|
||||
|
||||
it('嵌套方括号只取合规的内层标记', () => {
|
||||
const { blocks } = parseCitedBlocks([`[[${A}]]`])
|
||||
expect(blocks[0]).toEqual([{ kind: 'text', text: '[' }, { kind: 'cite', id: A, n: 1 }, { kind: 'text', text: ']' }])
|
||||
})
|
||||
|
||||
it('引用清单:正文编号在前,未出现在正文的引用续编', () => {
|
||||
expect(citationListOrder([B, C], [cite(A), cite(B)])).toEqual([B, C, A])
|
||||
expect(citationListOrder([], null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('核验状态文案与样式', () => {
|
||||
it('八种状态的中文文案;未知状态原样显示', () => {
|
||||
expect(['verified', 'single_source', 'degraded', 'edition_unresolved', 'conflict', 'unverified', 'not_applicable', 'invalid']
|
||||
.map(citationStateLabel))
|
||||
.toEqual(['已核验', '单一来源', '降级', '版本未定', '冲突', '未核验', '不适用', '无效引用'])
|
||||
expect(citationStateLabel('mystery')).toBe('mystery')
|
||||
expect(citationStateLabel(undefined)).toBe('未核验')
|
||||
})
|
||||
|
||||
it('仅 verified 为有效样式;invalid 与缺失 ID 为无效样式;其余为未核验样式', () => {
|
||||
expect(citationStyle({ state: 'verified' })).toBe('valid')
|
||||
for (const s of ['single_source', 'degraded', 'edition_unresolved', 'conflict', 'unverified', 'not_applicable', 'mystery']) {
|
||||
expect(citationStyle({ state: s })).toBe('unverified')
|
||||
}
|
||||
expect(citationStyle({ state: 'invalid' })).toBe('invalid')
|
||||
expect(citationStyle(undefined)).toBe('invalid')
|
||||
expect(CITATION_CHIP_TONE).toEqual({ valid: 'ok', unverified: 'warn', invalid: 'danger' })
|
||||
})
|
||||
|
||||
it('stale 的 verified 不再呈现为有效依据,显示「待复核」及原因', () => {
|
||||
expect(citationStyle({ state: 'verified', stale: true })).toBe('unverified')
|
||||
expect(staleLabel({ stale: false })).toBeNull()
|
||||
expect(staleLabel({ stale: true })).toBe('待复核')
|
||||
expect(staleLabel({ stale: true, staleReason: 'node_text_changed' })).toBe('待复核(条款文本已变更)')
|
||||
expect(staleLabel({ stale: true, staleReason: 'legal_status_changed' })).toBe('待复核(效力状态已变更)')
|
||||
expect(staleLabel({ stale: true, staleReason: 'edition_superseded' })).toBe('待复核(版本已被替代)')
|
||||
expect(staleLabel({ stale: true, staleReason: 'other_reason' })).toBe('待复核(other_reason)')
|
||||
})
|
||||
|
||||
it('标记文字:有效只给编号,其余附状态文案', () => {
|
||||
expect(citationMarkText(1, cite(A))).toBe('1')
|
||||
expect(citationMarkText(2, cite(A, { state: 'single_source' }))).toBe('2·单一来源')
|
||||
expect(citationMarkText(3, cite(A, { stale: true }))).toBe('3·待复核')
|
||||
expect(citationMarkText(4, cite(A, { state: 'invalid' }))).toBe('4·无效引用')
|
||||
expect(citationMarkText(5, undefined)).toBe('5·无效引用')
|
||||
})
|
||||
})
|
||||
|
||||
describe('原文省略原因', () => {
|
||||
it('已知原因中文化;未知原因原样;缺省给通用说明', () => {
|
||||
expect(redactionLabel('withheld_by_rights')).toBe('受版权限制')
|
||||
expect(redactionLabel('metadata_only')).toBe('仅元数据')
|
||||
expect(redactionLabel('no_source_text_permission')).toBe('无原文查看权限')
|
||||
expect(redactionLabel('source_withdrawn')).toBe('来源已撤回')
|
||||
expect(redactionLabel('court_order')).toBe('court_order')
|
||||
expect(redactionLabel(undefined)).toBe('未提供原文')
|
||||
})
|
||||
|
||||
it('有片段给片段;省略时给原因', () => {
|
||||
expect(excerptView({ excerpt: '原文' })).toEqual({ excerpt: '原文' })
|
||||
expect(excerptView({ redactionReason: 'withheld_by_rights' })).toEqual({ redaction: '受版权限制' })
|
||||
})
|
||||
|
||||
it('只有 http(s) 地址可作外链', () => {
|
||||
expect(safeWebHref('https://www.nmpa.gov.cn/x')).toBe('https://www.nmpa.gov.cn/x')
|
||||
expect(safeWebHref('javascript:alert(1)')).toBeNull()
|
||||
expect(safeWebHref(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('regsTreeParams:rdm.inspect.tree kind=regs 请求参数', () => {
|
||||
it('根层:kind=regs、空游标、无 parent', () => {
|
||||
expect(regsTreeParams('p1')).toEqual({ projectId: 'p1', kind: 'regs', cursor: '' })
|
||||
})
|
||||
it('分页沿用游标', () => {
|
||||
expect(regsTreeParams('p1', { cursor: 'c2' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: 'c2' })
|
||||
})
|
||||
it('展开证据批:parent=<batchId>', () => {
|
||||
expect(regsTreeParams('p1', { parent: 'rb-1' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: '', parent: 'rb-1' })
|
||||
expect(regsTreeParams('p1', { parent: 'rb-1', cursor: 'c3' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: 'c3', parent: 'rb-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('引用呈现', () => {
|
||||
it('正文标记编号渲染,未知与 invalid 显示无效引用,正文不作 HTML', () => {
|
||||
const html = renderToStaticMarkup(createElement(CitedBody, {
|
||||
texts: [`<b>粗</b>甲[${A}]乙[${B}]丙[${C}]`, `再引[${A}]`],
|
||||
citations: [cite(A), cite(B, { state: 'invalid' })],
|
||||
scope: 't',
|
||||
}))
|
||||
expect(html).toContain('<b>粗</b>甲')
|
||||
expect(html).not.toContain('<b>')
|
||||
expect(html).toContain('regcite-mark regcite-valid')
|
||||
expect(html).toContain('[1]')
|
||||
expect(html).toContain('[2·无效引用]')
|
||||
expect(html).toContain('[3·无效引用]')
|
||||
expect(html.match(/regcite-mark regcite-valid/g)).toHaveLength(2)
|
||||
expect(html).toContain('引用(3)')
|
||||
expect(html).not.toContain('<details')
|
||||
})
|
||||
|
||||
it('无标记且无引用时正文原样,不出现引用清单', () => {
|
||||
const html = renderToStaticMarkup(createElement(CitedBody, { texts: ['普通 [cit-xyz] 正文'], citations: undefined, scope: 't' }))
|
||||
expect(html).toBe('<p class="cockpit-reader-para">普通 [cit-xyz] 正文</p>')
|
||||
})
|
||||
|
||||
it('引用卡片:省略原文时显示中文原因;非 verified 标注未核验', () => {
|
||||
const html = renderToStaticMarkup(createElement(CitationCard, {
|
||||
id: A, scope: 't',
|
||||
citation: cite(A, { state: 'conflict', excerpt: undefined, redactionReason: 'no_source_text_permission', stale: true, staleReason: 'edition_superseded' }),
|
||||
}))
|
||||
expect(html).toContain('regcite-card-unverified')
|
||||
expect(html).toContain('冲突')
|
||||
expect(html).toContain('待复核(版本已被替代)')
|
||||
expect(html).toContain('未核验引用')
|
||||
expect(html).toContain('原文片段未展示:无原文查看权限')
|
||||
expect(html).toContain('GB 9706.1-2020')
|
||||
expect(html).toContain('4 通用要求 › 4.2 风险管理')
|
||||
})
|
||||
|
||||
it('引用卡片:verified 为有效样式,原文片段在折叠区内;缺失 ID 为无效卡片', () => {
|
||||
const ok = renderToStaticMarkup(createElement(CitationCard, { id: A, scope: 't', citation: cite(A) }))
|
||||
expect(ok).toContain('regcite-card-valid')
|
||||
expect(ok).toContain('mk-col')
|
||||
expect(ok).not.toContain('未核验引用')
|
||||
const missing = renderToStaticMarkup(createElement(CitationCard, { id: C, scope: 't' }))
|
||||
expect(missing).toContain('regcite-card-invalid')
|
||||
expect(missing).toContain('无效引用')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
// 批注范围锚定(06 §2:nodeId + textStart + textEnd + textHash;10 §5「批注范围准确」)。
|
||||
// RDM 契约:偏移是冻结节点文本上的 Unicode 码点(Go rune = JS Array.from 下标)半开区间;
|
||||
// quote 与范围同发时须等于范围原文;回复不带范围;textHash/range 永不由前端发送。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import {
|
||||
annotatePayload, codePointRange, describeRenderFailure, normalizeReviewView, partitionAnnotations, selectionAnchor,
|
||||
} from '../src/documentRenderPresentation'
|
||||
import { AnnotationGroups, AnnotationScope } from '../src/components/DocumentRenderWorkspace'
|
||||
|
||||
describe('码点偏移(UTF-16 选区 → RDM rune 偏移)', () => {
|
||||
it('ASCII:半开区间,参数顺序无关,越界裁剪', () => {
|
||||
expect(codePointRange('Hello world', 6, 11)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
|
||||
expect(codePointRange('Hello world', 11, 6)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
|
||||
expect(codePointRange('Hello world', 6, 99)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
|
||||
})
|
||||
it('首尾空白不计入,纯空白或空选区不记录范围', () => {
|
||||
expect(codePointRange('Hello world', 5, 11)).toEqual({ textStart: 6, textEnd: 11, quote: 'world' })
|
||||
expect(codePointRange('Hello world', 5, 6)).toBeUndefined()
|
||||
expect(codePointRange('Hello world', 3, 3)).toBeUndefined()
|
||||
})
|
||||
it('CJK:与契约示例一致("审阅正文 Review" 中 Review 为 5..11)', () => {
|
||||
expect(codePointRange('审阅正文 Review', 5, 11)).toEqual({ textStart: 5, textEnd: 11, quote: 'Review' })
|
||||
expect(codePointRange('项目内容、目标及意义', 5, 7)).toEqual({ textStart: 5, textEnd: 7, quote: '目标' })
|
||||
})
|
||||
it('emoji 代理对:UTF-16 下标换算为码点,后续字符不错位', () => {
|
||||
const text = '审阅👍正文 Review'
|
||||
expect(text.indexOf('Review')).toBe(7)
|
||||
expect(codePointRange(text, 7, 13)).toEqual({ textStart: 6, textEnd: 12, quote: 'Review' })
|
||||
expect(Array.from(text).slice(6, 12).join('')).toBe('Review')
|
||||
expect(codePointRange(text, 2, 4)).toEqual({ textStart: 2, textEnd: 3, quote: '👍' })
|
||||
})
|
||||
it('端点落在代理对中间时外扩为完整字符,绝不发出半个字符', () => {
|
||||
const text = '审阅👍正文'
|
||||
expect(codePointRange(text, 3, 5)).toEqual({ textStart: 2, textEnd: 4, quote: '👍正' })
|
||||
expect(codePointRange(text, 0, 3)).toEqual({ textStart: 0, textEnd: 3, quote: '审阅👍' })
|
||||
})
|
||||
it('ZWJ 组合 emoji 按码点(rune)计数而非字形', () => {
|
||||
const family = '👨\u200d👩\u200d👧'
|
||||
const text = `a${family}b`
|
||||
expect(codePointRange(text, 1, 1 + family.length)).toEqual({ textStart: 1, textEnd: 6, quote: family })
|
||||
})
|
||||
})
|
||||
|
||||
describe('阅读视图选区判定', () => {
|
||||
const hit = (nodeId: string, text: string, start: number, end: number) => ({ nodeId, text, start, end })
|
||||
it('无选区或折叠:不记录', () => {
|
||||
expect(selectionAnchor(null)).toEqual({ kind: 'none' })
|
||||
})
|
||||
it('落在单个节点正文内:记录该节点的码点范围', () => {
|
||||
expect(selectionAnchor([hit('dn-head', '项目内容、目标及意义', 5, 7)]))
|
||||
.toEqual({ kind: 'range', anchor: { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' } })
|
||||
})
|
||||
it('只擦到下一节点起点(零长度交集)仍算单节点', () => {
|
||||
expect(selectionAnchor([hit('a', 'Alpha', 0, 5), hit('b', 'Beta', 0, 0)]))
|
||||
.toEqual({ kind: 'range', anchor: { nodeId: 'a', textStart: 0, textEnd: 5, quote: 'Alpha' } })
|
||||
})
|
||||
it('跨越多个节点:说明原因,不记录范围(提交批注整个节点)', () => {
|
||||
const r = selectionAnchor([hit('a', 'Alpha', 2, 5), hit('b', 'Beta', 0, 2)])
|
||||
expect(r.kind).toBe('multi-node')
|
||||
expect(r.kind === 'multi-node' && r.notice).toContain('跨越多个节点')
|
||||
expect(r.kind === 'multi-node' && r.notice).toContain('批注整个节点')
|
||||
})
|
||||
it('选区不含任何节点正文(或只有空白):说明原因,不记录范围', () => {
|
||||
expect(selectionAnchor([]).kind).toBe('outside-text')
|
||||
expect(selectionAnchor([hit('a', 'A b', 1, 2)]).kind).toBe('outside-text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rdm.render.review.annotate 载荷', () => {
|
||||
const base = { projectId: 'p', submissionId: 'sub', treeNodeId: 'dn-head', text: ' 批 ', operationId: 'op' }
|
||||
const range = { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' }
|
||||
it('有选区:发送 textStart/textEnd/quote,绝不发送 textHash 或 range', () => {
|
||||
const p = annotatePayload({ ...base, range })
|
||||
expect(p).toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 'dn-head', text: '批', operationId: 'op', textStart: 5, textEnd: 7, quote: '目标' })
|
||||
expect(p).not.toHaveProperty('textHash')
|
||||
expect(p).not.toHaveProperty('range')
|
||||
})
|
||||
it('无选区:整节点批注,不带任何范围参数', () => {
|
||||
for (const p of [annotatePayload(base), annotatePayload({ ...base, range: null })]) {
|
||||
expect(p).toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 'dn-head', text: '批', operationId: 'op' })
|
||||
}
|
||||
})
|
||||
it('回复:即使持有选区也不带范围(继承父批注锚点)', () => {
|
||||
const p = annotatePayload({ ...base, replyTo: 'a1', range })
|
||||
expect(p).toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 'dn-head', text: '批', operationId: 'op', replyTo: 'a1' })
|
||||
for (const k of ['textStart', 'textEnd', 'quote', 'textHash', 'range']) expect(p).not.toHaveProperty(k)
|
||||
})
|
||||
it('范围不属于目标节点时不发送(绝不把 A 节点的偏移挂到 B 节点)', () => {
|
||||
expect(annotatePayload({ ...base, range: { ...range, nodeId: 'dn-other' } })).not.toHaveProperty('textStart')
|
||||
})
|
||||
})
|
||||
|
||||
describe('审阅视图:摘录与需重新锚定', () => {
|
||||
const person = { id: 'u1', displayName: '审核人' }
|
||||
const view = normalizeReviewView({
|
||||
annotations: [
|
||||
{ id: 'a1', nodeId: 'pp.goal', treeNodeId: 'dn-head', author: person, createdAt: '', text: '精确锚定', quote: '目标', needsReanchor: false,
|
||||
range: { start: 5, end: 7, textHash: 'h' }, replies: [{ id: 'r1', nodeId: 'pp.goal', treeNodeId: 'dn-head', author: person, createdAt: '', text: '回复', quote: '目标' }] },
|
||||
{ id: 'a2', nodeId: 'pp.goal', treeNodeId: 'dn-head', author: person, createdAt: '', text: '漂移了', quote: '旧目标', needsReanchor: true, replies: [] },
|
||||
{ id: 'a3', nodeId: 'pp.sig', treeNodeId: 'dn-head', author: person, createdAt: '', text: '节点级', replies: [] },
|
||||
],
|
||||
})
|
||||
it('归一化保留 quote 与 needsReanchor;缺省不补造', () => {
|
||||
expect(view.annotations[0]).toMatchObject({ quote: '目标' })
|
||||
expect(view.annotations[0]).not.toHaveProperty('needsReanchor')
|
||||
expect(view.annotations[1]).toMatchObject({ quote: '旧目标', needsReanchor: true })
|
||||
expect(view.annotations[2]).not.toHaveProperty('quote')
|
||||
})
|
||||
it('批注项显示摘录;需重新锚定时有可见状态与说明', () => {
|
||||
const { byNode, orphaned } = partitionAnnotations(view.annotations, new Set(['dn-head']))
|
||||
const html = renderToStaticMarkup(createElement(AnnotationGroups, { byNode, orphaned, nodeTitle: () => '项目内容、目标及意义', onSelectNode: () => {} }))
|
||||
expect(html).toContain('<blockquote class="drw-anno-quote" title="目标">目标</blockquote>')
|
||||
expect(html).toContain('<blockquote class="drw-anno-quote" title="旧目标">旧目标</blockquote>')
|
||||
expect(html.match(/需重新锚定/g)).toHaveLength(1)
|
||||
expect(html).toContain('drw-anno needs-reanchor')
|
||||
expect(html).toContain('上方为旧摘录')
|
||||
expect(html.match(/drw-anno-quote/g)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('撰写区的批注范围', () => {
|
||||
const render = (props: Partial<Parameters<typeof AnnotationScope>[0]>) =>
|
||||
renderToStaticMarkup(createElement(AnnotationScope, { anchor: null, notice: '', replying: false, onClear: () => {}, ...props }))
|
||||
it('有选区:显示摘录、码点位置与改为整个节点', () => {
|
||||
const html = render({ anchor: { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' } })
|
||||
expect(html).toContain('「目标」')
|
||||
expect(html).toContain('第 6–7 字')
|
||||
expect(html).toContain('改为整个节点')
|
||||
})
|
||||
it('无选区:明示整个节点', () => {
|
||||
expect(render({})).toContain('整个节点(在阅读视图中选中该节点的文字')
|
||||
})
|
||||
it('选区无效:显示说明', () => {
|
||||
expect(render({ notice: '所选文字跨越多个节点' })).toContain('role="status">所选文字跨越多个节点')
|
||||
})
|
||||
it('回复:沿用原批注范围,不显示选区', () => {
|
||||
const html = render({ replying: true, anchor: { nodeId: 'dn-head', textStart: 5, textEnd: 7, quote: '目标' } })
|
||||
expect(html).toContain('回复沿用原批注的范围')
|
||||
expect(html).not.toContain('目标')
|
||||
})
|
||||
})
|
||||
|
||||
describe('review_anchor_invalid: 转为中文可读文案(原始回执保留)', () => {
|
||||
const cases: [string, string][] = [
|
||||
['review_anchor_invalid: node pp.sig (image) has no frozen text a range can anchor; address a paragraph, heading, list item or table cell', '没有可按范围批注的正文'],
|
||||
['review_anchor_invalid: quote does not match the frozen text of node pp.goal at 5..7', '与提交审阅时冻结的正文不一致'],
|
||||
['review_anchor_invalid: quote occurs more than once in node pp.goal; supply textStart and textEnd', '出现多次'],
|
||||
['review_anchor_invalid: quote is not in the frozen text of node pp.goal', '不在该节点冻结的正文中'],
|
||||
['review_anchor_invalid: textStart/textEnd 5..99 out of range for node pp.goal (10 characters)', '超出该节点冻结正文的长度'],
|
||||
['review_anchor_invalid: reply must address the parent\'s node pp.goal; replies inherit the parent\'s anchor', '回复只能针对原批注所在的节点'],
|
||||
['textStart and textEnd must be supplied together', '起止位置须成对提供'],
|
||||
['review_anchor_invalid: something new', '批注范围无效'],
|
||||
]
|
||||
it.each(cases)('%s', (msg, expected) => {
|
||||
for (const raw of [msg, `400: ${JSON.stringify({ error: msg })}`]) {
|
||||
const f = describeRenderFailure(new Error(raw))
|
||||
expect(f.message).toContain(expected)
|
||||
expect(f.message).not.toContain('review_anchor_invalid')
|
||||
expect(f.raw).toBe(raw)
|
||||
}
|
||||
})
|
||||
it('非范围错误不受影响', () => {
|
||||
expect(describeRenderFailure(new Error('当前编制人不是你')).message).toBe('当前编制人不是你')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PermissionDescriptor, RoleRecord, UserRoleAssignmentPreview } from '../src/api'
|
||||
import {
|
||||
assignedToRole, clearSelection, copyAssignRequest, createSelection, memberAssignments, pageSelectionState, previewAllowsAssign,
|
||||
previewTransition, refreshStateVersions, roleSourceValues, selectPage, selectionCounts, selectionSummary, staleUserIds, toggleOne,
|
||||
} from '../src/roleMembers'
|
||||
import { stateToValue } from '../src/rolePermissions'
|
||||
|
||||
const catalog: PermissionDescriptor[] = [
|
||||
{ actionId: 'system.roles.view', label: '查看角色', description: '', group: '角色管理', scope: 'system', protected: true },
|
||||
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', category: '项目与协作', scope: 'system', protected: false },
|
||||
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
]
|
||||
const source: RoleRecord = {
|
||||
roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 7, bindingCount: 3,
|
||||
permissions: [{ actionId: 'document.read', allowed: true, capAllowed: true, permissionVersion: 1 }],
|
||||
}
|
||||
const preview = (userId: string, patch: Partial<UserRoleAssignmentPreview> = {}): UserRoleAssignmentPreview => ({
|
||||
userId, owner: userId, name: '', currentRoleId: '', currentRoleName: '', targetRoleId: 'role_engineer', targetRoleName: '软件工程师',
|
||||
stateMatches: true, ...patch,
|
||||
})
|
||||
|
||||
describe('cross-page member selection', () => {
|
||||
it('accumulates explicit picks across pages and counts the ones beyond the current page', () => {
|
||||
let selection = toggleOne(createSelection(), 'u1')
|
||||
selection = toggleOne(selection, 'u2')
|
||||
const pageTwo = ['u3', 'u4']
|
||||
selection = selectPage(selection, pageTwo, true)
|
||||
expect(selection.ids).toEqual(['u1', 'u2', 'u3', 'u4'])
|
||||
expect(selectionCounts(selection, pageTwo)).toEqual({ count: 4, beyondPage: 2 })
|
||||
expect(selectionSummary(selectionCounts(selection, pageTwo))).toBe('已选 4 人(包含其他页的 2 人)')
|
||||
expect(selectionSummary(selectionCounts(selection, ['u1', 'u2', 'u3', 'u4']))).toBe('已选 4 人')
|
||||
})
|
||||
|
||||
it('page select-all only touches the current page and reports a partial page', () => {
|
||||
const selection = selectPage(createSelection(['u9']), ['u1', 'u2'], true)
|
||||
expect(pageSelectionState(selection, ['u1', 'u2'])).toBe('all')
|
||||
const unpicked = selectPage(selection, ['u1', 'u2'], false)
|
||||
expect(unpicked.ids).toEqual(['u9'])
|
||||
expect(pageSelectionState(toggleOne(selection, 'u2', false), ['u1', 'u2'])).toBe('some')
|
||||
expect(pageSelectionState(unpicked, [])).toBe('none')
|
||||
})
|
||||
|
||||
it('keeps the selection when filters change and never widens it to unselected matches', () => {
|
||||
const selection = toggleOne(createSelection(), 'u1')
|
||||
const filteredPage = ['u5', 'u6']
|
||||
expect(selectionCounts(selection, filteredPage)).toEqual({ count: 1, beyondPage: 1 })
|
||||
expect(selection.ids).toEqual(['u1'])
|
||||
expect(toggleOne(selection, 'u1', true)).toBe(selection)
|
||||
expect(toggleOne(selection, 'u7', false)).toBe(selection)
|
||||
})
|
||||
|
||||
it('clears to an empty selection', () => {
|
||||
expect(clearSelection().ids).toEqual([])
|
||||
expect(selectionCounts(clearSelection(), ['u1'])).toEqual({ count: 0, beyondPage: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('member assignment entries', () => {
|
||||
it('submits stable userId/roleId pairs with each user\'s own state version, once per user', () => {
|
||||
expect(memberAssignments(['u1', 'u2', 'u1'], 'role_engineer', { u1: 3, u2: 8 })).toEqual([
|
||||
{ userId: 'u1', roleId: 'role_engineer', expectedStateVersion: 3 },
|
||||
{ userId: 'u2', roleId: 'role_engineer', expectedStateVersion: 8 },
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses to build entries without a target role or a known state version', () => {
|
||||
expect(() => memberAssignments(['u1'], '', { u1: 3 })).toThrow('目标角色')
|
||||
expect(() => memberAssignments(['u1', 'u2'], 'role_engineer', { u1: 3 })).toThrow('状态版本')
|
||||
})
|
||||
|
||||
it('allows confirmation only when every submitted user previews cleanly', () => {
|
||||
expect(previewAllowsAssign([preview('u1'), preview('u2')], ['u1', 'u2'])).toBe(true)
|
||||
expect(previewAllowsAssign([preview('u1')], ['u1', 'u2'])).toBe(false)
|
||||
expect(previewAllowsAssign([preview('u1', { error: '用户已停用' })], ['u1'])).toBe(false)
|
||||
expect(previewAllowsAssign([preview('u1', { stateMatches: false })], ['u1'])).toBe(false)
|
||||
expect(previewAllowsAssign([], [])).toBe(false)
|
||||
expect(staleUserIds([preview('u1', { stateMatches: false }), preview('u2')])).toEqual(['u1'])
|
||||
expect(previewTransition(preview('u1'))).toBe('未分配 → 软件工程师')
|
||||
expect(previewTransition(preview('u1', { currentRoleName: '审核工程师' }))).toBe('审核工程师 → 软件工程师')
|
||||
})
|
||||
|
||||
it('re-reads only stale users through the read-only detail endpoint', async () => {
|
||||
const adminGetUser = vi.fn(async (userId: string) => ({ userId, stateVersion: 12 }))
|
||||
expect(await refreshStateVersions({ adminGetUser }, ['u1', 'u1'])).toEqual({ u1: { userId: 'u1', stateVersion: 12 } })
|
||||
expect(adminGetUser).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('marks directory results already bound to the role', () => {
|
||||
expect(assignedToRole({ role: { roleId: 'role_engineer', name: '软件工程师', enabled: true } }, 'role_engineer')).toBe(true)
|
||||
expect(assignedToRole({ role: null }, 'role_engineer')).toBe(false)
|
||||
expect(assignedToRole({}, 'role_engineer')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('copy role and assign request', () => {
|
||||
const base = {
|
||||
idempotencyKey: 'key-1', source, description: ' 独立维护 ', catalog,
|
||||
users: [{ userId: 'u1', stateVersion: 4 }],
|
||||
}
|
||||
|
||||
it('requires an explicit non-empty new role name', () => {
|
||||
expect(() => copyAssignRequest({ ...base, name: ' ', values: {} })).toThrow('名称')
|
||||
expect(() => copyAssignRequest({ ...base, name: '新角色', values: {}, users: [] })).toThrow('用户')
|
||||
})
|
||||
|
||||
it('sends only actions whose value differs from the source role', () => {
|
||||
const request = copyAssignRequest({ ...base, name: ' 软件工程师(外包) ', values: {
|
||||
'document.read': stateToValue('default'),
|
||||
'document.author': stateToValue('project'),
|
||||
'project.create': stateToValue('denied'),
|
||||
} })
|
||||
expect(request).toEqual({
|
||||
idempotencyKey: 'key-1',
|
||||
source: { roleId: 'role_engineer', expectedRoleVersion: 7 },
|
||||
name: '软件工程师(外包)', description: '独立维护',
|
||||
overrides: [{ actionId: 'document.author', allowed: false, capAllowed: true }],
|
||||
assignments: [{ userId: 'u1', expectedStateVersion: 4 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits overrides entirely when nothing was adjusted', () => {
|
||||
const request = copyAssignRequest({ ...base, name: '副本', values: { 'document.read': stateToValue('default') } })
|
||||
expect(request).not.toHaveProperty('overrides')
|
||||
expect(request.assignments[0]).not.toHaveProperty('roleId')
|
||||
})
|
||||
|
||||
it('treats a role projection without permissions as an empty baseline instead of throwing', () => {
|
||||
expect(() => roleSourceValues({ permissions: undefined } as any, catalog)).not.toThrow()
|
||||
expect(roleSourceValues({ permissions: undefined } as any, catalog)).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PermissionDescriptor, RolePermissionPreview, RoleRecord } from '../src/api'
|
||||
import {
|
||||
NARROW_ROLE_QUERY, cellKind, commitRoleBatch, editRoleDraft, filterRoles, hasSystemAction, initialRoleView, memberStatusLabel, previewRoleBatch,
|
||||
rebaseRoleDrafts, roleCellValue, roleLeaveMessage, roleMembersLabel, shouldAcceptRoleResponse,
|
||||
} from '../src/components/RolePermissionsPanel'
|
||||
import { categoryActions, dirtyRoles, rolePatchEntries, stateToValue } from '../src/rolePermissions'
|
||||
|
||||
const roles: RoleRecord[] = [
|
||||
{ roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 1, bindingCount: 2, permissions: [] },
|
||||
{ roleId: 'role_reviewer', name: '审核工程师', description: '质量', enabled: false, roleVersion: 1, bindingCount: 1, permissions: [] },
|
||||
]
|
||||
|
||||
const catalog: PermissionDescriptor[] = [
|
||||
{ actionId: 'system.roles.view', label: '查看角色', description: '', group: '角色管理', scope: 'system', protected: true },
|
||||
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', category: '项目与协作', scope: 'system', protected: false },
|
||||
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
]
|
||||
const editableIds = catalog.filter(item => !item.protected).map(item => item.actionId)
|
||||
const versions = (drafts: Record<string, { role: RoleRecord }>) =>
|
||||
Object.fromEntries(Object.entries(drafts).map(([roleId, draft]) => [roleId, draft.role.roleVersion]))
|
||||
|
||||
describe('role permission UI helpers', () => {
|
||||
it('combines exact or fuzzy search with the role status filter', () => {
|
||||
expect(filterRoles(roles, '软件工程师', 'exact', 'enabled').map(role => role.roleId)).toEqual(['role_engineer'])
|
||||
expect(filterRoles(roles, '工程师', 'fuzzy', 'all').map(role => role.roleId)).toEqual(['role_engineer', 'role_reviewer'])
|
||||
expect(filterRoles(roles, '', 'fuzzy', 'disabled').map(role => role.roleId)).toEqual(['role_reviewer'])
|
||||
})
|
||||
|
||||
it('shows one business category of matrix columns at a time, keeping stable action IDs and skipping protected grants', () => {
|
||||
expect(categoryActions(catalog, '文档工作').map(item => item.actionId)).toEqual(['document.read', 'document.author'])
|
||||
expect(categoryActions(catalog, '项目与协作').map(item => item.actionId)).toEqual(['project.create'])
|
||||
expect(categoryActions(catalog, '').map(item => item.actionId)).toEqual([])
|
||||
})
|
||||
|
||||
it('drops late results and checks explicit system actions', () => {
|
||||
expect(shouldAcceptRoleResponse(4, 3)).toBe(false)
|
||||
expect(shouldAcceptRoleResponse(4, 4)).toBe(true)
|
||||
expect(hasSystemAction(['system.roles.view'], 'system.roles.view')).toBe(true)
|
||||
expect(hasSystemAction(['system.roles.view'], 'system.permissions.manage')).toBe(false)
|
||||
})
|
||||
|
||||
it('writes a matrix cell edit into that role\'s own draft without selecting it first', () => {
|
||||
const first = editRoleDraft({}, roles[0], catalog, 'document.read', stateToValue('project'))
|
||||
const both = editRoleDraft(first, roles[1], catalog, 'document.author', stateToValue('default'))
|
||||
expect(Object.keys(both)).toEqual(['role_engineer', 'role_reviewer'])
|
||||
expect(roleCellValue(both, roles[0], 'document.read')).toEqual({ allowed: false, capAllowed: true })
|
||||
expect(roleCellValue(both, roles[0], 'document.author')).toEqual({ allowed: false, capAllowed: false })
|
||||
expect(both.role_reviewer.role.roleId).toBe('role_reviewer')
|
||||
expect(dirtyRoles(both, editableIds)).toEqual([{ roleId: 'role_engineer', changes: 1 }, { roleId: 'role_reviewer', changes: 1 }])
|
||||
})
|
||||
|
||||
it('uses a two-state control for project.create and three states for project actions', () => {
|
||||
expect(cellKind(catalog[1])).toBe('toggle')
|
||||
expect(cellKind(catalog[2])).toBe('state')
|
||||
const on = editRoleDraft({}, roles[0], catalog, 'project.create', stateToValue('default'))
|
||||
expect(rolePatchEntries(on, versions(on), editableIds)[0].changes).toEqual([{ actionId: 'project.create', allowed: true, capAllowed: true }])
|
||||
})
|
||||
|
||||
it('saves every dirty role through one authoritative preview and one batch commit', async () => {
|
||||
let drafts = editRoleDraft({}, roles[0], catalog, 'document.read', stateToValue('default'))
|
||||
drafts = editRoleDraft(drafts, roles[1], catalog, 'project.create', stateToValue('default'))
|
||||
const entries = rolePatchEntries(drafts, versions(drafts), editableIds)
|
||||
const preview = (roleId: string): RolePermissionPreview => ({ roleId, roleName: roleId, expectedRoleVersion: 1, currentRoleVersion: 1,
|
||||
versionMatches: true, changes: [], linkedExpansion: [], keptLinkedActions: [], conflicts: [] })
|
||||
const client = {
|
||||
previewRolePermissions: vi.fn(async () => entries.map(entry => preview(entry.roleId))),
|
||||
batchPatchRolePermissions: vi.fn(async () => ({ receiptId: 'r1', authorityEpoch: 2, receipts: [] })),
|
||||
}
|
||||
const reviewed = await previewRoleBatch(client, entries)
|
||||
expect(reviewed.blocked).toBe(false)
|
||||
await commitRoleBatch(client, entries, reviewed.previews, 'key-1')
|
||||
expect(client.previewRolePermissions).toHaveBeenCalledWith(entries, { mode: 'patch' })
|
||||
expect(client.batchPatchRolePermissions).toHaveBeenCalledTimes(1)
|
||||
expect(client.batchPatchRolePermissions).toHaveBeenCalledWith([
|
||||
{ roleId: 'role_engineer', expectedRoleVersion: 1, changes: [{ actionId: 'document.read', allowed: true, capAllowed: true }] },
|
||||
{ roleId: 'role_reviewer', expectedRoleVersion: 1, changes: [{ actionId: 'project.create', allowed: true, capAllowed: true }] },
|
||||
], 'key-1')
|
||||
|
||||
const conflicted = [{ ...preview('role_engineer'), conflicts: [{ actionId: 'document.read', reason: '平台上限不允许' }] }]
|
||||
expect((await previewRoleBatch({ previewRolePermissions: async () => conflicted }, entries)).blocked).toBe(true)
|
||||
await expect(commitRoleBatch(client, entries, conflicted, 'key-2')).rejects.toThrow()
|
||||
expect(client.batchPatchRolePermissions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rebases every dirty role on its latest version after a batch conflict', async () => {
|
||||
let drafts = editRoleDraft({}, roles[0], catalog, 'document.read', stateToValue('default'))
|
||||
drafts = editRoleDraft(drafts, roles[1], catalog, 'document.author', stateToValue('project'))
|
||||
const fresh: Record<string, RoleRecord> = {
|
||||
role_engineer: { ...roles[0], roleVersion: 4, permissions: [{ actionId: 'document.author', allowed: true, capAllowed: true, permissionVersion: 2 }] },
|
||||
role_reviewer: { ...roles[1], roleVersion: 9, permissions: [] },
|
||||
}
|
||||
const getRole = vi.fn(async (roleId: string) => fresh[roleId])
|
||||
const rebased = await rebaseRoleDrafts({ getRole }, drafts, ['role_engineer', 'role_reviewer'], catalog)
|
||||
expect(getRole).toHaveBeenCalledTimes(2)
|
||||
expect(rebased.drafts.role_engineer.values['document.read']).toEqual({ allowed: true, capAllowed: true })
|
||||
expect(rebased.drafts.role_engineer.values['document.author']).toEqual({ allowed: true, capAllowed: true })
|
||||
expect(rolePatchEntries(rebased.drafts, versions(rebased.drafts), editableIds)).toEqual([
|
||||
{ roleId: 'role_engineer', expectedRoleVersion: 4, changes: [{ actionId: 'document.read', allowed: true, capAllowed: true }] },
|
||||
{ roleId: 'role_reviewer', expectedRoleVersion: 9, changes: [{ actionId: 'document.author', allowed: false, capAllowed: true }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('names every dirty role in the leave guard', () => {
|
||||
const message = roleLeaveMessage(['软件工程师', '审核工程师'])
|
||||
expect(message).toContain('「软件工程师」')
|
||||
expect(message).toContain('「审核工程师」')
|
||||
})
|
||||
|
||||
it('names the role member entry and member status for assistive technology', () => {
|
||||
expect(roleMembersLabel(roles[0])).toBe('查看角色 软件工程师 的成员')
|
||||
expect(memberStatusLabel({ enabled: true })).toBe('有效')
|
||||
expect(memberStatusLabel({ enabled: false })).toContain('停用')
|
||||
})
|
||||
|
||||
it('defaults to single-role focus on narrow screens and to the matrix otherwise', () => {
|
||||
expect(initialRoleView(query => query === NARROW_ROLE_QUERY)).toBe('single')
|
||||
expect(initialRoleView(() => false)).toBe('matrix')
|
||||
expect(initialRoleView()).toBe('matrix')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PermissionDescriptor } from '../src/api'
|
||||
import {
|
||||
categoryActions, createRolePermissionDraft, dirtyRoles, linkedByProjectCreate, mergeRoleDraft, permissionCategories, rebaseRoleDraft,
|
||||
rolePatchEntries, stateToValue, updateRolePermission, validateRolePermissionDraft, valueToState, visiblePatch,
|
||||
type RolePermissionStateName, type RolePermissionValue,
|
||||
} from '../src/rolePermissions'
|
||||
|
||||
const initial: Record<string, RolePermissionValue> = {
|
||||
'project.create': { allowed: false, capAllowed: true },
|
||||
'document.read': { allowed: true, capAllowed: true },
|
||||
'document.author': { allowed: false, capAllowed: true },
|
||||
}
|
||||
|
||||
describe('role permission draft', () => {
|
||||
it('retains hidden edits and patches only explicit visible actions', () => {
|
||||
let draft = createRolePermissionDraft(initial)
|
||||
draft = updateRolePermission(draft, 'project.create', { allowed: true, capAllowed: true })
|
||||
draft = updateRolePermission(draft, 'document.author', { allowed: true, capAllowed: true })
|
||||
|
||||
expect(visiblePatch(draft, ['project.create'])).toEqual([
|
||||
{ actionId: 'project.create', allowed: true, capAllowed: true },
|
||||
])
|
||||
expect(draft.values['document.author']).toEqual({ allowed: true, capAllowed: true })
|
||||
})
|
||||
|
||||
it('produces the same patch for single-role and matrix edits', () => {
|
||||
const single = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true })
|
||||
const matrix = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true })
|
||||
expect(visiblePatch(single, Object.keys(initial))).toEqual(visiblePatch(matrix, Object.keys(initial)))
|
||||
})
|
||||
|
||||
it('discards a stale server refresh without replacing newer edits', () => {
|
||||
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
|
||||
const merged = mergeRoleDraft(edited, initial, edited.generation - 1)
|
||||
expect(merged).toBe(edited)
|
||||
})
|
||||
|
||||
it('rejects defaults that exceed the role cap', () => {
|
||||
const invalid = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: true, capAllowed: false })
|
||||
expect(validateRolePermissionDraft(invalid)).toEqual({ actionId: 'document.read', reason: 'default-exceeds-cap' })
|
||||
})
|
||||
|
||||
it('rebases local changes on a newer role version without losing the draft', () => {
|
||||
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
|
||||
const server = { ...initial, 'document.read': { allowed: false, capAllowed: true } }
|
||||
const rebased = rebaseRoleDraft(edited, server)
|
||||
expect(rebased.baseline['document.read']).toEqual({ allowed: false, capAllowed: true })
|
||||
expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true })
|
||||
expect(rebased.values['document.read']).toEqual({ allowed: false, capAllowed: true })
|
||||
})
|
||||
})
|
||||
|
||||
const catalog: PermissionDescriptor[] = [
|
||||
{ actionId: 'system.roles.view', label: '查看角色', description: '', group: '角色管理', scope: 'system', protected: true },
|
||||
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
{ actionId: 'trace.edit', label: '编辑追溯网', description: '', group: '项目内容', category: '项目内容', scope: 'project', protected: false },
|
||||
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false },
|
||||
{ actionId: 'project.settings', label: '项目设置', description: '', group: '项目管理', category: '项目与协作', scope: 'project', protected: false },
|
||||
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
]
|
||||
|
||||
describe('role permission tri-state and categories', () => {
|
||||
it('maps the three display states to stored values and back', () => {
|
||||
expect(stateToValue('denied')).toEqual({ allowed: false, capAllowed: false })
|
||||
expect(stateToValue('project')).toEqual({ allowed: false, capAllowed: true })
|
||||
expect(stateToValue('default')).toEqual({ allowed: true, capAllowed: true })
|
||||
for (const state of ['denied', 'project', 'default'] as RolePermissionStateName[]) expect(valueToState(stateToValue(state))).toBe(state)
|
||||
expect(valueToState(undefined)).toBe('denied')
|
||||
})
|
||||
|
||||
it('groups actions in the fixed category order and keeps catalog order inside a category', () => {
|
||||
expect(permissionCategories(catalog)).toEqual(['项目与协作', '文档工作', '项目内容'])
|
||||
expect(categoryActions(catalog, '项目与协作').map(item => item.actionId)).toEqual(['project.create', 'project.settings'])
|
||||
expect(categoryActions(catalog, '文档工作').map(item => item.actionId)).toEqual(['document.read', 'document.author'])
|
||||
expect(permissionCategories(catalog).flatMap(category => categoryActions(catalog, category)).some(item => item.protected)).toBe(false)
|
||||
})
|
||||
|
||||
it('counts only roles whose drafts differ from their baseline', () => {
|
||||
const clean = createRolePermissionDraft(initial)
|
||||
const edited = updateRolePermission(updateRolePermission(createRolePermissionDraft(initial), 'document.read', stateToValue('project')),
|
||||
'document.author', stateToValue('default'))
|
||||
const reverted = updateRolePermission(updateRolePermission(createRolePermissionDraft(initial), 'document.read', stateToValue('denied')),
|
||||
'document.read', initial['document.read'])
|
||||
expect(dirtyRoles({ clean, edited, reverted })).toEqual([{ roleId: 'edited', changes: 2 }])
|
||||
})
|
||||
|
||||
it('builds one patch entry per dirty role with only that role\'s changed actions', () => {
|
||||
const a = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
|
||||
const b = updateRolePermission(createRolePermissionDraft(initial), 'document.read', stateToValue('denied'))
|
||||
const entries = rolePatchEntries({ a, b, c: createRolePermissionDraft(initial) }, { a: 3, b: 7, c: 1 }, Object.keys(initial))
|
||||
expect(entries).toEqual([
|
||||
{ roleId: 'a', expectedRoleVersion: 3, changes: [{ actionId: 'project.create', allowed: true, capAllowed: true }] },
|
||||
{ roleId: 'b', expectedRoleVersion: 7, changes: [{ actionId: 'document.read', allowed: false, capAllowed: false }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks linked actions only while project.create is allowed', () => {
|
||||
expect(linkedByProjectCreate({ 'project.create': { allowed: true, capAllowed: true } }, 'project.settings')).toBe(true)
|
||||
expect(linkedByProjectCreate({ 'project.create': { allowed: true, capAllowed: true } }, 'document.read')).toBe(false)
|
||||
expect(linkedByProjectCreate({ 'project.create': { allowed: false, capAllowed: true } }, 'project.settings')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user