Compare commits
266
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac5cf6b322 | ||
|
|
a82a1e18de | ||
|
|
279b2d02a2 | ||
|
|
6e611c5de0 | ||
|
|
4882e0ae25 | ||
|
|
a61eaac9ae | ||
|
|
fa08be4145 | ||
|
|
ec1ced3050 | ||
|
|
b02ff83dfc | ||
|
|
1d7f2a3e1b | ||
|
|
18fc958def | ||
|
|
16a059381c | ||
|
|
e4cd8f1a57 | ||
|
|
9f7f4a164b | ||
|
|
a13d041159 | ||
|
|
f6442cc501 | ||
|
|
e2a670e2aa | ||
|
|
1fe321240a | ||
|
|
c6df2dc180 | ||
|
|
97b947a8e3 | ||
|
|
096947f9a2 | ||
|
|
3fe5cf1617 | ||
|
|
e73f2ad9dd | ||
|
|
d997d4a4d0 | ||
|
|
0b388e010d | ||
|
|
2bd5116f58 | ||
|
|
d7768d4ea0 | ||
|
|
3c22d0957a | ||
|
|
e296d38ec2 | ||
|
|
f01579890d | ||
|
|
259021d530 | ||
|
|
7d43ef6ff7 | ||
|
|
9baf96d937 | ||
|
|
dab16052cf | ||
|
|
ce15f7977b | ||
|
|
f560201f8b | ||
|
|
6f8e7ec604 | ||
|
|
aa49e0dbf3 | ||
|
|
9df5517e9c | ||
|
|
b4fec2fdf4 | ||
|
|
164ea66039 | ||
|
|
23877af960 | ||
|
|
e6bb387f71 | ||
|
|
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 | ||
|
|
49ba8b1735 | ||
|
|
b594d2748e | ||
|
|
afd661c932 | ||
|
|
c9ef3c0f0a | ||
|
|
72f1a259e1 | ||
|
|
804170ec66 | ||
|
|
7759d8077f | ||
|
|
8d43b1de12 | ||
|
|
d66bd7941c | ||
|
|
a022257b40 | ||
|
|
3f6ced099c | ||
|
|
86770e35fb | ||
|
|
65ab7a8a44 | ||
|
|
119fff96e8 | ||
|
|
96ec03d461 | ||
|
|
17996cf4dd | ||
|
|
dbb5f0462f | ||
|
|
125261410c | ||
|
|
e895a4f6a8 | ||
|
|
46e99fedbb | ||
|
|
bcb81719ce | ||
|
|
520e7df58b | ||
|
|
123f4fb052 | ||
|
|
36f2fbea34 | ||
|
|
d8e610aebe | ||
|
|
3bbb918795 | ||
|
|
10635387e0 | ||
|
|
de65026238 | ||
|
|
85428b4633 | ||
|
|
a81e94a4f2 | ||
|
|
781cde6667 | ||
|
|
e33d17e1a6 | ||
|
|
5c0e9f2711 | ||
|
|
04269eed25 | ||
|
|
985fc0f931 | ||
|
|
05609ff61c | ||
|
|
4835c45b8d | ||
|
|
6093beb27e | ||
|
|
c2ff4fcc8e | ||
|
|
5c50943731 | ||
|
|
03db4b81be | ||
|
|
3555f815af | ||
|
|
11f66aee9d | ||
|
|
f2b5532b7b | ||
|
|
06249dd83f | ||
|
|
83c22681f9 | ||
|
|
44205e1262 | ||
|
|
1b7ba779f1 | ||
|
|
0cd1f9954a | ||
|
|
92c390a08a | ||
|
|
06fa1a40a6 | ||
|
|
95e1948a5f | ||
|
|
9242cee89d | ||
|
|
987be7442f | ||
|
|
ad503c9535 | ||
|
|
20c20b7959 | ||
|
|
1ef68abb8c | ||
|
|
8a9d129a94 | ||
|
|
f7d9197011 | ||
|
|
9d0ae331c7 | ||
|
|
1fc9dabc13 | ||
|
|
505aba36c1 | ||
|
|
b921bef6a6 | ||
|
|
c9b55eb502 | ||
|
|
70f0d2d41f | ||
|
|
bd736f528c | ||
|
|
b52f9654a0 | ||
|
|
814643f277 | ||
|
|
5ab81432d1 | ||
|
|
9b9f24b7be | ||
|
|
8dc68fd1e3 | ||
|
|
ddf3135c3f | ||
|
|
b04f4ded95 | ||
|
|
bc9b17a665 | ||
|
|
a1bd3e2983 | ||
|
|
93ee723131 | ||
|
|
ee735f2e2e | ||
|
|
3203e8d4f3 | ||
|
|
bed8a5c766 | ||
|
|
055476d135 | ||
|
|
34f6a1bf2f | ||
|
|
2e7e5b7a64 | ||
|
|
36ac754300 | ||
|
|
710ef836b5 | ||
|
|
e10e447dab | ||
|
|
b175506ebd | ||
|
|
7afae7f20b | ||
|
|
099a92022d | ||
|
|
1544781c28 | ||
|
|
cfabed8fae | ||
|
|
a961b19ebb | ||
|
|
010540f203 | ||
|
|
731c9b7776 | ||
|
|
c9d5acf089 | ||
|
|
139f2ce654 | ||
|
|
01ec12e77c | ||
|
|
34950997f8 | ||
|
|
5313cfad4e | ||
|
|
4e1eb39035 | ||
|
|
006c815675 | ||
|
|
9d7681cec4 | ||
|
|
5bc2e12c01 | ||
|
|
64ba640b77 | ||
|
|
eb2d6d32f9 | ||
|
|
40c2afe054 | ||
|
|
42cda9c056 | ||
|
|
15b962694b | ||
|
|
67585c1701 | ||
|
|
2ba275b991 | ||
|
|
329bdf53d6 | ||
|
|
e7f9081a93 | ||
|
|
c294835f8d | ||
|
|
880eff74f8 | ||
|
|
b7473fe585 | ||
|
|
dfcea5c988 | ||
|
|
07a03ac4f6 | ||
|
|
d69009e081 | ||
|
|
55a64f40e8 | ||
|
|
b3fa39744d | ||
|
|
23f96fd921 | ||
|
|
f96a591123 | ||
|
|
0bbccbe39e | ||
|
|
698fffd929 | ||
|
|
44b964682c | ||
|
|
3087b0621c | ||
|
|
4d1d64a83e | ||
|
|
0655bc37e1 | ||
|
|
861c45edb5 | ||
|
|
9fc7da2ace | ||
|
|
c63f6c7470 | ||
|
|
75a420210d | ||
|
|
175210f4bb | ||
|
|
b00fa4433b | ||
|
|
8be95c8807 | ||
|
|
54fe339e65 | ||
|
|
dcd2699933 | ||
|
|
0159f009fa | ||
|
|
82c08bd983 | ||
|
|
43d1933b8c | ||
|
|
91a832f93e | ||
|
|
37c668dbad | ||
|
|
e6eecb2bfa | ||
|
|
ac668d11d3 | ||
|
|
411a79d9e2 | ||
|
|
d9bb780384 | ||
|
|
4965de269a | ||
|
|
b7194d54d5 | ||
|
|
f752a7bcec | ||
|
|
e3332badca | ||
|
|
3a5fbe6e42 | ||
|
|
fca832e3b0 | ||
|
|
81b984c752 | ||
|
|
c4ebcf5404 | ||
|
|
11464242c0 | ||
|
|
809f28d48f | ||
|
|
336ec94763 | ||
|
|
0ae27749cf | ||
|
|
531b683a92 | ||
|
|
0f52634380 | ||
|
|
1edd872c55 | ||
|
|
a45a4d8652 | ||
|
|
378ec24bbd | ||
|
|
cc233fec90 | ||
|
|
8db7093a9d | ||
|
|
034bd5f724 | ||
|
|
83a062b7f8 | ||
|
|
fdaf89de9d | ||
|
|
986ebdbb61 | ||
|
|
e058adcf72 | ||
|
|
d01a3ebd81 |
@@ -0,0 +1,68 @@
|
|||||||
|
name: ci
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-and-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out fixed Frontend and UI commits
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }}
|
||||||
|
UI_SHA: fadabceadeec210d52cda7e9cec71e988d2e4482
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'context server=%s repository=%s sha=%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}"
|
||||||
|
[[ "${GITHUB_SERVER_URL}" =~ ^https?://[^/]+/?$ ]]
|
||||||
|
test "${GITHUB_REPOSITORY}" = "weichen/Agent-System-Frontend"
|
||||||
|
[[ "${GITHUB_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||||
|
[[ "${UI_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||||
|
test -n "${TOKEN}"
|
||||||
|
if [ -z "${UI_DEPLOY_KEY}" ]; then
|
||||||
|
echo "UI_DEPLOY_KEY is required for the private UI dependency" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
mkdir -p "${RUNNER_TEMP}/iam-frontend-ci/frontend" "${RUNNER_TEMP}/iam-frontend-ci/ui"
|
||||||
|
cd "${RUNNER_TEMP}/iam-frontend-ci/frontend"
|
||||||
|
git init -q
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout -q --detach FETCH_HEAD
|
||||||
|
test "$(git rev-parse HEAD)" = "${GITHUB_SHA}"
|
||||||
|
umask 077
|
||||||
|
trap 'rm -f "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"' EXIT
|
||||||
|
printf '%s' "${UI_DEPLOY_KEY}" > "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key"
|
||||||
|
printf '%s\n' '[git-ssh.u1680832.nyat.app]:31411 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK55QSxDqMMZ1HIWHSPkQ7fyJkDKbdg98TeIv6SrR3qx' > "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"
|
||||||
|
export GIT_SSH_COMMAND="ssh -F /dev/null -i ${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts -p 31411"
|
||||||
|
cd "${RUNNER_TEMP}/iam-frontend-ci/ui"
|
||||||
|
git init -q
|
||||||
|
git remote add origin "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git"
|
||||||
|
git fetch --depth=1 origin "${UI_SHA}"
|
||||||
|
git checkout -q --detach FETCH_HEAD
|
||||||
|
test "$(git rev-parse HEAD)" = "${UI_SHA}"
|
||||||
|
git -C "${RUNNER_TEMP}/iam-frontend-ci/frontend" status --short
|
||||||
|
git -C "${RUNNER_TEMP}/iam-frontend-ci/ui" status --short
|
||||||
|
- name: Install Node 22
|
||||||
|
env:
|
||||||
|
NODE_ARCHIVE_SHA256: df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz
|
||||||
|
printf '%s %s\n' "${NODE_ARCHIVE_SHA256}" "$RUNNER_TEMP/node.tar.xz" | sha256sum -c -
|
||||||
|
tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
npm ci --prefix "${RUNNER_TEMP}/iam-frontend-ci/ui"
|
||||||
|
npm ci --prefix "${RUNNER_TEMP}/iam-frontend-ci/frontend"
|
||||||
|
- name: Test and build
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "${RUNNER_TEMP}/iam-frontend-ci/frontend"
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
@@ -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,公钥指纹
|
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||||
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||||
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
`~/.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 内嵌)。
|
- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/(Dev Makefile 拷贝至 backend web/dist 内嵌)。
|
||||||
- `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。
|
- `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。
|
||||||
- 开发:`npm run dev`(Vite 代理 /api → 127.0.0.1:8787)。
|
- 开发:`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)。
|
- 依赖刻意最小:react + marked + dompurify(模型输出与文档预览先清洗再写入页面);无 UI 框架(设计令牌手写,见 styles.css)。
|
||||||
|
|||||||
+1
Submodule deps/development-guidelines added at 2688fd2be6
+1
Submodule deps/development-manager added at bd3a7de57a
@@ -0,0 +1,90 @@
|
|||||||
|
# 增量 C 前端独立评审(W10 审计工作台 + 依据/候选确认路径)
|
||||||
|
|
||||||
|
- 评审对象:`feature/rdm-two-trees-followup-w10` @ `fa08be4`(基线 `ec1ced3`,dev 无本地改动)
|
||||||
|
- 范围:`src/components/RdmAuditWorkspace.tsx(+test)`、`Conversation.tsx`、`ConversationWork.tsx`、`Workbench.tsx`、`src/api.ts`、`src/styles.css`、`src/companion-conversation.css`、`e2e/rdm-audit.playwright.ts`
|
||||||
|
- 增量:`+1048 / -7`,9 文件(`git diff --stat ec1ced3 fa08be4`)
|
||||||
|
- 评审性质:只读评审。代码零改动;本文件是唯一写入。契约核对基线:RDM 侧 `Agent-System-RDM-wt-feature-rdm-two-trees-followup-c-w8-w10` @ `c4c6df7`(本地检出的 W8b/W5 命令面);计划基线:`modules/documents` `rdm-two-trees-stage5-implementation-test-plan-r1.md` §W10
|
||||||
|
- 环境限制:本仓为独立检出,组合仓 `../ui`(`@ui` 源码别名)缺失、子模块未初始化 ⇒ 依赖 `@ui` 的 22 个测试文件与 `tsc -b` 无法在本工作区运行(见 §验证)。`npm ci --offline` 可用,故守护测试与 knip 实跑。
|
||||||
|
|
||||||
|
## 1. 逐项结论
|
||||||
|
|
||||||
|
| # | 检查项 | 结论 | 依据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 设计门禁(令牌/动效/折叠) | **FAIL(部分)** | 裸色值、私有 keyframes、行内字号、`transition`、`--fs-ui-*` 全部通过守护测试(7/7 PASS);但两处折叠未用 `@ui Collapsible`(MAJOR-3) |
|
||||||
|
| 2 | DTO 边界(只消费服务端已裁剪 DTO、无新增全量拉取) | **PASS** | 页面只渲染 `display/recorded/disclosure`;服务端 `redactOperationBodies`(`inspection_visibility.go:70-88`)确实把无权正文的 `summary.display` 换成「受限」并清空 `object.current`,前端未越权补正文;列表单页 `limit:30`,证据/影响分析均为按需单条读取,无全量拉取 |
|
||||||
|
| 3 | 异步纪律(单一 owner、seq 丢迟到响应) | **PASS(有 1 处冗余镜像,见 MINOR-4)** | `createOperationsFeed` 每请求取 `++seq`,非最新 seq 的成功与失败一并丢弃,`dispose()` 亦作废在途;续页回传首页 `cutoffSeq`;其余面板沿用既有 `startLatestFetch` 单 owner 模式 |
|
||||||
|
| 4 | 四态可读 + 游标失效显式 | **PASS** | 加载/空/错误(denied·cursor·query·capability·auth 分流)/旧记录「当时未记录」/正文受限/部分覆盖/影响截断均有独立文案;错误分类正则与服务端真实串一致(`权限拒绝:…` 出自 `perms_api.go:234`;`操作记录游标已失效…` 出自 `operation_projection.go:42`),非臆测匹配 |
|
||||||
|
| 5 | 既有行为兼容(树/卡片/对话/导出预览;右栏收起的确认路径) | **FAIL** | 树/卡片/对话/导出预览无回归(页签按名定位,`审计记录` 与既有 `操作记录` 不冲突,`.rdm-audit-card` 计数断言互不干扰);但右栏收起后的「对话确认路径」在最需要它的工作卡场景下是空操作(MAJOR-1) |
|
||||||
|
| 6 | e2e 扩展质量(skip 如实) | **PASS(附 MINOR-6)** | `test.skip(!SVC_REAL_RDM_BIN, …)` 与注释如实说明「缺真实 RDM 二进制则整组跳过、不伪造」;但当前 CI 与 Dev `scripts/e2e.sh` 均不注入该变量 ⇒ 4 条新用例在任何现有执行入口下都永不运行(零证据) |
|
||||||
|
| 7 | 知识多挂载为 TODO 未实现、未虚报 | **PASS(附 MINOR-7)** | `RdmAuditWorkspace.tsx:591` 仅留 TODO,未见任何实现或「已完成」表述;TODO 给出的理由经核实为真:W8b 分支 RDM 侧 `grep '"mounts"'` 零命中,`rdm.inspect.tree/object` 确实无挂载字段(域层已有 `MountsOf`,仅未出 DTO) |
|
||||||
|
|
||||||
|
## 2. BLOCKING
|
||||||
|
|
||||||
|
无。
|
||||||
|
|
||||||
|
## 3. MAJOR
|
||||||
|
|
||||||
|
### MAJOR-1 右栏收起后的「对话确认路径」在目标场景下是空操作(承诺与实现不符)
|
||||||
|
|
||||||
|
- 位置:`src/components/Conversation.tsx:571-572, 734-740`;关联 `src/components/ConversationWork.tsx:65, 91`;承诺文案 `src/components/Workbench.tsx:82`
|
||||||
|
- 理由:新提示条仅做 DOM 定位滚动:
|
||||||
|
`body.querySelector('[data-card-id="…"]') ?? body.querySelector('[data-conversation-work]')`。
|
||||||
|
但显示条件恰是 `!workPaneOwnsCards`(右栏收起或非工作页签),此时:
|
||||||
|
1. `ConversationWork` 命中 `if (!detailHere) return null`(`ConversationWork.tsx:91`)——`[data-conversation-work]` 整个子树不存在;
|
||||||
|
2. 工作卡在对话正文里只经 `CardAnchor` 出现,而 `Conversation.tsx:56` 对带 `card.work` 的卡显式传 `card={undefined}`,UI 侧 `data-card-id={card?.id}`(`modules/ui/src/CardAnchor.tsx:25`)因此不落属性。
|
||||||
|
结果:W10 重点场景(候选工作卡待确认)点「N 项待你确认 · 在对话中处理 ↓」→ `target` 为 `null` → `target?.scrollIntoView` 静默无动作,而收起按钮的 title 却承诺「N 项待确认会在对话中继续处理」。
|
||||||
|
附带:`pendingHere`(含 `work.decision.state === 'open'`)与 `pendingCardId`(仅 `status === 'action_required'`,`Conversation.tsx:572`)口径不同,decision-open 卡即使能定位也算不出 `id`。
|
||||||
|
- 修复建议:改走仓内既有的唯一定位路径 `store.openInbox(pendingCardId)`(`src/store.ts:1625-1633`:展开右栏、切工作页签、`flashLocate('.right-panel','data-card-id',…)`),或在 `!workPaneOwnsCards` 时把候选/决议控件渲染进对话正文;同时让 `pendingCardId` 与 `pendingConfirmations` 用同一谓词。
|
||||||
|
|
||||||
|
### MAJOR-2 新增「审计记录」页签无能力门禁,缺命令面的构建上恒为错误态
|
||||||
|
|
||||||
|
- 位置:`src/components/RdmAuditWorkspace.tsx:52, 125-137`
|
||||||
|
- 理由:页签无条件挂载,而仓内既有约定是「能力缺失即不暴露」(`src/store.ts:1462-1475` 依 `api.listCommands()` 刷新能力位;`src/workspaces.ts:52` `rdmAuditReady`)。当前就绪门 `RDM_AUDIT_COMMANDS` 只含 7 个 `rdm.inspect.*`(`workspaces.ts:23-31`),不含 W8b/W5 的 `rdm.audit.operations`、`rdm.trace.coverage`、`rdm.trace.pending-recheck`、`rdm.compliance.impact.*`。因此在已合入的 RDM 上审计工作区入口照常可见,而新页签必然落到 `classifyOperationsError → capability`(「当前服务未提供审计能力」),与 `e2e/plain-base.playwright.ts:38-51` 记录的「零暴露、不进入即 400」原则相悖。
|
||||||
|
- 修复建议:在 `workspaces.ts` 增加 `rdmAuditOperationsReady(cmds)`(上述 5 条命令)并在页签列表按能力过滤;覆盖面板与影响分析入口同样受此门控;若坚持常显,则须在 PR 描述中登记为已知降级并由 RDM 发布节奏兜底。
|
||||||
|
|
||||||
|
### MAJOR-3 两处折叠未使用 `@ui Collapsible`(违反 DESIGN.md §9.2)
|
||||||
|
|
||||||
|
- 位置:`src/components/RdmAuditWorkspace.tsx:1200`(`EvidenceLink`)、`:1266`(`ImpactAnalysisBlock`)
|
||||||
|
- 理由:两者用 `<button aria-expanded>` + 条件挂载自建展开,未消费全项目唯一折叠原语;DESIGN.md §9.2「一切折叠区…用它:外壳高度动画 + 内容 fade 两层分离、300ms 延迟卸载、展开态记忆」。守护测试只拦 `<details>`,故测试全绿但违宪——本次评审门禁「折叠只用 @ui Collapsible」据此判 FAIL。
|
||||||
|
- 修复建议:改为 `<Collapsible id={…} trigger={…}>`(这两处都有稳定的 id 语义:证据 `${pid}/${kind}/${id}`、影响分析 `${pid}/${analysisId}`),可顺带获得展开态记忆与卸载动画。若以「同文件 `:408`、`RegCitation.tsx:157` 已有先例」为由豁免,请在 PR 中显式登记豁免理由,而非默认放行。
|
||||||
|
|
||||||
|
## 4. MINOR
|
||||||
|
|
||||||
|
| # | 位置 | 问题 | 建议 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| MINOR-1 | `src/components/ConversationWork.tsx:22-24` | `candidateActions` 未排除已有决议表单的卡:@ui `WorkCardBody` 对 `work.decision` 开放态渲染 `DecisionForm`,若该卡 `actions` 同时含 confirm/reject,则同一决议出现两个入口,且新按钮只提交 `{resultVersion}`(不含 `selectedIds`/`freeText`),存在「未选选项即提交」风险 | 首行加 `if (card.work?.decision?.state === 'open') return []`,保持一个动作一个入口 |
|
||||||
|
| MINOR-2 | `src/components/Workbench.tsx:38-41` | `pendingConfirmations` 与 @ui `pendingCount`(`modules/ui/src/InboxList.tsx:103-106`)口径不一致:新函数未排除 `status === 'resolved'`,「resolved + decision open」的卡会被计入输入区提示与收起提示,却不计入收件箱/顶栏计数 ⇒ 同屏两个数字打架 | 直接复用 `pendingCount` 语义,或在注释中说明差异并统一文案 |
|
||||||
|
| MINOR-3 | `src/components/RdmAuditWorkspace.tsx:1303` | 覆盖面板用 `Promise.all([rdmTraceCoverage, rdmTracePendingRecheck])`:任一命令缺失(较旧构建只有 coverage)即整面板降级,正式/候选覆盖数字一并消失 | 改 `Promise.allSettled`,coverage 可用即渲染,pending 不可用时只在待复核一栏给说明 |
|
||||||
|
| MINOR-4 | `src/components/RdmAuditWorkspace.tsx:1051, 1060` | `dict` 是 `state.dictionary` 的第二份异步状态镜像;切项目时旧项目词典保留到新页落地,可能把「新项目不存在的筛选值」静默带上,且 `<select>` 无匹配 option 时显示为空白 | 随 `pid` 变化清空 `dict`,或词典未就绪前禁用两个 select |
|
||||||
|
| MINOR-5 | `src/components/RdmAuditWorkspace.tsx:1090` | `.rdm-audit-ops` 在任何 CSS 中都无规则(本次只补了 `.rdm-audit-wrap`、`.rdm-audit-op .rdm-audit-opgroup-head/.rdm-audit-op-pending`、`.rdm-audit-op-object`),列表容器无布局约束 | 补一条容器规则或复用既有类名,避免长对象名/窄屏下出现不可预期的块级堆叠 |
|
||||||
|
| MINOR-6 | `e2e/rdm-audit.playwright.ts:150` | 整组 skip 条件 `SVC_REAL_RDM_BIN` 在 Dev `scripts/e2e.sh` 与本仓 CI 中均无注入(`grep SVC_REAL_RDM_BIN scripts/e2e.sh` 零命中;CI 只跑 `npm test` + `npm run build`)⇒ 4 条新用例(含游标翻页、游标失效重查、无授权三态)当前零执行证据 | 组合验证时由 e2e.sh 导出该变量(它已在 `$OUT/agent-system-rdm` 构建出二进制,`e2e.sh:88`),或在 PR 中把「e2e 未运行」显式登记为 NOT_RUN 缺口 |
|
||||||
|
| MINOR-7 | `src/components/RdmAuditWorkspace.tsx:591` | 知识多挂载降级只以代码 TODO 记录;计划 §W10 把「知识多挂载」列入实施范围(`rdm-two-trees-stage5-implementation-test-plan-r1.md:154`),缺登记的降级记录,容易在跨仓验收时被当作已交付 | 在 Dev 任务卡/证据条目登记该验收项为「待 RDM DTO 增补」,并回指此 TODO;代码内无需再改 |
|
||||||
|
|
||||||
|
## 5. 已核实的正面结论(附证据)
|
||||||
|
|
||||||
|
1. **DTO 边界真实成立**:`internal/rdmapp/inspection_visibility.go:70-88` 在无权时把 `change.summary.display` 置为「受限」、`disclosure='restricted'`、`object.current=nil`;前端 `OperationRow`(`:1125-1170`)只渲染服务端 `display/recorded`,`refsText` 只拼 `kind.label+id`,未渲染任何前端臆造的正文或补全的「当前」名称。
|
||||||
|
2. **契约字段与 RDM 实际 DTO 对齐**:`OperationRecordDTO`/`OperationPage`/`ImpactAnalysis`/`TraceCoverageResult`/`TracePendingEdge` 的 JSON tag 与 `src/api.ts:73-175` 的类型逐字段核对一致(含 `cutoffSeq/nextCursor/total`、`ObjectRef.revision` 为字符串、`CGap{rule,node,reason}`)。
|
||||||
|
3. **错误分类与服务端真实串匹配**:`权限拒绝:`(`perms_api.go:234`)→ `denied`;`操作记录游标已失效`(`operation_projection.go:42`)/`操作记录游标不合法`(`:454`)→ `cursor` 且隐藏翻页按钮并给「从第一页重新查询」。
|
||||||
|
4. **异步纪律的测试覆盖到位**:`createOperationsFeed` 的 seq、dispose、续页 `cutoffSeq`、迟到失败响应均有断言(`RdmAuditWorkspace.test.tsx:145-215`),且实现与断言一致(`request()` 成功/失败双路都比对 `mine === seq`)。
|
||||||
|
5. **未引入 knip 基线压力**:`node scripts/check-knip.mjs` → `knip unused exports 58 <= 58`;逐项核对 knip JSON,本次改动的 5 个文件未新增 unused exports/types。
|
||||||
|
6. **既有回归面检查通过**:既有 `操作记录` 页签(TimelineTab)与新 `审计记录` 页签按 aria 名定位互不冲突;既有 e2e 的 `.rdm-audit-card` 计数断言作用于概览页签,与新覆盖卡片不同时渲染;导出预览入口(`exportNeedsDraft`/`DocumentRenderWorkspace`)未改动。
|
||||||
|
|
||||||
|
## 6. 验证命令与结果(本工作区实跑)
|
||||||
|
|
||||||
|
| 命令 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| `git diff --stat ec1ced3 fa08be4` | 9 files, +1048/-7(评审对象确认) |
|
||||||
|
| `npm ci --offline --no-audit --no-fund` | 176 packages(仅本地缓存,未联网) |
|
||||||
|
| `npx vitest run test/design-guard.test.ts` | **PASS** — Test Files 1 passed, Tests 7 passed(① 门禁实测) |
|
||||||
|
| `npx vitest run` | 43 files passed / 22 files failed;398 tests passed, 1 failed。22 个失败文件与该 1 个失败全部为 `Error: Cannot find package '@ui'`(组合仓 `../ui` 未检出),无其他错误类别 |
|
||||||
|
| `npx vitest run src/components/RdmAuditWorkspace.test.tsx` | **NOT_RUN** — 同一 `@ui` 解析失败(新增单测在本工作区无法执行) |
|
||||||
|
| `npx tsc -b` | **NOT_RUN** — 全部为 `TS2307 Cannot find module '@ui'` 及其引发的隐式 any,无新增类型错误信号 |
|
||||||
|
| `node scripts/check-knip.mjs` | **PASS** — `knip unused exports 58 <= 58` |
|
||||||
|
| `npm run e2e` / `npx playwright test` | **NOT_RUN** — 无 `PLAYWRIGHT_BASE_URL` 且无浏览器/后端组合环境;`SVC_REAL_RDM_BIN` 未注入时新组按声明跳过 |
|
||||||
|
| 浅/深主题、窄屏、长对象名视觉检查 | **NOT_RUN** — 需真实组合浏览器;静态侧只能确认新增样式全部消费令牌 |
|
||||||
|
|
||||||
|
## 7. 合入建议
|
||||||
|
|
||||||
|
1. **合入门禁**:MAJOR-1、MAJOR-2 建议在合入前修复(前者是承诺失效,后者是能力面 fail-closed 破口);MAJOR-3 可修或登记豁免。
|
||||||
|
2. **证据缺口**:本单元的 vitest/tsc/e2e 在组合检出(`../ui` 就绪 + auth-on 真实 RDM)下必须补跑;在补跑前,本评审对新增单测与 e2e 的结论仅为静态核对,不得记为「测试通过」。
|
||||||
|
3. **范围声明**:知识多挂载(MINOR-7)按计划 §W10 属未完成项,验收矩阵中应保持「待 RDM DTO 增补」状态,不得因代码内 TODO 而计为已交付。
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// 防再发守卫:管理员建号路径(UsersPanel)必须持续符合后端待初始化契约。
|
||||||
|
// 背景:backend 8dfc223 将 POST /api/admin/users 改为严格 {owner, profile} 解码,
|
||||||
|
// 前端未跟进导致部署后 UI 建号 400(json: unknown field "name")——单测与 mock
|
||||||
|
// 都没有覆盖这条组合路径,本场景以真实浏览器把「填用户名 → 创建账号 →
|
||||||
|
// 确认『待初始化』提示与用户表行」钉进组合验收线。
|
||||||
|
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
|
||||||
|
// 与 vite preview;种子账号 e2e-auditor 为基座管理员(bootstrap-admin)。
|
||||||
|
// 环境契约(缺省值与 e2e.sh 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过):
|
||||||
|
// PLAYWRIGHT_BASE_URL / E2E_OWNER / E2E_PASSWORD / E2E_PASSWORD2
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||||
|
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||||
|
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||||
|
|
||||||
|
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
|
||||||
|
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
|
||||||
|
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
// 三种落点:直接进工作台(密码已生效)|强制改密门|初始密码已失效(种子二次运行)→ 改用新密码重登。
|
||||||
|
const entered = await page.locator('.tsb-user').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||||
|
if (!entered) {
|
||||||
|
const gated = await page.getByText('修改密码').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||||
|
if (gated) {
|
||||||
|
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||||
|
await pwInputs.nth(0).fill(PASSWORD)
|
||||||
|
await pwInputs.nth(1).fill(PASSWORD2)
|
||||||
|
await pwInputs.nth(2).fill(PASSWORD2)
|
||||||
|
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||||
|
} else {
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
test('管理员建号:UI 请求新契约并给出待初始化提示', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
|
||||||
|
// 账户菜单 → 系统管理…(全屏独立视图)→ 用户管理分区。
|
||||||
|
await page.locator('.tsb-user').click()
|
||||||
|
const adminEntry = page.getByText('系统管理…', { exact: true })
|
||||||
|
await expect(adminEntry).toBeVisible()
|
||||||
|
await adminEntry.click()
|
||||||
|
await expect(page.locator('.az')).toBeVisible()
|
||||||
|
const usersSection = page.getByText('用户管理', { exact: true }).first()
|
||||||
|
if (await usersSection.count()) await usersSection.click()
|
||||||
|
|
||||||
|
// 建号表单:用户名每次运行唯一(重复运行不因 409 假红),显示名可空。
|
||||||
|
const newOwner = `e2e-ui-${Date.now().toString(36)}`
|
||||||
|
const userInput = page.getByPlaceholder('用户名(登录名)')
|
||||||
|
await expect(userInput).toBeVisible()
|
||||||
|
await userInput.fill(newOwner)
|
||||||
|
await page.getByPlaceholder('显示名(可空)').fill('组合验收')
|
||||||
|
|
||||||
|
// 创建 → 契约断言一:请求体是严格 {owner, profile:{name}},无 password/isAdmin。
|
||||||
|
const createBody = page.waitForRequest(
|
||||||
|
req => req.url().endsWith('/api/admin/users') && req.method() === 'POST',
|
||||||
|
).then(req => req.postDataJSON())
|
||||||
|
await page.getByRole('button', { name: '创建账号', exact: true }).click()
|
||||||
|
const body = await createBody
|
||||||
|
expect(body).toEqual({ owner: newOwner, profile: { name: '组合验收' } })
|
||||||
|
|
||||||
|
// 契约断言二:页面创建后账号可直接使用,不再提示 CLI 初始化。
|
||||||
|
await expect(page.locator('.ad-notice')).toContainText(`已创建 ${newOwner}`)
|
||||||
|
|
||||||
|
// 契约断言三:用户表出现该账号,状态为未设密码(HTTP 建号不能设密码/授权限)。
|
||||||
|
const row = page.locator('.ad-table tbody tr', { hasText: newOwner })
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
await expect(row).toContainText('未设密码')
|
||||||
|
await expect(row).not.toContainText('ADMIN')
|
||||||
|
|
||||||
|
// 界面说明与后端流程一致(激活 Key 由服务器本地 CLI 发放)。
|
||||||
|
await expect(page.locator('.ad-create .ad-hint')).toContainText('初始密码')
|
||||||
|
await expect(page.locator('.ad-create .ad-hint')).not.toContainText('user initialize-account')
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// 增量 3 验收(2026-10-12 诊断报告):改密弹窗表单语义——<form onSubmit> 承载提交
|
||||||
|
//(Enter 可提交)、autocomplete 三段标注(username/current-password/new-password)、
|
||||||
|
// 只读用户名关联、错误文案复用 humanChangePasswordError(401 不再一刀切)。
|
||||||
|
// 驱动:scripts/e2e.sh 第 4 阶段(E2E_PLAIN_BASE=1)——表单语义与命令面无关,
|
||||||
|
// 任意 auth-on 服务可验,普通基座段顺路覆盖;RDM 组合主跑(第 3 阶段)本文件自跳过。
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||||
|
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||||
|
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||||
|
|
||||||
|
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||||
|
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
|
||||||
|
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
// 受限设密门(2026-10-13 合同):heading 唯一定位(标题与按钮同名),
|
||||||
|
// 门挂载后登录卡已卸载,fill 不再与登录卡竞态。
|
||||||
|
const gate = page.getByRole('heading', { name: '设置新密码' })
|
||||||
|
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||||
|
if (gated) {
|
||||||
|
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||||
|
await pwInputs.nth(0).fill(PASSWORD2)
|
||||||
|
await pwInputs.nth(1).fill(PASSWORD2)
|
||||||
|
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||||
|
// 设密成功后服务端销毁会话:等成功页再回登录(在途点取消会被会话复验拒绝)。
|
||||||
|
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||||
|
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
} else {
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
}
|
||||||
|
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
test('改密弹窗:form 提交、autocomplete/用户名关联、错误分类文案', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
await page.locator('.tsb-user').click()
|
||||||
|
await page.getByRole('button', { name: '修改密码…' }).click()
|
||||||
|
const dlg = page.getByRole('dialog', { name: '修改密码' })
|
||||||
|
await expect(dlg).toBeVisible()
|
||||||
|
|
||||||
|
// 用户名只读关联 + 三段 autocomplete。
|
||||||
|
const username = dlg.locator('input[autocomplete="username"]')
|
||||||
|
await expect(username).toHaveValue(OWNER)
|
||||||
|
// 本仓 @playwright/test 无 toBeReadOnly:以 readonly 属性断言只读关联。
|
||||||
|
await expect(username).toHaveAttribute('readonly', '')
|
||||||
|
await expect(dlg.locator('input[autocomplete="current-password"]')).toHaveCount(1)
|
||||||
|
await expect(dlg.locator('input[autocomplete="new-password"]')).toHaveCount(2)
|
||||||
|
|
||||||
|
// 表单语义:填错当前密码后按 Enter(不点按钮)提交——证明提交走 <form onSubmit>。
|
||||||
|
await dlg.locator('input[autocomplete="current-password"]').fill('definitely-wrong-pass')
|
||||||
|
await dlg.locator('input[autocomplete="new-password"]').first().fill('ANewPass#2026x')
|
||||||
|
await dlg.locator('input[autocomplete="new-password"]').nth(1).fill('ANewPass#2026x')
|
||||||
|
await dlg.locator('input[autocomplete="current-password"]').press('Enter')
|
||||||
|
// 后端 401 "current password incorrect" → humanChangePasswordError 的分类文案。
|
||||||
|
await expect(dlg.getByText('当前密码不正确')).toBeVisible({ timeout: 10_000 })
|
||||||
|
})
|
||||||
@@ -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,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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// 管理员发放一次性登录密码与登录入口回归:管理员生成一次性密码
|
||||||
|
// → 用户密码登录 → 强制改密;登录页不再提供一次性 Key 登录 tab。
|
||||||
|
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
||||||
|
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
||||||
|
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const ADMIN = process.env.E2E_ADMIN || 'e2e-admin'
|
||||||
|
const ADMIN_BOOT = process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin#2026'
|
||||||
|
const ADMIN_PW = process.env.E2E_ADMIN_PASSWORD2 || 'E2eAdmin#2026b'
|
||||||
|
const USER = process.env.E2E_LOGIN_KEY_USER || 'e2e-user'
|
||||||
|
const USER_PW = process.env.E2E_LOGIN_KEY_PASSWORD2 || 'E2eUser#2026b'
|
||||||
|
|
||||||
|
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||||
|
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (auth-on instance required)')
|
||||||
|
|
||||||
|
async function passwordLogin(page: Page, owner: string, password: string) {
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByPlaceholder('由管理员分配').fill(owner)
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(password)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理员登录;初始密码(must-change)则先完成受限设密再以新密码重登。
|
||||||
|
* 初始密码单次有效:已跑过本用例的环境(boot 已被改掉)自动改用新密码。 */
|
||||||
|
async function adminLogin(page: Page) {
|
||||||
|
await passwordLogin(page, ADMIN, ADMIN_BOOT)
|
||||||
|
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位,
|
||||||
|
// getByText 双命中会触发 strict-mode 抛错被 catch 吞掉、误判 gated=false。
|
||||||
|
let gate = page.getByRole('heading', { name: '设置新密码' })
|
||||||
|
let gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||||
|
if (!gated) {
|
||||||
|
// boot 密码失败(此前运行已完成改密):换正式密码重登。
|
||||||
|
const loginErr = await page.locator('.lg-err').waitFor({ state: 'visible', timeout: 3_000 }).then(() => true).catch(() => false)
|
||||||
|
if (!loginErr) throw new Error('admin login neither gated nor errored')
|
||||||
|
await passwordLogin(page, ADMIN, ADMIN_PW)
|
||||||
|
gate = page.getByRole('heading', { name: '设置新密码' })
|
||||||
|
gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||||
|
}
|
||||||
|
if (gated) {
|
||||||
|
const pw = page.locator('.lg-card input[type="password"]')
|
||||||
|
await pw.nth(0).fill(ADMIN_PW)
|
||||||
|
await pw.nth(1).fill(ADMIN_PW)
|
||||||
|
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||||
|
// 等设密事务完成再回登录:提交在途时点取消会登出受限会话、
|
||||||
|
// 服务端按会话复验拒绝该次设密(产品正确行为,测试必须同步成功页)。
|
||||||
|
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||||
|
await passwordLogin(page, ADMIN, ADMIN_PW)
|
||||||
|
}
|
||||||
|
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
test('管理员生成一次性登录密码:用户登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
||||||
|
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
// 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。
|
||||||
|
await adminLogin(page)
|
||||||
|
await page.locator('.tsb-user').click()
|
||||||
|
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||||
|
const zone = page.getByRole('dialog', { name: '系统管理' })
|
||||||
|
await expect(zone).toBeVisible()
|
||||||
|
const row = zone.locator('tr', { hasText: USER })
|
||||||
|
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
|
||||||
|
const temporaryPassword = zone.locator('input[aria-label="一次性密码"]')
|
||||||
|
await expect(temporaryPassword).toBeVisible({ timeout: 10_000 })
|
||||||
|
const password = await temporaryPassword.inputValue()
|
||||||
|
expect(password.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// 2) 管理员退出系统管理与登录态,回到登录页。
|
||||||
|
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
|
||||||
|
await zone.getByRole('button', { name: '返回工作台' }).click()
|
||||||
|
await page.locator('.tsb-user').click()
|
||||||
|
await page.getByRole('button', { name: '退出登录' }).click()
|
||||||
|
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
|
// 3) 用户使用临时密码登录后进入强制改密门。
|
||||||
|
await passwordLogin(page, USER, password)
|
||||||
|
const gate = page.locator('.lg-card')
|
||||||
|
await expect(gate.getByText(`账号 ${USER}`)).toBeVisible({ timeout: 10_000 })
|
||||||
|
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(1)
|
||||||
|
const pw = gate.locator('input[type="password"]')
|
||||||
|
await pw.nth(0).fill(password)
|
||||||
|
await pw.nth(1).fill(USER_PW)
|
||||||
|
await pw.nth(2).fill(USER_PW)
|
||||||
|
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||||
|
|
||||||
|
// 4) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||||
|
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||||
|
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// 5) 新密码登录进工作台。
|
||||||
|
await passwordLogin(page, USER, USER_PW)
|
||||||
|
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||||
|
|
||||||
|
// 6) 登录页不再提供一次性 Key 登录入口。
|
||||||
|
await page.locator('.tsb-user').click()
|
||||||
|
await page.getByRole('button', { name: '退出登录' }).click()
|
||||||
|
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||||
|
await expect(page.getByRole('tab', { name: '一次性登录 Key' })).toHaveCount(0)
|
||||||
|
await expect(page.getByPlaceholder('alk_…')).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// 增量 2 验收(2026-10-12 诊断报告):普通基座(无 rdm.inspect.* 命令面)不展示
|
||||||
|
// 「追溯审计」入口,且不发出任何 rdm.* 命令请求——消除「入口可达、进入即 400」。
|
||||||
|
// 驱动:scripts/e2e.sh 第 4 阶段——RDM 组合验收跑完后,同一数据目录换基座 CLI
|
||||||
|
//(agent-system serve,同源同 pin)服务,同一 preview 指向它,只跑本文件。
|
||||||
|
// 环境契约与 rdm-audit.playwright.ts 一致(e2e.sh 注入)。
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||||
|
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||||
|
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||||
|
|
||||||
|
// 仅第 4 阶段(普通基座)启用:RDM 组合主跑(第 3 阶段)扫全目录时本文件自跳过,
|
||||||
|
// 否则「入口隐藏」断言会跑在入口本就可见的 RDM 组合上。
|
||||||
|
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||||
|
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
|
||||||
|
|
||||||
|
// 与 rdm-audit spec 同一账号:若数据库仍是初始密码则先过强制改密门。
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
const gate = page.getByText('修改密码')
|
||||||
|
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||||
|
if (gated) {
|
||||||
|
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||||
|
await pwInputs.nth(0).fill(PASSWORD)
|
||||||
|
await pwInputs.nth(1).fill(PASSWORD2)
|
||||||
|
await pwInputs.nth(2).fill(PASSWORD2)
|
||||||
|
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||||
|
} else {
|
||||||
|
// 已改密(RDM 阶段完成):改用新密码重登。
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('普通基座:命令面无 rdm.inspect.* → 审计入口隐藏、无 rdm 请求', async ({ page }) => {
|
||||||
|
const rdmRequests: string[] = []
|
||||||
|
page.on('request', r => {
|
||||||
|
if (r.url().includes('/api/commands/rdm.')) rdmRequests.push(r.url())
|
||||||
|
})
|
||||||
|
await login(page)
|
||||||
|
// 登录成功标志:侧栏常驻「代理」与「文件」体系工具(均为无条件内置)。
|
||||||
|
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||||
|
await expect(page.locator('.tsb-tool', { hasText: '文件' })).toBeVisible()
|
||||||
|
// 能力探测(GET /api/commands)返回普通基座清单后,审计入口保持隐藏。
|
||||||
|
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
|
||||||
|
// 入口不存在则不应有任何 rdm.* 命令请求(原缺陷路径是进入即 400)。
|
||||||
|
expect(rdmRequests).toEqual([])
|
||||||
|
})
|
||||||
@@ -71,7 +71,7 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
|
|||||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||||
await page.locator('.rdm-audit-tree-node').first().click()
|
await page.locator('.rdm-audit-tree-node').first().click()
|
||||||
const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node')
|
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 expect(sectionNode).toBeVisible()
|
||||||
await sectionNode.click()
|
await sectionNode.click()
|
||||||
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
|
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
|
||||||
@@ -123,4 +123,102 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
|
|||||||
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
|
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('概览导出:生成后显示水位与校验和,并可下载', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
await openAudit(page)
|
||||||
|
const exportBox = page.locator('.rdm-audit-export')
|
||||||
|
await expect(exportBox).toBeVisible()
|
||||||
|
await exportBox.getByLabel('导出类型').selectOption('cgraph-docx')
|
||||||
|
await exportBox.getByRole('button', { name: '生成', exact: true }).click()
|
||||||
|
const draft = exportBox.getByRole('button', { name: '生成草稿' })
|
||||||
|
if (await draft.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true).catch(() => false)) {
|
||||||
|
await draft.click()
|
||||||
|
}
|
||||||
|
await expect(exportBox.getByText(/水位 \d+/)).toBeVisible({ timeout: 15_000 })
|
||||||
|
await expect(exportBox.getByText(/sha256 [0-9a-f]{64}/)).toBeVisible()
|
||||||
|
await expect(exportBox.getByText(/有效期/)).toBeVisible()
|
||||||
|
const download = page.waitForEvent('download')
|
||||||
|
await exportBox.getByRole('button', { name: '下载' }).click()
|
||||||
|
const file = await download
|
||||||
|
expect(file.suggestedFilename()).toMatch(/\.docx$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// W10 审计工作台(rdm.audit.operations + rdm.trace.coverage/pending-recheck)。
|
||||||
|
// 需真实组合:RDM 二进制含 W8b/W5 命令面(SVC_REAL_RDM_BIN 指向该构建)且 serve 以 auth on 运行;
|
||||||
|
// e2e.sh 默认从 RDM origin/main 构建,未必含这些命令——缺 SVC_REAL_RDM_BIN 时整组跳过,不伪造结果。
|
||||||
|
const OPS_ROUTE = '**/api/commands/rdm.audit.operations'
|
||||||
|
|
||||||
|
async function openOperations(page: Page) {
|
||||||
|
await login(page)
|
||||||
|
await openAudit(page)
|
||||||
|
await page.getByRole('tab', { name: '审计记录' }).click()
|
||||||
|
await expect(page.getByText('正在读取审计记录…')).toHaveCount(0, { timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改写请求体的 params(命令总线请求体为 { params, ... })。 */
|
||||||
|
const withParams = (raw: string | null, patch: Record<string, unknown>) => {
|
||||||
|
const body = JSON.parse(raw || '{}') as { params?: Record<string, unknown> }
|
||||||
|
return JSON.stringify({ ...body, params: { ...(body.params || {}), ...patch } })
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.serial('W10 审计工作台:审计记录', () => {
|
||||||
|
test.skip(!process.env.SVC_REAL_RDM_BIN, '需真实 RDM 二进制(SVC_REAL_RDM_BIN,含 rdm.audit.operations / rdm.trace.*)与 auth-on 组合环境')
|
||||||
|
|
||||||
|
test('列表呈现业务摘要与受控覆盖,行展开见字段差异与来源链', async ({ page }) => {
|
||||||
|
await openOperations(page)
|
||||||
|
await expect(page.getByText('受控追溯覆盖').or(page.getByText(/受控覆盖统计暂不可用/))).toBeVisible()
|
||||||
|
const rows = page.locator('.rdm-audit-op')
|
||||||
|
await expect(rows.first()).toBeVisible()
|
||||||
|
await expect(page.getByText(/已显示 \d+ \/ 共 \d+ 条 · 截至水位 \d+/)).toBeVisible()
|
||||||
|
await rows.first().locator('.mk-col-trigger').click()
|
||||||
|
await expect(rows.first().getByText('字段差异')).toBeVisible()
|
||||||
|
await expect(rows.first().getByText('来源与确认链')).toBeVisible()
|
||||||
|
await expect(rows.first().getByText('影响(当时与当前)')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('游标翻页:每页一条时加载更多追加下一条', async ({ page }) => {
|
||||||
|
await page.route(OPS_ROUTE, route => route.continue({ postData: withParams(route.request().postData(), { limit: 1 }) }))
|
||||||
|
await openOperations(page)
|
||||||
|
const rows = page.locator('.rdm-audit-op')
|
||||||
|
await expect(rows).toHaveCount(1)
|
||||||
|
const meta = await page.getByText(/已显示 \d+ \/ 共 \d+ 条/).textContent()
|
||||||
|
const total = Number(/共 (\d+) 条/.exec(meta || '')?.[1] || 0)
|
||||||
|
test.skip(total < 2, `种子项目仅 ${total} 条审计记录,无法验证翻页`)
|
||||||
|
await page.getByRole('button', { name: '加载更多记录' }).click()
|
||||||
|
await expect(rows).toHaveCount(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('游标失效:续页报明确错误,可从第一页重新查询', async ({ page }) => {
|
||||||
|
await page.route(OPS_ROUTE, async route => {
|
||||||
|
const body = JSON.parse(route.request().postData() || '{}') as { params?: { cursor?: string } }
|
||||||
|
if (body.params?.cursor) {
|
||||||
|
await route.fulfill({ status: 200, contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ receipt: { state: 'rejected', error: '操作记录游标已失效:游标绑定主体不符——请从首页重新查询' } }) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await route.fetch({ postData: withParams(route.request().postData(), { limit: 1 }) })
|
||||||
|
const json = await res.json() as { payload?: Record<string, unknown> } & Record<string, unknown>
|
||||||
|
const pageBody = (json.payload ?? json) as Record<string, unknown>
|
||||||
|
pageBody.hasMore = true
|
||||||
|
pageBody.nextCursor = 'stale-cursor'
|
||||||
|
await route.fulfill({ response: res, json })
|
||||||
|
})
|
||||||
|
await openOperations(page)
|
||||||
|
await page.getByRole('button', { name: '加载更多记录' }).click()
|
||||||
|
await expect(page.getByText(/翻页位置已失效/)).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: '加载更多记录' })).toHaveCount(0)
|
||||||
|
await page.getByRole('button', { name: '从第一页重新查询' }).click()
|
||||||
|
await expect(page.getByText(/翻页位置已失效/)).toHaveCount(0)
|
||||||
|
await expect(page.locator('.rdm-audit-op')).toHaveCount(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('无审计授权:首页错误态可读', async ({ page }) => {
|
||||||
|
await page.route(OPS_ROUTE, route => route.fulfill({ status: 200, contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ receipt: { state: 'rejected', error: '权限拒绝:无项目审计授权' } }) }))
|
||||||
|
await openOperations(page)
|
||||||
|
await expect(page.getByText(/你没有该项目的审计授权/)).toBeVisible()
|
||||||
|
await expect(page.getByText('原始回执')).toBeVisible()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// I4 组合验收(Playwright):文档驾驶舱七场景(rollout §3.I4 最小场景集)。
|
||||||
|
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制(含 I1/I3 命令面)+ frontend dist,
|
||||||
|
// 种子:审计演示项目(rdm-self + cockpit-e2e 结构装载;两个章节经草稿流落正文;
|
||||||
|
// 一个章节留空;第二个账号 e2e-reader 不入项目做权限负例)。
|
||||||
|
// 环境契约与 rdm-audit.playwright.ts 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过。
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||||
|
const PASSWORD = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||||
|
const READER = process.env.E2E_READER || 'e2e-reader'
|
||||||
|
const READER_PASSWORD = process.env.E2E_READER_PASSWORD || 'E2eReader#2026'
|
||||||
|
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
|
||||||
|
|
||||||
|
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
|
||||||
|
|
||||||
|
async function login(page: Page, user: string, password: string) {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
|
||||||
|
await page.getByPlaceholder('由管理员分配').fill(user)
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(password)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 侧栏入口两种形态:常规侧栏按钮(.tsb-tool);窄宽紧凑布局换图标栏
|
||||||
|
// (.icon-only 内 title 以「文档驾驶舱」开头的按钮)——入口都可达。
|
||||||
|
function cockpitEntry(page: Page) {
|
||||||
|
return page.locator('.tsb-tool', { hasText: '文档驾驶舱' })
|
||||||
|
.or(page.locator('.icon-only button[title^="文档驾驶舱"]')).first()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginOwner(page: Page) {
|
||||||
|
await login(page, OWNER, PASSWORD)
|
||||||
|
// 登录后身份纪元与能力探针仍可能各跳一次;应用在身份变更时会关闭已开
|
||||||
|
// 工作区——等落定再点入口,避免 overlay 被身份刷新关掉(竞态实测)。
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {})
|
||||||
|
await page.waitForTimeout(1_500)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCockpit(page: Page) {
|
||||||
|
// 窄宽是单窗格布局:登录后默认「对话」窗格,先切到「上下文」窗格。
|
||||||
|
if (!(await cockpitEntry(page).isVisible().catch(() => false))) {
|
||||||
|
await page.getByRole('button', { name: '打开上下文' }).click()
|
||||||
|
}
|
||||||
|
await cockpitEntry(page).click()
|
||||||
|
await expect(page.locator('.cockpit-project')).toBeVisible({ timeout: 15_000 })
|
||||||
|
// 默认选中首个项目(种子=审计演示项目)。
|
||||||
|
await expect(page.locator('.cockpit-stats')).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReaderOnDoc(page: Page, title: string) {
|
||||||
|
await page.locator('.cockpit-tree-title', { hasText: title }).first().click()
|
||||||
|
await page.getByRole('button', { name: '在阅读器中打开' }).click()
|
||||||
|
await expect(page.locator('.cockpit-reader')).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.serial('I4 文档驾驶舱组合验收', () => {
|
||||||
|
// 场景 1:首次进入项目——统计带、结构树、全景可见(DV-R01/R02)。
|
||||||
|
test('1 首屏:统计、树、全景可见且统计可判读', async ({ page }) => {
|
||||||
|
await loginOwner(page)
|
||||||
|
await openCockpit(page)
|
||||||
|
// placeholder
|
||||||
|
await expect(page.locator('.cockpit-stat', { hasText: '完成度' })).toBeVisible()
|
||||||
|
await expect(page.locator('.cockpit-stat', { hasText: '文档' })).toBeVisible()
|
||||||
|
// 空 vs 不可算不得互相冒充:有数据项目的空缺计数显示数字。
|
||||||
|
await expect(page.locator('.cockpit-stat', { hasText: '空缺节点' }).locator('.cockpit-stat-num')).toHaveText(/^\d+$/)
|
||||||
|
await expect(page.locator('.cockpit-tree-title', { hasText: '验证主体报告' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByText('文档概览')).toBeVisible()
|
||||||
|
await expect(page.locator('.cockpit-snap')).toContainText(/快照 wm:\d+/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 场景 2:文档→章节,全景与阅读器双向定位(DV-R03)。
|
||||||
|
test('2 定位:树选章节→阅读器正文;返回全景仍同一节点', async ({ page }) => {
|
||||||
|
await loginOwner(page)
|
||||||
|
await openCockpit(page)
|
||||||
|
// 展开目标文档(树按标题排序,branch 作用域防跨文档串扰),点「概述」。
|
||||||
|
const branch = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
|
||||||
|
await branch.locator('.cockpit-tree-caret').click()
|
||||||
|
const sec = branch.locator('.cockpit-tree-title', { hasText: '概述' }).first()
|
||||||
|
await expect(sec).toBeVisible()
|
||||||
|
sec.click()
|
||||||
|
await expect(page.locator('.cockpit-nodehead')).toBeVisible()
|
||||||
|
// 全景→阅读器:同一 nodeRef(阅读器显示该节标题与正文)。
|
||||||
|
await page.getByRole('button', { name: '在阅读器中打开' }).click()
|
||||||
|
await expect(page.locator('.cockpit-reader')).toBeVisible()
|
||||||
|
await expect(page.locator('.cockpit-nodetitle', { hasText: '概述' })).toBeVisible()
|
||||||
|
const body = page.locator('.cockpit-reader-para', { hasText: '本报告' }).first()
|
||||||
|
await expect(body).toBeVisible()
|
||||||
|
// 阅读器→全景:返回后仍是选中节点(节点头仍在)。
|
||||||
|
await page.getByRole('button', { name: '← 返回全景' }).click()
|
||||||
|
await expect(page.locator('.cockpit-nodehead')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 场景 3:四面板展开并正确显示计数;受限与空态诚实(DV-R04)。
|
||||||
|
test('3 面板:来源/历史/影响/冲突计数与空态、截断旗标', async ({ page }) => {
|
||||||
|
await loginOwner(page)
|
||||||
|
await openCockpit(page)
|
||||||
|
const branch = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
|
||||||
|
await branch.locator('.cockpit-tree-caret').click()
|
||||||
|
await branch.locator('.cockpit-tree-title', { hasText: '概述' }).first().click()
|
||||||
|
const panel = page.locator('.cockpit-panel')
|
||||||
|
await expect(panel).toBeVisible()
|
||||||
|
// 来源:草稿落版本挂了依据切片(种子经 ingest 提供)。
|
||||||
|
await panel.getByRole('tab', { name: '内容来源' }).click()
|
||||||
|
await expect(panel.locator('.cockpit-panel-count')).toContainText(/来源 \d+ 条/)
|
||||||
|
// 历史:已提交事件(structure + draft 链)。
|
||||||
|
await panel.getByRole('tab', { name: '修改历史' }).click()
|
||||||
|
await expect(panel.locator('.cockpit-panel-count')).toContainText('已提交事件')
|
||||||
|
await expect(panel.locator('.cockpit-panel-row').first()).toBeVisible()
|
||||||
|
// 影响:一跳;空/计数二态都诚实。
|
||||||
|
await panel.getByRole('tab', { name: '影响对象' }).click()
|
||||||
|
await expect(panel.locator('.cockpit-panel-count')).toContainText(/影响 \d+ 项/)
|
||||||
|
// 冲突:种子无开放碰撞 → 明确「无开放冲突」而非空白。
|
||||||
|
await panel.getByRole('tab', { name: '冲突与风险' }).click()
|
||||||
|
await expect(panel.getByText('无开放冲突')).toBeVisible()
|
||||||
|
// 版本块(I3):当前版本+差异折叠入口可见。
|
||||||
|
await expect(page.locator('.cockpit-versions-toggle')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 场景 4:后台变更后所有投影一致(DV-R05)——测试经 API 采纳新版本,
|
||||||
|
// 驾驶舱刷新后统计水位/树时间/详情版本块同步前进。
|
||||||
|
test('4 一致性:API 采纳新版本后,刷新入口与各投影同步', async ({ page }) => {
|
||||||
|
await loginOwner(page)
|
||||||
|
await openCockpit(page)
|
||||||
|
const branch4 = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
|
||||||
|
await branch4.locator('.cockpit-tree-caret').click()
|
||||||
|
await branch4.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
|
||||||
|
const snapBefore = await page.locator('.cockpit-snap').innerText()
|
||||||
|
// 通过应用自身 API 落一个已提交变更(需求账本新增条目,唯一键防幂等跳过;
|
||||||
|
// 采纳走高危确认卡、服务端不允许客户端伪造 Confirmed,故用低危提交面驱动)。
|
||||||
|
await page.request.post('/api/commands/rdm.reqs.import', {
|
||||||
|
data: { params: { projectId: await page.locator('.cockpit-project').inputValue(),
|
||||||
|
items: [{ kind: 'UR', title: `UR-PROBE-${Date.now()} 一致性探针`, statement: '刷新一致性驱动条目', source: 'e2e-probe' }] } },
|
||||||
|
failOnStatusCode: false,
|
||||||
|
})
|
||||||
|
await page.getByRole('button', { name: '刷新' }).click()
|
||||||
|
await expect(page.locator('.cockpit-snap')).not.toHaveText(snapBefore)
|
||||||
|
// 版本块前进:出现新版本条目。
|
||||||
|
await expect(page.locator('.cockpit-versions-toggle')).toContainText(/版本 v\d+/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 场景 5:断线/失败保留旧快照并给出重试(DV-R08)——page.route 掐断命令面。
|
||||||
|
test('5 失败:命令面断连时保留旧快照并出现重试入口', async ({ page }) => {
|
||||||
|
await loginOwner(page)
|
||||||
|
await openCockpit(page)
|
||||||
|
await expect(page.locator('.cockpit-stats')).toBeVisible()
|
||||||
|
// 掐断后续命令面:切换项目触发重取 → 失败横幅 + 旧内容保留。
|
||||||
|
await page.route('**/api/commands/rdm.cockpit.**', route => route.abort())
|
||||||
|
await page.locator('.cockpit-refresh').click()
|
||||||
|
await expect(page.locator('.cockpit-banner-err')).toBeVisible()
|
||||||
|
await expect(page.locator('.cockpit-stats')).toBeVisible() // 旧快照未被清空
|
||||||
|
await page.unroute('**/api/commands/rdm.cockpit.**')
|
||||||
|
await page.locator('.cockpit-refresh').click()
|
||||||
|
await expect(page.locator('.cockpit-banner-err')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 场景 6:权限负例——非成员账号看不到项目(无项目诚实空态;不泄露存在性)。
|
||||||
|
test('6 权限:非成员账号无项目可见', async ({ page }) => {
|
||||||
|
await login(page, READER, READER_PASSWORD)
|
||||||
|
await page.locator('.tsb-tool', { hasText: '文档驾驶舱' }).click()
|
||||||
|
await expect(page.getByText('暂无可见研发项目')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await expect(page.locator('.cockpit-tree-title', { hasText: '验证主体报告' })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 场景 7:窄屏、键盘、减少动效(DV-R07)。
|
||||||
|
test('7 可用性:390 宽无横向溢出、键盘可达、减少动效可用', async ({ page }) => {
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 })
|
||||||
|
await loginOwner(page)
|
||||||
|
await openCockpit(page)
|
||||||
|
// 无横向溢出。
|
||||||
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||||
|
expect(overflow).toBeLessThanOrEqual(1)
|
||||||
|
// 键盘:统计卡/树行可聚焦(tab 序含驾驶舱交互件)。
|
||||||
|
await page.locator('.cockpit-tree-title').first().focus()
|
||||||
|
await expect(page.locator('.cockpit-tree-title').first()).toBeFocused()
|
||||||
|
// 18px 大字(html font-size 注入)不破坏统计带布局。
|
||||||
|
await page.addStyleTag({ content: 'html{font-size:18px !important}' })
|
||||||
|
await expect(page.locator('.cockpit-stats')).toBeVisible()
|
||||||
|
const overflowBig = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||||
|
expect(overflowBig).toBeLessThanOrEqual(8)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// 文档职责三视图:确定性 HTTP,不连接真实账号或数据库。
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void
|
||||||
|
const promise = new Promise<void>(ok => { resolve = ok })
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dutiesPage(page: Page) {
|
||||||
|
const writes: { command: string; params: Record<string, unknown> }[] = []
|
||||||
|
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
|
||||||
|
let generation = 0
|
||||||
|
const commands = ['rdm.project.member.list', 'rdm.duty.matrix', 'rdm.duty.preview',
|
||||||
|
'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.scan', 'rdm.duty.legacy.apply']
|
||||||
|
await page.route('**/api/**', async route => {
|
||||||
|
const request = route.request()
|
||||||
|
const path = new URL(request.url()).pathname
|
||||||
|
let body: unknown = []
|
||||||
|
if (path === '/api/health') body = { ok: true }
|
||||||
|
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: true }
|
||||||
|
else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '',
|
||||||
|
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
|
||||||
|
else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} }
|
||||||
|
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
|
||||||
|
else if (path.startsWith('/api/commands/')) {
|
||||||
|
const command = decodeURIComponent(path.substring('/api/commands/'.length))
|
||||||
|
const params = request.postDataJSON().params as Record<string, unknown>
|
||||||
|
expect(params.projectId).toBe('domain-7')
|
||||||
|
const hold = holds.get(command)
|
||||||
|
if (hold) { hold.started.resolve(); await hold.release.promise }
|
||||||
|
const author = generation === 0 ? 'alice' : 'bob'
|
||||||
|
if (command === 'rdm.duty.matrix') {
|
||||||
|
body = { payload: {
|
||||||
|
canAssign: true,
|
||||||
|
docs: [{ docId: 'doc-A', code: 'A', title: '文档A', typeKey: 'design', stage: 'BD', dutyVersion: 1, authors: 1, reviewers: 1, approvers: 1, conflicts: 0, legacySectionOverrides: 0 }],
|
||||||
|
members: ['alice', 'bob', 'carol'].map(owner => ({ owner, role: 'member', valid: true, canAuthor: true, canReview: true, canApprove: true })),
|
||||||
|
cells: [
|
||||||
|
{ docId: 'doc-A', owner: 'alice', duty: author === 'alice' ? 'author' : 'none' },
|
||||||
|
{ docId: 'doc-A', owner: 'bob', duty: author === 'bob' ? 'author' : 'reviewer' },
|
||||||
|
{ docId: 'doc-A', owner: 'carol', duty: 'approver' },
|
||||||
|
],
|
||||||
|
} }
|
||||||
|
} else if (command === 'rdm.duty.preview') {
|
||||||
|
writes.push({ command, params })
|
||||||
|
body = { payload: { ok: true, docs: [{ docId: 'doc-A', title: '文档A', replacedAuthor: 'alice' }] } }
|
||||||
|
} else if (command === 'rdm.duty.legacy.scan') {
|
||||||
|
body = { payload: { findings: [{ docId: 'doc-A', title: '文档A', sectionTitle: '范围', kind: 'ambiguous' }] } }
|
||||||
|
} else {
|
||||||
|
writes.push({ command, params })
|
||||||
|
generation += 1
|
||||||
|
body = { payload: { applied: 1 } }
|
||||||
|
}
|
||||||
|
} else if (path.endsWith('/cards')) body = { cards: [] }
|
||||||
|
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||||
|
})
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByRole('button', { name: '项目设置', exact: true }).click()
|
||||||
|
await page.getByRole('tab', { name: '文档职责', exact: true }).click()
|
||||||
|
await expect(page.getByText('本页只分配已有文档的职责,不创建或生成文档。')).toBeVisible()
|
||||||
|
return { writes, hold(command: string) {
|
||||||
|
const pending = { started: deferred(), release: deferred() }
|
||||||
|
holds.set(command, pending)
|
||||||
|
return pending
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('three views share one draft and do not offer review actions', async ({ page }) => {
|
||||||
|
const fixture = await dutiesPage(page)
|
||||||
|
await page.getByLabel('选择成员').selectOption('bob')
|
||||||
|
await page.getByLabel('bob 在 文档A 的职责').selectOption({ label: '编制(将替换alice)' })
|
||||||
|
await page.getByRole('tab', { name: '按文档' }).click()
|
||||||
|
await page.getByLabel('选择文档').selectOption('doc-A')
|
||||||
|
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
|
||||||
|
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
|
||||||
|
await page.getByRole('tab', { name: '矩阵' }).click()
|
||||||
|
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
|
||||||
|
await expect(page.getByRole('button', { name: /提交|审核通过|驳回|终批|生成|新建文档/ })).toHaveCount(0)
|
||||||
|
await page.getByRole('button', { name: '保存职责' }).click()
|
||||||
|
await expect.poll(() => fixture.writes.map(item => item.command)).toEqual(['rdm.duty.preview', 'rdm.review.assign.doc.batch'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a stale matrix response does not replace the current draft', async ({ page }) => {
|
||||||
|
const fixture = await dutiesPage(page)
|
||||||
|
await page.getByLabel('选择成员').selectOption('alice')
|
||||||
|
const held = fixture.hold('rdm.duty.matrix')
|
||||||
|
await page.getByLabel('筛选文档名称或编号').fill('文档A')
|
||||||
|
await held.started.promise
|
||||||
|
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
|
||||||
|
held.release.resolve()
|
||||||
|
await expect(page.getByLabel('alice 在 文档A 的职责')).toHaveValue('reviewer')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a narrow viewport points away from the full matrix', async ({ page }) => {
|
||||||
|
await dutiesPage(page)
|
||||||
|
await page.setViewportSize({ width: 390, height: 800 })
|
||||||
|
await page.getByRole('tab', { name: '矩阵' }).click()
|
||||||
|
await expect(page.getByText('当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。')).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Isolated browser component regression: actual React workspace, deliberately deferred API responses.
|
||||||
|
// This checks client request ownership, not server authorization or business acceptance.
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
test.skip(!process.env.PLAYWRIGHT_COMPONENT_BASE_URL, 'requires the isolated Vite component test server')
|
||||||
|
test.use({ baseURL: process.env.PLAYWRIGHT_COMPONENT_BASE_URL })
|
||||||
|
|
||||||
|
async function mount(page: Page, delayed: string) {
|
||||||
|
const index = await page.request.get('/')
|
||||||
|
const html = (await index.text()).replace(/<script[^>]+src="\/src\/main\.tsx[^" ]*"[^>]*><\/script>/, '')
|
||||||
|
// Let Vite prepare ReactDOM before requesting its optimized module directly.
|
||||||
|
await page.request.get('/src/main.tsx')
|
||||||
|
await page.route('**/__export_test', route => route.fulfill({ contentType: 'text/html',
|
||||||
|
body: html }))
|
||||||
|
await page.goto('/__export_test')
|
||||||
|
await page.waitForFunction(() => typeof (window as any).$RefreshReg$ === 'function')
|
||||||
|
await page.evaluate(async delayed => {
|
||||||
|
const load = (path: string) => import(/* @vite-ignore */ path)
|
||||||
|
const { default: React } = await load('/node_modules/.vite/deps/react.js')
|
||||||
|
const { default: ReactDOM } = await load('/node_modules/.vite/deps/react-dom_client.js')
|
||||||
|
const { RdmAuditWorkspace } = await load('/src/components/RdmAuditWorkspace.tsx')
|
||||||
|
const { api } = await load('/src/api.ts')
|
||||||
|
const { auth } = await load('/src/auth.ts')
|
||||||
|
const { store } = await load('/src/store.ts')
|
||||||
|
const { RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } = await load('/src/workspaces.ts')
|
||||||
|
const calls: { command: string; params: any }[] = []
|
||||||
|
let actor = 'alice'
|
||||||
|
const pending: (() => void)[] = []
|
||||||
|
let downloads = 0
|
||||||
|
URL.createObjectURL = () => { downloads++; return 'blob:export-test' }
|
||||||
|
URL.revokeObjectURL = () => {}
|
||||||
|
HTMLAnchorElement.prototype.click = () => {}
|
||||||
|
api.listPlugins = async () => []
|
||||||
|
api.listWorkspaces = async () => []
|
||||||
|
api.listCommands = async () => [...RDM_AUDIT_COMMANDS, ...RDM_EXPORT_COMMANDS].map(id => ({ id, enabled: true }))
|
||||||
|
api.adminZoneSections = async () => []
|
||||||
|
api.getSettings = async () => ({ user: {} })
|
||||||
|
api.rdmCommand = async (command: string, params: any = {}) => {
|
||||||
|
calls.push({ command, params })
|
||||||
|
const result = command === 'rdm.inspect.capabilities'
|
||||||
|
? { actor, crossProject: false, projects: ['A', 'B'].map(projectId => ({ projectId, name: actor + projectId, canAudit: true })) }
|
||||||
|
: command === 'rdm.inspect.summary'
|
||||||
|
? { projectId: params.projectId, counts: {}, watermark: 7 }
|
||||||
|
: command === 'rdm.inspect.export.create'
|
||||||
|
? { exportId: params.projectId + '-report', watermark: 7, sha256: 'abc', fileName: 'report.docx' }
|
||||||
|
: command === 'rdm.inspect.export.status'
|
||||||
|
? { expiresAt: '2099-01-01', watermark: 7, sha256: 'abc' }
|
||||||
|
: { fileName: 'report.docx', mime: 'application/octet-stream', dataB64: 'QQ==', sha256: 'abc' }
|
||||||
|
if (command === delayed && params.projectId === 'A') {
|
||||||
|
await new Promise<void>(resolve => pending.push(resolve))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
await store.refreshPlugins()
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root')!)
|
||||||
|
root.render(React.createElement(RdmAuditWorkspace))
|
||||||
|
;(window as any).exportTest = { calls, release: () => pending.splice(0).forEach(resolve => resolve()),
|
||||||
|
downloads: () => downloads, identity: async () => { actor = 'bob'; auth.beginSession(); await store.refreshPlugins() },
|
||||||
|
unmount: () => root.unmount() }
|
||||||
|
}, delayed)
|
||||||
|
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||||
|
}
|
||||||
|
async function release(page: Page) {
|
||||||
|
await page.evaluate(async () => { (window as any).exportTest.release(); await new Promise(resolve => setTimeout(resolve, 0)) })
|
||||||
|
}
|
||||||
|
const generate = (page: Page) => page.getByRole('button', { name: '生成', exact: true }).click()
|
||||||
|
|
||||||
|
for (const stage of ['create', 'status']) {
|
||||||
|
test(`ignores old ${stage} response after project A -> B -> A`, async ({ page }) => {
|
||||||
|
await mount(page, 'rdm.inspect.export.' + stage)
|
||||||
|
await generate(page)
|
||||||
|
await expect.poll(() => page.evaluate(stage => (window as any).exportTest.calls
|
||||||
|
.some((c: any) => c.command === 'rdm.inspect.export.' + stage), stage)).toBe(true)
|
||||||
|
await page.getByLabel('选择项目').selectOption('B')
|
||||||
|
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||||
|
await page.getByLabel('选择项目').selectOption('A')
|
||||||
|
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||||
|
await release(page)
|
||||||
|
await expect(page.getByRole('button', { name: '下载', exact: true })).toHaveCount(0)
|
||||||
|
if (stage === 'create') expect(await page.evaluate(() => (window as any).exportTest.calls
|
||||||
|
.filter((c: any) => c.command === 'rdm.inspect.export.status').length)).toBe(0)
|
||||||
|
await expect(page.getByRole('button', { name: '生成', exact: true })).toBeEnabled()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const change of ['project', 'identity', 'unmount']) {
|
||||||
|
test(`suppresses delayed download after ${change} change`, async ({ page }) => {
|
||||||
|
await mount(page, 'rdm.inspect.export.download')
|
||||||
|
await generate(page)
|
||||||
|
await page.getByRole('button', { name: '下载', exact: true }).click()
|
||||||
|
if (change === 'project') {
|
||||||
|
await page.getByLabel('选择项目').selectOption('B')
|
||||||
|
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||||
|
} else if (change === 'identity') {
|
||||||
|
await page.evaluate(() => (window as any).exportTest.identity())
|
||||||
|
await expect(page.getByLabel('选择项目').locator('option').first()).toHaveText('bobA')
|
||||||
|
} else await page.evaluate(() => (window as any).exportTest.unmount())
|
||||||
|
await release(page)
|
||||||
|
expect(await page.evaluate(() => (window as any).exportTest.downloads())).toBe(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
test('current identity can generate and download normally', async ({ page }) => {
|
||||||
|
await mount(page, '')
|
||||||
|
await generate(page)
|
||||||
|
await page.getByRole('button', { name: '下载', exact: true }).click()
|
||||||
|
await expect.poll(() => page.evaluate(() => (window as any).exportTest.downloads())).toBe(1)
|
||||||
|
await expect(page.locator('.rdm-audit-export-result')).toContainText('水位 7')
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import type { Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts'
|
||||||
|
|
||||||
|
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'requires a served frontend bundle')
|
||||||
|
|
||||||
|
const conversation: Conversation = {
|
||||||
|
id: 'surface-reload', projectId: 'surface-project', title: '页面恢复回归',
|
||||||
|
createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z',
|
||||||
|
}
|
||||||
|
const snapshots: SurfaceSnapshot[] = ['page-1', 'page-2'].map((surfaceId, i) => ({
|
||||||
|
identity: { surfaceId, owner: 'fixture', pluginId: 'generative-ui', conversationId: conversation.id },
|
||||||
|
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1,
|
||||||
|
spec: { title: `恢复页面${i + 1}`, nodes: [{ id: 'body', type: 'markdown', markdown: `恢复正文${i + 1}` }] },
|
||||||
|
}))
|
||||||
|
const metadata: SurfaceMeta[] = snapshots.map(s => ({
|
||||||
|
surfaceId: s.identity.surfaceId, title: s.spec.title, pluginId: s.identity.pluginId,
|
||||||
|
revision: s.revision, conversationId: conversation.id, updatedAt: conversation.updatedAt,
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('saved-page reload keeps the app and restores both full snapshots', async ({ page }) => {
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on('pageerror', e => errors.push(e.message))
|
||||||
|
await page.addInitScript(({ id }) => {
|
||||||
|
localStorage.setItem('as.conv', id)
|
||||||
|
localStorage.setItem('as.ui', JSON.stringify({ rightTab: 'files', filesView: 'preview', rightCollapsed: false }))
|
||||||
|
}, { id: conversation.id })
|
||||||
|
|
||||||
|
// Replay the two distinct server contracts through the real app. Intercept all
|
||||||
|
// API requests so this regression can run against a live bundle without writes.
|
||||||
|
await page.route('**/api/**', async route => {
|
||||||
|
const request = route.request()
|
||||||
|
const path = new URL(request.url()).pathname
|
||||||
|
if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only regression' })
|
||||||
|
if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' })
|
||||||
|
let body: unknown = []
|
||||||
|
if (path === '/api/health') body = { status: 'ok' }
|
||||||
|
else if (path === '/api/me') body = { owner: 'fixture', name: 'fixture', auth: true, authVia: 'session' }
|
||||||
|
else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '恢复项目', createdAt: '', updatedAt: '' }]
|
||||||
|
else if (path === '/api/conversations' || path === `/api/projects/${conversation.projectId}/conversations`) body = [conversation]
|
||||||
|
else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }
|
||||||
|
else if (path === `/api/conversations/${conversation.id}/surfaces` || path === `/api/projects/${conversation.projectId}/surfaces`) body = metadata
|
||||||
|
else if (path.startsWith('/api/surfaces/')) {
|
||||||
|
body = snapshots.find(s => s.identity.surfaceId === path.split('/').at(-1))
|
||||||
|
} else if (path.startsWith('/api/settings')) body = { schema: [], defaults: {}, user: {}, project: {} }
|
||||||
|
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||||
|
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1')
|
||||||
|
await expect(page.locator('.preview-pane')).toContainText('恢复正文1')
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1')
|
||||||
|
await page.getByRole('button', { name: '返回文件列表' }).click()
|
||||||
|
await expect(page.locator('[data-surface-row]')).toHaveCount(2)
|
||||||
|
await page.locator('[data-surface-row="page-2"]').click()
|
||||||
|
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面2')
|
||||||
|
await expect(page.locator('.preview-pane')).toContainText('恢复正文2')
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// U7 浏览器点验(补录):R1–R8 用户可见流程——在实际 8788 入口(React 内嵌组合
|
||||||
|
// 产物)以已激活账号登录,点验体系工具入口、审计工作区、项目设置三页签(成员/
|
||||||
|
// 文档职责·整文审签/项目权限矩阵)与系统管理 RDM 角色分区。
|
||||||
|
// 驱动:PLAYWRIGHT_BASE_URL=http://127.0.0.1:8788 + 已激活账号(无 must-change 门)。
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const OWNER = process.env.E2E_OWNER || 'u7-creator'
|
||||||
|
const PASSWORD = process.env.E2E_PASSWORD || 'U7-Final-creator!7'
|
||||||
|
|
||||||
|
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (drives against the live 8788 entry)')
|
||||||
|
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('U7 浏览器点验:登录后工作台与审计入口(R1)', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
// 工作台主体出现(侧栏/会话区之一可见即视为进入)。
|
||||||
|
await expect(page.locator('body')).toContainText(/会话|代理|项目|工作台/, { timeout: 15_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('U7 浏览器点验:追溯审计工作区可进入(R1/R7)', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
const audit = page.locator('.tsb-tool', { hasText: '追溯审计' })
|
||||||
|
await expect(audit).toBeVisible({ timeout: 15_000 })
|
||||||
|
await audit.click()
|
||||||
|
await expect(page.locator('body')).toContainText(/概览|覆盖|追溯/, { timeout: 15_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('U7 浏览器点验:项目设置三页签(R2/R4/R5)', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
// 侧栏「项目设置」按钮(currentProject 就绪后出现;最多等 15s)。
|
||||||
|
const btn = page.locator('.tsb-tool', { hasText: '项目设置' })
|
||||||
|
await expect(btn).toBeVisible({ timeout: 15_000 })
|
||||||
|
await btn.click()
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
const body = await page.locator('body').innerText().catch(() => '')
|
||||||
|
const tabs = ['成员', '文档职责', '项目权限'].filter(t => body.includes(t))
|
||||||
|
test.info().annotations.push({ type: 'note', description: `project-settings tabs visible: ${tabs.join(',')}` })
|
||||||
|
expect(tabs.length).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('U7 浏览器点验:系统管理 RDM 角色分区(R3)', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
await page.waitForTimeout(1200)
|
||||||
|
const admin = page.locator('button[title*="系统管理"], button:has-text("系统管理")').first()
|
||||||
|
const visible = await admin.isVisible().catch(() => false)
|
||||||
|
test.info().annotations.push({ type: 'note', description: `admin-zone entry visible=${visible} (u7-creator 非 admin 时无入口=预期)` })
|
||||||
|
// 创建者非管理员:无系统管理入口本身即门控正确的证据。
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
const OWNER = process.env.E2E_OWNER || 'u7-creator'
|
||||||
|
const PASSWORD = process.env.E2E_PASSWORD || 'U7-Final-creator!7'
|
||||||
|
test.skip(!process.env.PLAYWRIGHT_BASE_URL)
|
||||||
|
test('u7 工作台+审计截图', async ({ page }) => {
|
||||||
|
test.setTimeout(60_000)
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
await page.waitForTimeout(1200)
|
||||||
|
await page.screenshot({ path: 'test-results/u7-01-workbench.png' })
|
||||||
|
const audit = page.locator('.tsb-tool', { hasText: '追溯审计' })
|
||||||
|
await expect(audit).toBeVisible({ timeout: 15_000 })
|
||||||
|
await audit.click()
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
await page.screenshot({ path: 'test-results/u7-02-audit.png' })
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
const OWNER = process.env.E2E_OWNER || 'u7-creator'
|
||||||
|
const PASSWORD = process.env.E2E_PASSWORD || 'U7-Final-creator!7'
|
||||||
|
test.skip(!process.env.PLAYWRIGHT_BASE_URL)
|
||||||
|
test('u7 项目设置截图', async ({ page }) => {
|
||||||
|
test.setTimeout(60_000)
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
const settings = page.locator('.tsb-tool', { hasText: '项目设置' })
|
||||||
|
await expect(settings).toBeVisible({ timeout: 20_000 })
|
||||||
|
await settings.click()
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
await page.screenshot({ path: 'test-results/u7-03-project-settings.png' })
|
||||||
|
})
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { readFileSync, readlinkSync } from 'node:fs'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
|
const manifestPath=process.env.WORK_CHECKPOINT_HTTP_MANIFEST
|
||||||
|
test.skip(!manifestPath,'Run explicitly with a task-owned mechanism instance manifest')
|
||||||
|
const manifest=manifestPath?JSON.parse(readFileSync(manifestPath,'utf8')):null
|
||||||
|
if(manifest&&(manifest.mode!=='mechanism'||!manifest.dataDir.includes('fast-slow-u2-u3-')||readlinkSync(`/proc/${manifest.pid}/exe`)!==manifest.binary||createHash('sha256').update(readFileSync(manifest.binary)).digest('hex')!==manifest.binarySHA256))throw new Error('Owned mechanism instance verification failed')
|
||||||
|
|
||||||
|
type Scenario='normal'|'fast-ack'|'queue-hold'|'slow-stages'|'zero-failure'|'partial-failure'|'choice'
|
||||||
|
async function setup(request:any,scenario:Scenario='normal',awaitTask=true,options:{text?:string,small?:boolean,startFailure?:boolean}={}){
|
||||||
|
const project=await(await request.post(manifest.base+'/api/projects',{data:{name:'browser-'+Date.now()}})).json()
|
||||||
|
const conv=await(await request.post(manifest.base+`/api/projects/${project.id}/conversations`,{data:{title:'材料整理',profileId:'checkpoint-eval'}})).json()
|
||||||
|
const ids:string[]=[]
|
||||||
|
for(let i=0;i<(options.small?1:3);i++){
|
||||||
|
const marker=i===0&&scenario!=='normal'?`[fixture:${scenario}]\n`:''
|
||||||
|
const res=await request.post(manifest.base+`/api/projects/${project.id}/uploads`,{multipart:{file:{name:`来源${i+1}.txt`,mimeType:'text/plain',buffer:Buffer.from(marker+'上线六月。验收条件需要补齐,预算50万元尚未审批。交付包含12项需求,其中3项依赖外部接口。第一期先完成8项核心需求。\n\n'+(options.small?'':('整理记录说明:保留日期、金额、限制和待确认事项。\n\n').repeat(options.text?250:100)))}}})
|
||||||
|
expect(res.ok()).toBeTruthy();ids.push((await res.json()).artifactId)
|
||||||
|
}
|
||||||
|
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:true}})).status()).toBe(204)
|
||||||
|
let sendBody:any
|
||||||
|
try{
|
||||||
|
const sent=await request.post(manifest.base+`/api/conversations/${conv.id}/messages`,{data:{text:options.text??'',attachmentIds:ids,requestId:'browser-'+Date.now()}})
|
||||||
|
expect(sent.ok()).toBeTruthy()
|
||||||
|
sendBody=await sent.json()
|
||||||
|
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${sendBody.run.id}`)).json()).run.status).toBe('completed')
|
||||||
|
}finally{
|
||||||
|
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:false}})).status()).toBe(204)
|
||||||
|
}
|
||||||
|
if(awaitTask)await expect.poll(async()=>{
|
||||||
|
const out=await(await request.get(manifest.base+`/api/conversations/${conv.id}/cards`)).json()
|
||||||
|
return out.cards.find((c:any)=>c.task)?.task.executionState
|
||||||
|
}).toBe(scenario.endsWith('failure')?'failed':'completed')
|
||||||
|
return {project,conv,ids,sendRunId:sendBody.run.id}
|
||||||
|
}
|
||||||
|
async function waitForFailedWorkCard(request:any,seed:any){
|
||||||
|
await expect.poll(async()=>{
|
||||||
|
const out=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()
|
||||||
|
const card=out.cards.find((c:any)=>c.task)
|
||||||
|
return card?.task.executionState==='failed'&&card.status==='failed'&&Boolean(card.work)
|
||||||
|
}).toBe(true)
|
||||||
|
}
|
||||||
|
async function expectOnlySendRun(request:any,seed:any){
|
||||||
|
const current=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
|
||||||
|
expect(current.runs.filter((r:any)=>r.kind==='conversation').map((r:any)=>r.id)).toEqual([seed.sendRunId])
|
||||||
|
}
|
||||||
|
async function open(page:any,seed:any){
|
||||||
|
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
|
||||||
|
}
|
||||||
|
test('attachment-only intake gives a safe first reply before sourced findings',async({page,request})=>{
|
||||||
|
const seed=await setup(request,'fast-ack',false)
|
||||||
|
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||||
|
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||||
|
expect(cards.find((c:any)=>c.task).task.checkpointSeq??0).toBe(0)
|
||||||
|
await open(page,seed)
|
||||||
|
const reply=page.getByText(/已收到 3 份材料.*待确认/)
|
||||||
|
await expect(reply).toBeVisible()
|
||||||
|
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
|
||||||
|
await page.setViewportSize({width:390,height:844})
|
||||||
|
await expect(reply).toBeVisible()
|
||||||
|
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT.replace(/\.png$/, '-mobile.png'),fullPage:true})
|
||||||
|
await page.reload()
|
||||||
|
await expect(reply).toBeVisible()
|
||||||
|
})
|
||||||
|
test('text with long material keeps the user focus in the host reply',async({page,request})=>{
|
||||||
|
const seed=await setup(request,'fast-ack',false,{text:'重点核对预算审批'})
|
||||||
|
await open(page,seed)
|
||||||
|
await expect(page.getByText(/你提出的工作重点已带入整理任务.*待确认/)).toBeVisible()
|
||||||
|
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||||
|
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
test('running and completed resends reuse one material card',async({page,request})=>{
|
||||||
|
const seed=await setup(request,'slow-stages',false)
|
||||||
|
const resend=async(key:string)=>{
|
||||||
|
const response=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:seed.ids,requestId:key}})
|
||||||
|
expect(response.ok()).toBeTruthy()
|
||||||
|
const body=await response.json()
|
||||||
|
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${body.run.id}`)).json()).run.status).toBe('completed')
|
||||||
|
}
|
||||||
|
await resend('active-resend-'+Date.now())
|
||||||
|
await open(page,seed)
|
||||||
|
await expect(page.getByText(/这批材料仍在整理.*待确认/)).toBeVisible()
|
||||||
|
await expect.poll(async()=>{
|
||||||
|
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||||
|
return cards.find((c:any)=>c.task)?.task.executionState
|
||||||
|
}).toBe('completed')
|
||||||
|
await resend('completed-resend-'+Date.now())
|
||||||
|
await expect(page.getByText(/这批材料已整理完成.*待确认/)).toBeVisible()
|
||||||
|
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||||
|
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
test('text with a small attachment retains the ordinary reply path',async({page,request})=>{
|
||||||
|
const seed=await setup(request,'normal',true,{text:'请先解释材料要点',small:true})
|
||||||
|
await open(page,seed)
|
||||||
|
await expect(page.getByText('已收到材料,正在后台整理;请结合阶段发现继续工作。').first()).toBeVisible()
|
||||||
|
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||||
|
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
test('real HTTP task recovery, source navigation and changed-version limitation',async({page,request})=>{
|
||||||
|
const seed=await setup(request);const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message));await open(page,seed)
|
||||||
|
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
|
||||||
|
await expect(page.locator('[data-conversation-work]')).toContainText('材料未说明具体归属')
|
||||||
|
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
|
||||||
|
await page.getByRole('button',{name:'查看依据',exact:true}).first().click()
|
||||||
|
await page.getByRole('button',{name:/查看来源/}).first().click()
|
||||||
|
await expect(page.locator('.preview-pane')).toBeVisible();await expect(page.getByText('任务处理时引用的来源版本;下方是本项发现引用的片段')).toBeVisible()
|
||||||
|
const card=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.find((c:any)=>c.task)
|
||||||
|
const source=card.task.findings[0].sources[0]
|
||||||
|
const update=await request.put(manifest.base+`/api/artifacts/${source.artifactId}/content`,{data:'上线七月。文件内容已经更新。',headers:{'Content-Type':'text/plain'}});expect(update.ok()).toBeTruthy()
|
||||||
|
const old=await request.get(manifest.base+`/api/artifacts/${source.artifactId}/content?expectedHash=${source.contentHash}`);expect(old.status()).toBe(409)
|
||||||
|
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('依据文件已更新')
|
||||||
|
await expect(page.locator('[data-conversation-work] .acard-findings').first()).not.toContainText('上线六月')
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT,fullPage:true})
|
||||||
|
})
|
||||||
|
test('background replay does not steal the current file, focus or viewport',async({page,request})=>{
|
||||||
|
const seed=await setup(request);await open(page,seed)
|
||||||
|
await page.getByRole('button',{name:'查看依据',exact:true}).first().click();await page.getByRole('button',{name:/查看来源/}).first().click();await expect(page.locator('.preview-pane')).toBeVisible()
|
||||||
|
const tabs=await page.locator('.tab-strip').textContent()
|
||||||
|
const composer=page.locator('textarea').last();await composer.fill('正在记录自己的想法');await composer.focus()
|
||||||
|
const focus=await composer.evaluate((el:any)=>document.activeElement===el);expect(focus).toBe(true)
|
||||||
|
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'重点核对预算',attachmentIds:seed.ids,requestId:'another-'+Date.now()}});expect(sent.ok()).toBeTruthy()
|
||||||
|
await expect.poll(async()=>{
|
||||||
|
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||||
|
return cards.filter((c:any)=>c.task?.executionState==='completed').length
|
||||||
|
}).toBe(2)
|
||||||
|
await expect(composer).toBeFocused();await expect(composer).toHaveValue('正在记录自己的想法')
|
||||||
|
expect(await page.locator('.tab-strip').textContent()).toBe(tabs)
|
||||||
|
})
|
||||||
|
test('two sourced stages appear while the material task is still running',async({page,request})=>{
|
||||||
|
const seed=await setup(request,'slow-stages',false)
|
||||||
|
await open(page,seed)
|
||||||
|
const cards=async()=>(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||||
|
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('running')
|
||||||
|
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
|
||||||
|
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState==='running'&&card.task.checkpointSeq>=2&&card.task.findings?.length>1}).toBe(true)
|
||||||
|
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
|
||||||
|
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('completed')
|
||||||
|
})
|
||||||
|
for(const scenario of ['zero-failure','partial-failure'] as const){
|
||||||
|
test(`${scenario} shows the saved scope without a deliverable choice`,async({page,request})=>{
|
||||||
|
const seed=await setup(request,scenario)
|
||||||
|
await waitForFailedWorkCard(request,seed)
|
||||||
|
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||||
|
await page.goto('/')
|
||||||
|
const work=page.locator('[data-conversation-work]')
|
||||||
|
await expect(work).toContainText('执行失败')
|
||||||
|
if(scenario==='zero-failure')await expect(work).toContainText('暂无阶段结论记录')
|
||||||
|
else await expect(work).toContainText('上线六月')
|
||||||
|
await expect(page.locator('.work-choice')).toHaveCount(0)
|
||||||
|
await expect(page.locator('.hitl-choice')).toHaveCount(0)
|
||||||
|
await expectOnlySendRun(request,seed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
test('completed sourced work offers only its grounded suggestions',async({page,request})=>{
|
||||||
|
const seed=await setup(request,'choice')
|
||||||
|
await open(page,seed)
|
||||||
|
const work=page.locator('[data-conversation-work]')
|
||||||
|
await expect(work.locator('.work-choice')).toHaveCount(3)
|
||||||
|
for(const label of ['会议摘要','验收清单','联调计划'])await expect(work.locator('.work-choice').filter({hasText:label})).toHaveCount(1)
|
||||||
|
await expect(work.locator('.work-decision legend')).toContainText('接下来需要做什么?')
|
||||||
|
await page.reload()
|
||||||
|
await expect(work.locator('.work-choice')).toHaveCount(3)
|
||||||
|
})
|
||||||
|
test('failed material registration reports no started task or unsupported finding',async({page,request})=>{
|
||||||
|
const seed=await setup(request,'normal',false,{startFailure:true})
|
||||||
|
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByText(/后台整理未能启动/).first()).toBeVisible()
|
||||||
|
const conversation=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
|
||||||
|
expect(conversation.messages.some((m:any)=>m.role==='assistant'&&(m.parts??[]).some((p:any)=>p.type==='text'&&p.text?.includes('未能启动')))).toBe(true)
|
||||||
|
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||||
|
expect(cards.filter((c:any)=>c.task)).toHaveLength(0)
|
||||||
|
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
|
||||||
|
})
|
||||||
|
test('saturated material slots show queued status without claiming execution',async({page,request})=>{
|
||||||
|
const seed=await setup(request,'queue-hold',false)
|
||||||
|
const sendAnother=async(label:string,hold:boolean)=>{
|
||||||
|
const uploaded=await request.post(manifest.base+`/api/projects/${seed.project.id}/uploads`,{
|
||||||
|
multipart:{file:{name:`${label}.txt`,mimeType:'text/plain',buffer:Buffer.from((hold?'[fixture:queue-hold]\n':'')+'上线六月。验收条件需要补齐。'+label)}}
|
||||||
|
})
|
||||||
|
expect(uploaded.ok()).toBeTruthy()
|
||||||
|
const artifactId=(await uploaded.json()).artifactId
|
||||||
|
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:[artifactId],requestId:'queue-'+label}})
|
||||||
|
expect(sent.ok()).toBeTruthy()
|
||||||
|
const runId=(await sent.json()).run.id
|
||||||
|
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${runId}`)).json()).run.status).toBe('completed')
|
||||||
|
}
|
||||||
|
await sendAnother('占用二',true)
|
||||||
|
await sendAnother('占用三',true)
|
||||||
|
await sendAnother('等待四',false)
|
||||||
|
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.locator('[data-conversation-work]')).toContainText('排队中')
|
||||||
|
await expect(page.getByText(/整理任务正在排队.*待确认/).first()).toBeVisible()
|
||||||
|
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.filter((c:any)=>c.task)
|
||||||
|
expect(cards).toHaveLength(4)
|
||||||
|
expect(cards.filter((c:any)=>c.task.executionState==='queued')).toHaveLength(1)
|
||||||
|
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { test, expect, type Page, type APIRequestContext, type Response } from '@playwright/test'
|
||||||
|
import type { Artifact, Message, Part, Run, UploadInfo } from '../src/contracts'
|
||||||
|
|
||||||
|
test.skip(!process.env.WORK_COMPANION_LIVE_BROWSER, 'Requires the task-owned backend, SQLite data and MockRunner')
|
||||||
|
|
||||||
|
async function openRealConversation(page: Page, request: APIRequestContext) {
|
||||||
|
const projectResponse = await request.post('/api/projects', { data: { name: '工作台验收 ' + Date.now() } })
|
||||||
|
expect(projectResponse.ok()).toBe(true)
|
||||||
|
const project = await projectResponse.json()
|
||||||
|
const conversationResponse = await request.post(`/api/projects/${project.id}/conversations`, { data: { title: '真实接口验收' } })
|
||||||
|
expect(conversationResponse.ok()).toBe(true)
|
||||||
|
const conversation = await conversationResponse.json()
|
||||||
|
await page.addInitScript(({ projectId, conversationId }) => {
|
||||||
|
localStorage.setItem('as.conv', conversationId)
|
||||||
|
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', rightCollapsed: true, lastProjectId: projectId }))
|
||||||
|
}, { projectId: project.id, conversationId: conversation.id })
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.locator('.chat-title')).toHaveText('真实接口验收')
|
||||||
|
return { project, conversation }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('real upload/send/reload preserves three actual attachments', async ({ page, request }, testInfo) => {
|
||||||
|
const { project, conversation } = await openRealConversation(page, request)
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on('pageerror', error => errors.push(error.message))
|
||||||
|
const uploadResponses: Response[] = []
|
||||||
|
const uploadPath = `/api/projects/${project.id}/uploads`
|
||||||
|
const observeUpload = (response: Response) => {
|
||||||
|
if (response.request().method() === 'POST' && new URL(response.url()).pathname === uploadPath) uploadResponses.push(response)
|
||||||
|
}
|
||||||
|
page.on('response', observeUpload)
|
||||||
|
await page.locator('input[type=file]').first().setInputFiles([
|
||||||
|
{ name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议决定:先明确交付日期') },
|
||||||
|
{ name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求:整理材料并生成摘要') },
|
||||||
|
{ name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审意见:补充验收标准') },
|
||||||
|
])
|
||||||
|
await expect(page.locator('.attach-chip')).toHaveCount(3)
|
||||||
|
page.off('response', observeUpload)
|
||||||
|
expect(uploadResponses).toHaveLength(3)
|
||||||
|
const uploads: UploadInfo[] = await Promise.all(uploadResponses.map(async response => {
|
||||||
|
expect(response.ok()).toBe(true)
|
||||||
|
return response.json()
|
||||||
|
}))
|
||||||
|
expect(uploads.map(upload => upload.filename)).toEqual(['会议纪要.txt', '需求说明.txt', '评审意见.txt'])
|
||||||
|
const attachmentIds = uploads.map(upload => upload.artifactId)
|
||||||
|
for (const id of attachmentIds) expect(id).toEqual(expect.stringMatching(/\S+/))
|
||||||
|
expect(new Set(attachmentIds).size).toBe(3)
|
||||||
|
const artifactsResponse = await request.get(`/api/projects/${project.id}/artifacts`)
|
||||||
|
expect(artifactsResponse.ok()).toBe(true)
|
||||||
|
const artifacts: Artifact[] = await artifactsResponse.json()
|
||||||
|
expect(artifacts).toHaveLength(3)
|
||||||
|
expect(artifacts.map(artifact => artifact.id).sort()).toEqual([...attachmentIds].sort())
|
||||||
|
const expectedParts: Part[] = uploads.map(upload => {
|
||||||
|
const artifact = artifacts.find(artifact => artifact.id === upload.artifactId)!
|
||||||
|
expect(artifact.name).toBe(upload.filename)
|
||||||
|
return { type: 'artifact_ref', artifact: { artifactId: artifact.id, name: artifact.name, kind: artifact.kind } }
|
||||||
|
})
|
||||||
|
const messagePath = `/api/conversations/${conversation.id}/messages`
|
||||||
|
const [sendRequest, sendResponse] = await Promise.all([
|
||||||
|
page.waitForRequest(request => request.method() === 'POST' && new URL(request.url()).pathname === messagePath),
|
||||||
|
page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === messagePath),
|
||||||
|
page.getByRole('button', { name: '发送', exact: true }).click(),
|
||||||
|
])
|
||||||
|
const sent = sendRequest.postDataJSON()
|
||||||
|
expect(sent.text).toBe('')
|
||||||
|
expect(sent.attachmentIds).toEqual(attachmentIds)
|
||||||
|
expect(sendResponse.ok()).toBe(true)
|
||||||
|
const accepted: { message: Message; run: Run } = await sendResponse.json()
|
||||||
|
expect(accepted.message.role).toBe('user')
|
||||||
|
expect(accepted.message.conversationId).toBe(conversation.id)
|
||||||
|
expect(accepted.message.parts).toEqual(expectedParts)
|
||||||
|
expect(accepted.run.kind).toBe('conversation')
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const response = await request.get(`/api/conversations/${conversation.id}`)
|
||||||
|
expect(response.ok()).toBe(true)
|
||||||
|
const current: { runs: Run[] } = await response.json()
|
||||||
|
return current.runs.some(run => run.id === accepted.run.id && run.kind === 'conversation' && run.status === 'completed')
|
||||||
|
}).toBe(true)
|
||||||
|
async function persistedMessage() {
|
||||||
|
const response = await request.get(`/api/conversations/${conversation.id}`)
|
||||||
|
expect(response.ok()).toBe(true)
|
||||||
|
const current: { messages: Message[] } = await response.json()
|
||||||
|
const users = current.messages.filter(message => message.role === 'user')
|
||||||
|
expect(users).toHaveLength(1)
|
||||||
|
expect(users[0].id).toBe(accepted.message.id)
|
||||||
|
expect(users[0].conversationId).toBe(conversation.id)
|
||||||
|
expect(users[0].parts).toEqual(expectedParts)
|
||||||
|
return users[0]
|
||||||
|
}
|
||||||
|
const beforeReload = await persistedMessage()
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator('.chat-title')).toHaveText('真实接口验收')
|
||||||
|
const afterReload = await persistedMessage()
|
||||||
|
expect(afterReload).toEqual(beforeReload)
|
||||||
|
const association = { projectId: project.id, conversationId: conversation.id, runId: accepted.run.id,
|
||||||
|
uploads: uploads.map(({ artifactId, filename }) => ({ artifactId, filename })),
|
||||||
|
sent: { text: sent.text, attachmentIds: sent.attachmentIds }, beforeReload, afterReload }
|
||||||
|
await testInfo.attach('real-material-associations', { body: Buffer.from(JSON.stringify(association, null, 2)), contentType: 'application/json' })
|
||||||
|
console.log('real-material-associations', JSON.stringify(association))
|
||||||
|
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
||||||
|
await expect(page.locator('[data-artifact-row]')).toHaveCount(3)
|
||||||
|
await page.locator('[data-artifact-row]').first().click()
|
||||||
|
await expect(page.locator('.preview-pane-name')).toBeVisible()
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('real background card and HITL decision survive query reconciliation', async ({ page, request }) => {
|
||||||
|
const { conversation } = await openRealConversation(page, request)
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on('pageerror', error => errors.push(error.message))
|
||||||
|
const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true })
|
||||||
|
// Backend's deterministic runner exercises the real run/card/event lifecycle;
|
||||||
|
// this is not a live-model quality or stage-findings test.
|
||||||
|
await input.fill('#background')
|
||||||
|
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const out = await request.get(`/api/conversations/${conversation.id}/cards`).then(r => r.json())
|
||||||
|
return out.cards?.some((card: { kind: string; status: string }) => card.kind === 'progress' && ['done', 'failed'].includes(card.status))
|
||||||
|
}, { timeout: 15000 }).toBe(true)
|
||||||
|
await expect(page.locator('[data-conversation-work]')).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: '发送', exact: true })).toBeVisible()
|
||||||
|
await input.fill('#hitl')
|
||||||
|
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||||
|
await expect(page.locator('.hitl-card')).toBeVisible()
|
||||||
|
await page.locator('.hitl-card').getByRole('button', { name: /Markdown 文档/ }).click()
|
||||||
|
await expect(page.locator('.hitl-card')).toHaveCount(0)
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const current = await request.get(`/api/conversations/${conversation.id}`).then(r => r.json())
|
||||||
|
return current.activeHitl.length
|
||||||
|
}).toBe(0)
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
})
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
import type { Artifact, Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts'
|
||||||
|
|
||||||
|
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
|
||||||
|
|
||||||
|
const stamp = '2026-10-12T10:00:00Z'
|
||||||
|
const conversation: Conversation = {
|
||||||
|
id: 'navigation-conversation', projectId: 'navigation-project', title: '继续阅读材料',
|
||||||
|
createdAt: stamp, updatedAt: stamp,
|
||||||
|
}
|
||||||
|
const artifact: Artifact = {
|
||||||
|
id: 'navigation-document', projectId: conversation.projectId, name: '材料摘要.md', kind: 'markdown',
|
||||||
|
size: 64, createdAt: stamp, updatedAt: stamp,
|
||||||
|
}
|
||||||
|
const snapshot: SurfaceSnapshot = {
|
||||||
|
identity: { surfaceId: 'saved-navigation-page', owner: 'reader', pluginId: 'generative-ui', conversationId: conversation.id },
|
||||||
|
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1,
|
||||||
|
spec: { title: '已保存的材料页面', nodes: [
|
||||||
|
{ id: 'summary', type: 'markdown', markdown: '已保存的材料范围' },
|
||||||
|
{ id: 'source', type: 'doc-ref', docId: artifact.id },
|
||||||
|
] },
|
||||||
|
}
|
||||||
|
const metadata: SurfaceMeta = {
|
||||||
|
surfaceId: snapshot.identity.surfaceId, title: snapshot.spec.title, pluginId: snapshot.identity.pluginId,
|
||||||
|
revision: snapshot.revision, conversationId: conversation.id, updatedAt: stamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay the external list contract, while running the real store, App and panes.
|
||||||
|
// The saved selection is restored by openConversation rather than test-only state injection.
|
||||||
|
async function fixture(page: Page) {
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on('pageerror', error => errors.push(error.message))
|
||||||
|
let release!: () => void
|
||||||
|
const hydration = new Promise<void>(resolve => { release = resolve })
|
||||||
|
await page.addInitScript(({ conversationId }) => {
|
||||||
|
localStorage.setItem('as.conv', conversationId)
|
||||||
|
localStorage.setItem('as.ui', JSON.stringify({
|
||||||
|
theme: 'light', rightTab: 'files', filesView: 'preview', rightCollapsed: true,
|
||||||
|
}))
|
||||||
|
}, { conversationId: conversation.id })
|
||||||
|
await page.route('**/api/**', async route => {
|
||||||
|
const request = route.request()
|
||||||
|
const path = new URL(request.url()).pathname
|
||||||
|
if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only navigation fixture' })
|
||||||
|
if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' })
|
||||||
|
let body: unknown = []
|
||||||
|
if (path === '/api/health') body = { ok: true }
|
||||||
|
else if (path === '/api/me') body = { owner: 'reader', name: '材料阅读者', auth: true, authVia: 'session', isAdmin: false }
|
||||||
|
else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '材料项目', createdAt: stamp, updatedAt: stamp }]
|
||||||
|
else if (path === '/api/conversations') body = [conversation]
|
||||||
|
else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }
|
||||||
|
else if (path === `/api/conversations/${conversation.id}/surfaces`) { await hydration; body = [metadata] }
|
||||||
|
else if (path === `/api/surfaces/${snapshot.identity.surfaceId}`) body = snapshot
|
||||||
|
else if (path === `/api/conversations/${conversation.id}/cards`) body = { cards: [] }
|
||||||
|
else if (path === `/api/projects/${conversation.projectId}/artifacts`) body = [artifact]
|
||||||
|
else if (path === `/api/artifacts/${artifact.id}/content`) {
|
||||||
|
return route.fulfill({ contentType: 'text/markdown', body: '# 材料摘要\n\n原始材料的阅读范围' })
|
||||||
|
} else if (path === '/api/settings') body = { user: {}, project: {}, effective: {}, schema: [] }
|
||||||
|
else if (path === '/api/workspaces') body = [{ id: 'navigation-materials', title: '材料工作区',
|
||||||
|
body: [{ type: 'doc-ref', docId: artifact.id }] }]
|
||||||
|
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||||
|
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||||
|
})
|
||||||
|
const listRequest = page.waitForRequest(request => new URL(request.url()).pathname === `/api/conversations/${conversation.id}/surfaces`)
|
||||||
|
await page.goto('/')
|
||||||
|
await listRequest
|
||||||
|
await expect(page.locator('.chat-title')).toHaveText(conversation.title)
|
||||||
|
return { release, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const width of [736, 390]) {
|
||||||
|
test(`keeps context and focus when saved Surface hydration arrives at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 })
|
||||||
|
const state = await fixture(page)
|
||||||
|
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||||
|
const search = page.getByRole('textbox', { name: '搜索本项目会话', exact: true })
|
||||||
|
await search.fill('材料')
|
||||||
|
await expect(search).toBeFocused()
|
||||||
|
state.release()
|
||||||
|
// The real hidden preview proves both restored tab selection and snapshot hydration finished.
|
||||||
|
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||||
|
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'context')
|
||||||
|
await expect(page.locator('.left-nav')).toBeVisible()
|
||||||
|
await expect(page.locator('.main')).toBeHidden()
|
||||||
|
await expect(page.locator('.right-panel')).toBeHidden()
|
||||||
|
await expect(search).toHaveValue('材料')
|
||||||
|
await expect(search).toBeFocused()
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`preserves explicit work, Surface, document and return navigation at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 })
|
||||||
|
const state = await fixture(page)
|
||||||
|
state.release()
|
||||||
|
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||||
|
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||||
|
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'work')
|
||||||
|
await expect(page.locator('.right-panel')).toBeVisible()
|
||||||
|
await expect(page.locator('.left-nav')).toBeHidden()
|
||||||
|
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
||||||
|
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files')
|
||||||
|
await page.getByRole('button', { name: '返回文件列表', exact: true }).click()
|
||||||
|
await page.locator(`[data-surface-row="${snapshot.identity.surfaceId}"]`).click()
|
||||||
|
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||||
|
// Surface doc-ref uses the existing document bridge, which opens the same preview.
|
||||||
|
await page.locator('.preview-pane').getByRole('button', { name: artifact.id, exact: true }).click()
|
||||||
|
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||||
|
await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围')
|
||||||
|
await page.getByRole('button', { name: '展开预览', exact: true }).click()
|
||||||
|
await expect(page.locator('.right-panel')).toHaveClass(/expanded/)
|
||||||
|
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||||
|
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation')
|
||||||
|
await expect(page.locator('.main')).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
await expect(page.locator('.main')).toBeVisible()
|
||||||
|
await page.setViewportSize({ width, height: 900 })
|
||||||
|
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation')
|
||||||
|
await expect(page.locator('.left-nav')).toBeHidden()
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`opens a workspace document while context overlays the selected files pane at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 })
|
||||||
|
const state = await fixture(page)
|
||||||
|
state.release()
|
||||||
|
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||||
|
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||||
|
await page.locator('.left-nav').getByRole('button', { name: /材料工作区/ }).click()
|
||||||
|
const workspace = page.getByRole('dialog', { name: '材料工作区', exact: true })
|
||||||
|
await workspace.getByRole('button', { name: artifact.name, exact: true }).click()
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files')
|
||||||
|
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||||
|
await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围')
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
import type { ActivityCard, Artifact, Conversation, Part, StreamEvent, WorkCardAction } from '../src/contracts'
|
||||||
|
|
||||||
|
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
|
||||||
|
|
||||||
|
const stamp = '2026-10-12T10:00:00Z'
|
||||||
|
const project = { id: 'p1', name: '产品升级计划', createdAt: stamp, updatedAt: stamp }
|
||||||
|
const conversations: Conversation[] = [
|
||||||
|
{ id: 'c1', projectId: 'p1', title: '整理项目材料', createdAt: stamp, updatedAt: stamp },
|
||||||
|
{ id: 'c2', projectId: 'p1', title: '讨论本周工作', createdAt: stamp, updatedAt: stamp },
|
||||||
|
]
|
||||||
|
const artifact: Artifact = { id: 'doc1', projectId: 'p1', name: '材料整理摘要.md', kind: 'markdown', size: 64, createdAt: stamp, updatedAt: stamp }
|
||||||
|
function work(id: string, status: ActivityCard['status'], over: Partial<ActivityCard> = {}): ActivityCard {
|
||||||
|
return { id, status, projectId: 'p1', conversationId: 'c1', kind: 'progress', title: '整理项目材料', createdAt: stamp, updatedAt: stamp, ...over }
|
||||||
|
}
|
||||||
|
const progress = work('work1', 'active', { body: [{ type: 'kv', kv: [
|
||||||
|
['状态', '进行中'], ['正在做', '核对会议结论'], ['当前发现', '两份材料的时间安排需要核对'], ['剩余', '评审意见仍待核查'], ['材料键', 'private-material-key'],
|
||||||
|
] }] })
|
||||||
|
const confirmation = work('confirm1', 'action_required', { kind: 'confirm', title: '生成项目草稿',
|
||||||
|
body: [{ type: 'markdown', markdown: '依据三份材料新增一份草稿,保留原件,不对外发送' }],
|
||||||
|
actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14,
|
||||||
|
history?: { events: StreamEvent[]; cardRef?: string; savedParts?: Part[] }, theme: 'light' | 'dark' | 'molv' = 'light') {
|
||||||
|
let cards = [...initialCards]
|
||||||
|
let actionCount = 0
|
||||||
|
let releaseAction: (() => void) | undefined
|
||||||
|
let holdAction = false
|
||||||
|
let sent: { text: string; attachmentIds: string[]; taskRef?: { cardId: string; resultVersion: number } } | undefined
|
||||||
|
const workActions: { cardId: string; actionId: string; payload: WorkCardAction }[] = []
|
||||||
|
let uploadNumber = 0
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on('pageerror', error => errors.push(error.message))
|
||||||
|
await page.addInitScript(({ fontSize, theme }) => {
|
||||||
|
localStorage.setItem('as.conv', 'c1')
|
||||||
|
localStorage.setItem('as.ui', JSON.stringify({ theme, fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
|
||||||
|
const streams: { url: string; emit: (event: unknown) => void }[] = []
|
||||||
|
class TestEventSource {
|
||||||
|
onopen: (() => void) | null = null
|
||||||
|
onerror = null
|
||||||
|
onmessage: ((event: { data: string }) => void) | null = null
|
||||||
|
listeners: Record<string, ((event: { data: string }) => void)[]> = {}
|
||||||
|
constructor(public url: string) { streams.push(this); queueMicrotask(() => this.onopen?.()) }
|
||||||
|
addEventListener(type: string, callback: (event: { data: string }) => void) { (this.listeners[type] ??= []).push(callback) }
|
||||||
|
emit(event: unknown) {
|
||||||
|
const value = event as { type: string }
|
||||||
|
for (const callback of this.listeners[value.type] ?? []) callback({ data: JSON.stringify(event) })
|
||||||
|
}
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
Object.assign(window, { EventSource: TestEventSource, __workStreams: streams })
|
||||||
|
}, { fontSize, theme })
|
||||||
|
await page.route('**/api/**', async route => {
|
||||||
|
const request = route.request()
|
||||||
|
const url = new URL(request.url())
|
||||||
|
const path = url.pathname
|
||||||
|
let body: unknown = []
|
||||||
|
if (path === '/api/health') body = { ok: true }
|
||||||
|
else if (path === '/api/me') body = { owner: 'reader', name: '林老师', auth: false, isAdmin: false }
|
||||||
|
else if (path === '/api/projects') body = [project]
|
||||||
|
else if (path === '/api/conversations') body = conversations
|
||||||
|
else if (path === '/api/projects/p1/conversations' && request.method() === 'POST') body = conversations[1]
|
||||||
|
else if (/^\/api\/conversations\/c[12]$/.test(path)) {
|
||||||
|
const id = path.split('/').at(-1)!
|
||||||
|
body = { conversation: conversations.find(c => c.id === id), messages: id === 'c1' ? [
|
||||||
|
{ id: 'm1', conversationId: id, role: 'user', parts: [{ type: 'text', text: '请整理这三份材料,看看需要优先处理什么' }], createdAt: stamp },
|
||||||
|
{ id: 'm2', conversationId: id, role: 'assistant', parts: [{ type: 'text', text: '先核对时间安排,再补齐验收条件。其他内容仍在整理。' }], createdAt: stamp },
|
||||||
|
...(history ? [{ id: 'm3', conversationId: id, role: 'user', runId: 'run-history', parts: [{ type: 'text', text: '继续核对' }], createdAt: stamp },
|
||||||
|
{ id: 'm4', conversationId: id, role: 'assistant', runId: 'run-history', parts: history.savedParts ?? [
|
||||||
|
{ type: 'thinking', process: true, text: '先核对日期' },
|
||||||
|
{ type: 'text', process: true, text: '开始读取材料' },
|
||||||
|
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' } },
|
||||||
|
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok', summary: '读取成功' } },
|
||||||
|
{ type: 'text', text: '最终回复已完成' },
|
||||||
|
...(history.cardRef ? [{ type: 'card', card: { cardId: history.cardRef, kind: 'result', data: [] } }] : []),
|
||||||
|
], createdAt: stamp }] : []),
|
||||||
|
] : [], runs: history ? [{ id: 'run-history', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'completed', createdAt: stamp, endedAt: stamp }] : [], activeHitl: [], lastSeq: 10 }
|
||||||
|
} else if (path === '/api/conversations/c1/cards') body = { cards }
|
||||||
|
else if (path === '/api/conversations/c1/runs/run-history/events') {
|
||||||
|
const after = Number(url.searchParams.get('after') ?? 0)
|
||||||
|
body = { events: (history?.events ?? []).filter(event => event.seq > after).slice(0, 500) }
|
||||||
|
}
|
||||||
|
else if (path === '/api/conversations/c2/cards') body = { cards: [] }
|
||||||
|
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||||
|
else if (path === '/api/projects/p1/artifacts') body = [artifact]
|
||||||
|
else if (path === '/api/artifacts/doc1/content') {
|
||||||
|
await route.fulfill({ status: 200, contentType: 'text/markdown', body: '# 材料整理摘要\n\n已整理两份材料,评审意见仍待核对' }); return
|
||||||
|
} else if (path === '/api/settings') body = { user: { runtime: { mock: false } }, project: {}, effective: {}, schema: [] }
|
||||||
|
else if (path === '/api/profiles') body = [{ id: 'default', name: '工作助手', model: {}, tools: { allow: [] }, context: { maxHistoryMessages: 20 }, hitl: { askOnAmbiguity: true }, systemPrompt: '' }]
|
||||||
|
else if (path === '/api/projects/p1/uploads') body = { artifactId: 'upload' + ++uploadNumber, filename: '材料.txt', size: 16 }
|
||||||
|
else if (/\/messages$/.test(path) && request.method() === 'POST') {
|
||||||
|
sent = request.postDataJSON()
|
||||||
|
body = { message: { id: 'sent', conversationId: 'c1', role: 'user', parts: [{ type: 'text', text: sent?.text ?? '' }], createdAt: stamp }, run: { id: 'run1', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'running', createdAt: stamp } }
|
||||||
|
} else if (/^\/api\/cards\/[^/]+\/actions\/work\./.test(path)) {
|
||||||
|
const cardId = path.split('/')[3]
|
||||||
|
const actionId = path.split('/').at(-1)!
|
||||||
|
const payload = request.postDataJSON() as WorkCardAction
|
||||||
|
workActions.push({ cardId, actionId, payload })
|
||||||
|
cards = cards.map(card => {
|
||||||
|
if (card.id !== cardId || !card.work) return card
|
||||||
|
const next = { ...card, work: { ...card.work } }
|
||||||
|
if (actionId === 'work.read') next.work.readVersion = payload.resultVersion
|
||||||
|
else if (next.work.decision) next.work.decision = { ...next.work.decision,
|
||||||
|
state: actionId === 'work.defer' ? 'deferred' : actionId === 'work.reopen' ? 'open' : 'submitted',
|
||||||
|
...(actionId === 'work.submit' ? { selectedIds: payload.selectedIds, freeText: payload.freeText, respondedAt: stamp } : {}),
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
body = { card: cards.find(card => card.id === cardId) }
|
||||||
|
} else if (path === '/api/cards/confirm1/actions/confirm') {
|
||||||
|
actionCount++
|
||||||
|
if (holdAction) await new Promise<void>(resolve => { releaseAction = resolve })
|
||||||
|
cards = cards.map(card => card.id === 'confirm1' ? { ...card, status: 'resolved' } : card)
|
||||||
|
body = { card: cards.find(card => card.id === 'confirm1') }
|
||||||
|
}
|
||||||
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
||||||
|
})
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.locator('.chat-title')).toHaveText('整理项目材料')
|
||||||
|
if (initialCards.length) await expect(page.locator('[data-conversation-work]')).toBeVisible()
|
||||||
|
return { errors, workActions, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
|
||||||
|
async emit(type: StreamEvent['type'], payload: unknown, seq: number) {
|
||||||
|
await page.evaluate(({ type, payload, seq }) => {
|
||||||
|
const win = window as typeof window & { __workStreams: { url: string; emit: (e: unknown) => void }[] }
|
||||||
|
const stream = win.__workStreams.findLast(s => s.url.includes('conversationId=c1'))
|
||||||
|
if (!stream) throw Error('conversation stream unavailable')
|
||||||
|
stream.emit({ type, payload, seq, conversationId: 'c1', ts: '2026-10-12T10:00:00Z', v: 1 })
|
||||||
|
}, { type, payload, seq })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalWork(id = 'optional-work', mode: 'single' | 'multiple' = 'single'): ActivityCard {
|
||||||
|
return work(id, 'done', { title: '说明书材料整理', sourceRunId: 'run-history',
|
||||||
|
work: { resultVersion: 2, readVersion: 0, completedAt: stamp,
|
||||||
|
result: '# 说明书重点\n\n已整理软件结构、角色权限与操作流程,原文件保留。\n\n'
|
||||||
|
+ '- **使用角色**:管理员、医生和患者\n- **软件结构**:患者端与医生端连接同一服务端\n- **核心内容**:安装维护、训练操作与安全说明\n\n'
|
||||||
|
+ '## 可继续整理的内容\n\n可以先阅读摘要了解全貌,也可以单独提取性能参数、设备要求与需要进一步确认的事项。'
|
||||||
|
+ '整理结果仅依据当前上传的说明书;缺少的材料和不能确认的内容会单独列出,不会补写成已确认的结论。'
|
||||||
|
+ '如果继续生成交付物,新的结果会更新到独立工作卡,您可以同时讨论其他话题。'
|
||||||
|
+ '每项结论都应保留材料范围和来源,便于后续复核。\n\n'
|
||||||
|
+ '## 后续核对\n\n在正式采用整理结果前,建议把产品版本、适用设备、使用人员与预期交付形式分别确认。'
|
||||||
|
+ '材料中直接写明的内容和整理时发现的问题会分开记录。对没有原文依据的数字、范围或归属,整理结果会保持待确认状态。'
|
||||||
|
+ '需要比较其他版本时,可补充相应文件,并说明希望重点比较的部分。\n\n最后一条:需要确认使用场景。',
|
||||||
|
decision: { mode, state: 'open', options: [{ id: 'summary', label: '生成摘要' }, { id: 'facts', label: '提取参数' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keeps optional choices nonblocking and supports defer, reopen, free input and saved answers', async ({ page }) => {
|
||||||
|
const state = await fixture(page, [optionalWork('optional-work', 'multiple')])
|
||||||
|
const inline = page.locator('[data-conversation-work] [data-card-id="optional-work"]')
|
||||||
|
await expect(inline.getByRole('checkbox', { name: '生成摘要', exact: true })).toBeVisible()
|
||||||
|
await inline.getByRole('button', { name: '先不选择', exact: true }).click()
|
||||||
|
await expect(inline.getByText('已暂缓选择,您可以继续对话')).toBeVisible()
|
||||||
|
await expect(inline.getByRole('checkbox')).toHaveCount(0)
|
||||||
|
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('先聊一下本周的安排')
|
||||||
|
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||||
|
await expect.poll(() => state.sent?.text).toBe('先聊一下本周的安排')
|
||||||
|
expect(state.sent?.taskRef).toBeUndefined()
|
||||||
|
// Receiving the POST is earlier than rendering its legitimate foreground reply.
|
||||||
|
await expect(page.locator('.msg.assistant[data-msg-id="live:run1"]')).toBeVisible()
|
||||||
|
const assistantCount = await page.locator('.msg.assistant').count()
|
||||||
|
await inline.getByRole('button', { name: '继续选择', exact: true }).click()
|
||||||
|
await inline.getByRole('checkbox', { name: '生成摘要', exact: true }).check()
|
||||||
|
await inline.getByRole('checkbox', { name: '提取参数', exact: true }).check()
|
||||||
|
await inline.getByRole('textbox', { name: '补充要求或直接输入需求' }).fill('保留原文中的来源和限制')
|
||||||
|
await inline.getByRole('button', { name: '开始处理', exact: true }).click()
|
||||||
|
await expect(inline.getByText('已提交需求', { exact: true })).toBeVisible()
|
||||||
|
await expect(inline.getByText('保留原文中的来源和限制', { exact: true })).toBeVisible()
|
||||||
|
await expect(inline.getByRole('textbox')).toHaveCount(0)
|
||||||
|
expect(state.workActions.filter(action => action.actionId === 'work.submit')).toEqual([{
|
||||||
|
cardId: 'optional-work', actionId: 'work.submit', payload: { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '保留原文中的来源和限制' },
|
||||||
|
}])
|
||||||
|
await expect(page.locator('.msg.assistant')).toHaveCount(assistantCount)
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('opens full results in one place, preserves unread until explicit action and references a version in the composer', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1050 })
|
||||||
|
const state = await fixture(page, [optionalWork()])
|
||||||
|
await expect(page.locator('.work-result-notice')).toContainText('1 项工作有新结果')
|
||||||
|
await page.locator('.work-result-notice').click()
|
||||||
|
const card = page.locator('.right-panel [data-card-id="optional-work"]')
|
||||||
|
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
|
||||||
|
await expect(card.getByRole('radio', { name: '生成摘要', exact: true })).toBeVisible()
|
||||||
|
if (process.env.WORKBENCH_FLOW_SCREENSHOT) await page.screenshot({ path: process.env.WORKBENCH_FLOW_SCREENSHOT, fullPage: true })
|
||||||
|
await card.getByRole('button', { name: '查看完整结果', exact: true }).click()
|
||||||
|
await expect(card.getByText('最后一条:需要确认使用场景。', { exact: false })).toBeVisible()
|
||||||
|
expect(state.workActions.filter(action => action.actionId === 'work.read')).toHaveLength(0)
|
||||||
|
await card.getByRole('button', { name: '基于此结果继续', exact: true }).click()
|
||||||
|
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toContainText('第 2 版结果')
|
||||||
|
await expect.poll(() => state.workActions.filter(action => action.actionId === 'work.read').length).toBe(1)
|
||||||
|
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('解释一下需要确认的事项')
|
||||||
|
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||||
|
await expect.poll(() => state.sent?.taskRef).toEqual({ cardId: 'optional-work', resultVersion: 2 })
|
||||||
|
expect(state.sent?.text).toBe('解释一下需要确认的事项')
|
||||||
|
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toHaveCount(0)
|
||||||
|
await expect(page.locator('.msg.user time[datetime]')).toHaveCount(2)
|
||||||
|
await expect(page.locator('.msg.assistant time[datetime]')).toHaveCount(2)
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retains text, uploads and the result reference when mixed input is not supported', async ({ page }) => {
|
||||||
|
const state = await fixture(page, [optionalWork()])
|
||||||
|
await page.locator('.work-result-notice').click()
|
||||||
|
await page.getByRole('button', { name: '基于此结果继续', exact: true }).click()
|
||||||
|
await page.getByLabel('添加材料', { exact: true }).setInputFiles({ name: '补充材料.txt', mimeType: 'text/plain', buffer: Buffer.from('补充要求') })
|
||||||
|
const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true })
|
||||||
|
await input.fill('结合补充材料解释重点')
|
||||||
|
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||||
|
await expect(input).toHaveValue('结合补充材料解释重点')
|
||||||
|
await expect(page.locator('.attach-chip')).toContainText('补充材料.txt')
|
||||||
|
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toBeVisible()
|
||||||
|
expect(state.sent).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps middle and right scrolling independent while retaining full execution output', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 800 })
|
||||||
|
const events: StreamEvent[] = [
|
||||||
|
{ seq: 1, type: 'thinking.delta', payload: { text: '已保存的核对思路' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
|
||||||
|
{ seq: 2, type: 'tool.call', payload: { callId: 'call-record', name: 'fs_read_file', arguments: '{"path":"说明书"}' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
|
||||||
|
{ seq: 3, type: 'tool.output', payload: { callId: 'call-record', text: '完整的文档读取记录' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
|
||||||
|
{ seq: 4, type: 'tool.result', payload: { callId: 'call-record', status: 'ok' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
|
||||||
|
]
|
||||||
|
const cards = [optionalWork(), ...Array.from({ length: 15 }, (_, index) => work(`active-${index}`, 'active', { title: `其他工作 ${index + 1}` }))]
|
||||||
|
const state = await fixture(page, cards, 14, { events, savedParts: [{ type: 'text', text: '继续讨论当前主题。\n\n'.repeat(100) }] })
|
||||||
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||||
|
const right = page.locator('.right-panel [data-work-scroll]')
|
||||||
|
const chat = page.locator('.chat-body')
|
||||||
|
await right.evaluate(element => { element.scrollTop = 180 })
|
||||||
|
const rightTop = await right.evaluate(element => element.scrollTop)
|
||||||
|
await chat.evaluate(element => { element.scrollTop = 80; element.dispatchEvent(new Event('scroll', { bubbles: true })) })
|
||||||
|
expect(await right.evaluate(element => element.scrollTop)).toBe(rightTop)
|
||||||
|
const chatTop = await chat.evaluate(element => element.scrollTop)
|
||||||
|
await right.evaluate(element => { element.scrollTop = 240; element.dispatchEvent(new Event('scroll', { bubbles: true })) })
|
||||||
|
expect(await chat.evaluate(element => element.scrollTop)).toBe(chatTop)
|
||||||
|
await page.locator('.right-panel [data-card-id="optional-work"]').getByRole('button', { name: '查看完整执行记录', exact: true }).click()
|
||||||
|
const record = page.getByRole('region', { name: '说明书材料整理的执行记录' })
|
||||||
|
await expect(record.getByText('已保存的核对思路', { exact: true })).toBeVisible()
|
||||||
|
await record.getByRole('button', { name: /查看记录/ }).click()
|
||||||
|
await expect(record.getByText('完整的文档读取记录', { exact: true })).toBeVisible()
|
||||||
|
await expect(page.locator('.work-execution-record')).toHaveCount(1)
|
||||||
|
await record.getByRole('button', { name: '返回工作', exact: true }).click()
|
||||||
|
await expect(page.locator('.right-panel [data-card-id="optional-work"]')).toBeVisible()
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('folds read task history newest first and retains submitted choices after reload', async ({ page }) => {
|
||||||
|
const older = optionalWork('older')
|
||||||
|
const latest = optionalWork('latest')
|
||||||
|
older.title = '旧材料整理'; latest.title = '新材料整理'
|
||||||
|
older.work = { ...older.work!, readVersion: 2, decision: { mode: 'single', state: 'submitted', selectedIds: ['summary'], freeText: '保留参数表', respondedAt: stamp,
|
||||||
|
options: [{ id: 'summary', label: '生成摘要' }] } }
|
||||||
|
latest.work = { ...latest.work!, readVersion: 2, decision: { mode: 'single', state: 'deferred' } }
|
||||||
|
latest.updatedAt = '2026-10-13T10:00:00Z'
|
||||||
|
const state = await fixture(page, [older, latest])
|
||||||
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||||
|
await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(0)
|
||||||
|
await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click()
|
||||||
|
await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(2)
|
||||||
|
expect(await page.locator('.right-panel [data-card-id]').evaluateAll(elements => elements.map(element => element.getAttribute('data-card-id')))).toEqual(['latest', 'older'])
|
||||||
|
await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible()
|
||||||
|
await page.reload()
|
||||||
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||||
|
await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click()
|
||||||
|
await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible()
|
||||||
|
await expect(page.locator('.right-panel [data-card-id="older"]').getByRole('textbox')).toHaveCount(0)
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps real summary and details accessible with right pane closed', async ({ page }) => {
|
||||||
|
const state = await fixture(page)
|
||||||
|
await expect(page.locator('.right-panel')).toBeHidden()
|
||||||
|
await expect(page.getByText('核对会议结论', { exact: true }).first()).toBeVisible()
|
||||||
|
await expect(page.getByText('private-material-key')).toHaveCount(0)
|
||||||
|
await page.locator('[data-conversation-work]').getByRole('button', { name: '查看依据与范围', exact: true }).click()
|
||||||
|
await page.locator('[data-conversation-work]').getByRole('button', { name: '查看执行记录', exact: true }).click()
|
||||||
|
await expect(page.locator('[data-conversation-work]').getByText('private-material-key')).toBeVisible()
|
||||||
|
await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12)
|
||||||
|
await state.emit('card.status', { cardId: 'work1', status: 'active' }, 11)
|
||||||
|
await expect(page.locator('[data-conversation-work] .acard-status')).toHaveText('已完成')
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('places the full work card only in the desktop work pane', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 960 })
|
||||||
|
const state = await fixture(page)
|
||||||
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||||
|
await expect(page.locator('.right-panel .acard')).toHaveCount(1)
|
||||||
|
await expect(page.locator('[data-conversation-work] .acard')).toHaveCount(0)
|
||||||
|
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
|
||||||
|
await expect(page.locator('.companion-topbar').getByRole('button', { name: '打开工作' })).toHaveCount(0)
|
||||||
|
await expect(page.locator('.right-panel').getByRole('button', { name: /工作/ }).first()).toBeVisible()
|
||||||
|
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses a focused reading column and a compact authored-message shape', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||||
|
const state = await fixture(page)
|
||||||
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||||
|
|
||||||
|
const conversation = page.locator('.chat-body .msg').first()
|
||||||
|
const authored = page.locator('.chat-body .msg.user').first()
|
||||||
|
const answer = page.locator('.chat-body .msg.assistant').first()
|
||||||
|
const [conversationBox, authoredBox, answerBox] = await Promise.all([
|
||||||
|
conversation.boundingBox(), authored.boundingBox(), answer.boundingBox(),
|
||||||
|
])
|
||||||
|
expect(conversationBox).not.toBeNull()
|
||||||
|
expect(authoredBox).not.toBeNull()
|
||||||
|
expect(answerBox).not.toBeNull()
|
||||||
|
expect(conversationBox!.width).toBeLessThanOrEqual(840)
|
||||||
|
expect(authoredBox!.width).toBeLessThanOrEqual(680)
|
||||||
|
expect(authoredBox!.x + authoredBox!.width).toBeCloseTo(answerBox!.x + answerBox!.width, 0)
|
||||||
|
|
||||||
|
const surfaces = await page.evaluate(() => {
|
||||||
|
const pick = (selector: string) => {
|
||||||
|
const element = document.querySelector(selector)
|
||||||
|
if (!element) throw new Error(`missing ${selector}`)
|
||||||
|
const style = getComputedStyle(element)
|
||||||
|
return {
|
||||||
|
background: style.backgroundColor,
|
||||||
|
color: style.color,
|
||||||
|
borderRadius: Number.parseFloat(style.borderRadius),
|
||||||
|
paddingLeft: Number.parseFloat(style.paddingLeft),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
chrome: pick('.companion-topbar'),
|
||||||
|
context: pick('.left-nav'),
|
||||||
|
conversation: pick('.main'),
|
||||||
|
answer: pick('.msg.assistant'),
|
||||||
|
authored: pick('.msg.user .bubble'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const rgb = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
|
||||||
|
const distance = (a: string, b: string) => rgb(a).reduce((sum, value, index) => sum + Math.abs(value - rgb(b)[index]), 0)
|
||||||
|
expect(distance(surfaces.chrome.background, surfaces.conversation.background)).toBeGreaterThan(300)
|
||||||
|
expect(distance(surfaces.context.background, surfaces.conversation.background)).toBeGreaterThan(300)
|
||||||
|
expect(distance(surfaces.authored.background, surfaces.answer.background)).toBeGreaterThan(300)
|
||||||
|
expect(surfaces.answer.borderRadius).toBeGreaterThanOrEqual(12)
|
||||||
|
expect(surfaces.answer.paddingLeft).toBeGreaterThanOrEqual(20)
|
||||||
|
|
||||||
|
const activeCard = page.locator('.right-panel .acard.status-active')
|
||||||
|
await expect(activeCard).toBeVisible()
|
||||||
|
const cardStyle = await activeCard.evaluate(element => {
|
||||||
|
const style = getComputedStyle(element)
|
||||||
|
return { background: style.backgroundColor, borderLeftWidth: style.borderLeftWidth }
|
||||||
|
})
|
||||||
|
const panelBackground = await page.locator('.right-panel').evaluate(element => getComputedStyle(element).backgroundColor)
|
||||||
|
expect(cardStyle.background).not.toBe(panelBackground)
|
||||||
|
expect(Number.parseFloat(cardStyle.borderLeftWidth)).toBeGreaterThanOrEqual(3)
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const theme of ['light', 'dark', 'molv'] as const) {
|
||||||
|
test(`keeps the workbench frame legible in ${theme}`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 960 })
|
||||||
|
const state = await fixture(page, [progress], 14, undefined, theme)
|
||||||
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||||
|
const surfaces = await page.evaluate(() => ['.companion-topbar', '.left-nav', '.msg.user .bubble', '.msg.assistant'].map(selector => {
|
||||||
|
const element = document.querySelector(selector)
|
||||||
|
if (!element) throw new Error(`missing ${selector}`)
|
||||||
|
const style = getComputedStyle(element)
|
||||||
|
return { selector, foreground: style.color, background: style.backgroundColor }
|
||||||
|
}))
|
||||||
|
const channels = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
|
||||||
|
const luminance = (value: string) => {
|
||||||
|
const linear = channels(value).map(channel => {
|
||||||
|
const normalized = channel / 255
|
||||||
|
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
|
||||||
|
})
|
||||||
|
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||||
|
}
|
||||||
|
for (const surface of surfaces) {
|
||||||
|
const lighter = Math.max(luminance(surface.foreground), luminance(surface.background))
|
||||||
|
const darker = Math.min(luminance(surface.foreground), luminance(surface.background))
|
||||||
|
expect((lighter + 0.05) / (darker + 0.05), `${surface.selector} contrast in ${theme}`).toBeGreaterThanOrEqual(4.5)
|
||||||
|
}
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('preserves the selected material, physical scroll and focus when background work finishes', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 960 })
|
||||||
|
const tabbed = work('work1', 'active', { body: [...progress.body!, {
|
||||||
|
type: 'tabs', id: 'materials', tabs: { tabs: [
|
||||||
|
{ id: 'first', label: '第一份材料', nodes: [{ type: 'markdown', markdown: '第一份材料的范围' }] },
|
||||||
|
{ id: 'second', label: '第二份材料', nodes: [{ type: 'markdown', markdown: Array.from({ length: 70 }, (_, i) => `待核对事项 ${i + 1}`).join('\n\n') }] },
|
||||||
|
] },
|
||||||
|
}] })
|
||||||
|
const state = await fixture(page, [tabbed, work('peer', 'active', { body: [] })])
|
||||||
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||||
|
const card = page.locator('.right-panel [data-card-id="work1"]')
|
||||||
|
await card.getByRole('button', { name: '查看依据与范围', exact: true }).click()
|
||||||
|
const tab = card.getByRole('tab', { name: '第二份材料', exact: true })
|
||||||
|
await tab.click()
|
||||||
|
await tab.focus()
|
||||||
|
const details = card.locator('.acard-detail-body')
|
||||||
|
await expect.poll(() => details.evaluate(element => element.clientHeight)).toBe(420)
|
||||||
|
const physical = await details.evaluate(element => {
|
||||||
|
Object.assign(window, { __workReading: element })
|
||||||
|
element.scrollTop = 137
|
||||||
|
return { height: element.clientHeight, scrollHeight: element.scrollHeight, top: element.scrollTop }
|
||||||
|
})
|
||||||
|
expect(physical.scrollHeight).toBeGreaterThan(physical.height)
|
||||||
|
expect(physical.top).toBe(137)
|
||||||
|
await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12)
|
||||||
|
await expect(card.locator('.acard-status')).toHaveText('已完成')
|
||||||
|
await expect(tab).toHaveAttribute('aria-selected', 'true')
|
||||||
|
expect(await details.evaluate(element => ({
|
||||||
|
retained: (window as typeof window & { __workReading: Element }).__workReading === element,
|
||||||
|
top: element.scrollTop,
|
||||||
|
}))).toEqual({ retained: true, top: 137 })
|
||||||
|
expect(await tab.evaluate(element => document.activeElement === element)).toBe(true)
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps one confirmation action location when the work pane is visible', async ({ page }) => {
|
||||||
|
const state = await fixture(page, [confirmation])
|
||||||
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||||
|
state.hold()
|
||||||
|
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
|
||||||
|
await page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true }).click()
|
||||||
|
await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
|
||||||
|
await expect(page.locator('.chat-body').getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
|
||||||
|
expect(state.actionCount).toBe(1)
|
||||||
|
state.release()
|
||||||
|
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps each run process folded and reloadable after the final reply', async ({ page }) => {
|
||||||
|
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
|
||||||
|
({ seq, type, payload, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 })
|
||||||
|
const events = [
|
||||||
|
event(1, 'thinking.delta', { index: 0, text: '先核对日期' }),
|
||||||
|
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
|
||||||
|
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
|
||||||
|
event(4, 'tool.output', { callId: 'call-1', text: '完整工具输出' }),
|
||||||
|
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
|
||||||
|
event(6, 'run.phase', { phase: 'finalizing' }),
|
||||||
|
event(7, 'message.delta', { index: 1, text: '最终回复已完成' }),
|
||||||
|
]
|
||||||
|
const state = await fixture(page, [], 14, { events })
|
||||||
|
const message = page.locator('[data-msg-id="m4"]')
|
||||||
|
await expect(message.getByText('最终回复已完成')).toBeVisible()
|
||||||
|
await expect(message.getByText('开始读取材料')).toHaveCount(0)
|
||||||
|
await message.getByRole('button', { name: /执行记录/ }).click()
|
||||||
|
await expect(message.getByText('先核对日期')).toBeVisible()
|
||||||
|
await expect(message.getByText('开始读取材料')).toBeVisible()
|
||||||
|
await message.locator('.tool-block-head').click()
|
||||||
|
await expect(message.getByText('完整工具输出')).toBeVisible()
|
||||||
|
await page.reload()
|
||||||
|
const restored = page.locator('[data-msg-id="m4"]')
|
||||||
|
await expect(restored.getByText('最终回复已完成')).toBeVisible()
|
||||||
|
await restored.getByRole('button', { name: /执行记录/ }).click()
|
||||||
|
await expect(restored.getByText('先核对日期')).toBeVisible()
|
||||||
|
await restored.locator('.tool-block-head').click()
|
||||||
|
await expect(restored.getByText('完整工具输出')).toBeVisible()
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('replay enriches saved notes and keeps collaboration progress after completion', async ({ page }) => {
|
||||||
|
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
|
||||||
|
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
|
||||||
|
const events = [
|
||||||
|
event(1, 'message.delta', { index: 0, text: '<think>已解析的思考</think>' }),
|
||||||
|
event(2, 'message.delta', { index: 1, text: '开始读取' }),
|
||||||
|
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
|
||||||
|
event(4, 'tool.output', { callId: 'call-1', text: '完整输出留存' }),
|
||||||
|
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
|
||||||
|
event(6, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
|
||||||
|
event(7, 'run.phase', { phase: 'finalizing' }),
|
||||||
|
event(8, 'message.delta', { index: 1, text: '最终回复已完成' }),
|
||||||
|
event(9, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
|
||||||
|
event(10, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
|
||||||
|
]
|
||||||
|
const savedParts: Part[] = [
|
||||||
|
{ type: 'text', process: true, text: '模型在输出前失败,正在重试' },
|
||||||
|
{ type: 'thinking', process: true, text: '已解析的思考' },
|
||||||
|
{ type: 'text', process: true, text: '开始读取材料' },
|
||||||
|
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell' } },
|
||||||
|
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } },
|
||||||
|
{ type: 'text', text: '最终回复已完成' },
|
||||||
|
]
|
||||||
|
await fixture(page, [], 14, { events, savedParts })
|
||||||
|
const message = page.locator('[data-msg-id="m4"]')
|
||||||
|
await message.getByRole('button', { name: /执行记录/ }).click()
|
||||||
|
await expect(message.getByText('模型在输出前失败,正在重试')).toBeVisible()
|
||||||
|
await expect(message.getByText('已解析的思考')).toBeVisible()
|
||||||
|
await expect(message.getByText('开始读取材料')).toBeVisible()
|
||||||
|
await expect(message.getByText('开始读取', { exact: true })).toHaveCount(0)
|
||||||
|
await expect(message.getByText('协作进展:正在核对附录')).toBeVisible()
|
||||||
|
await expect(message.getByText('协作结束:附录已核对')).toBeVisible()
|
||||||
|
await expect(message.getByText('<think>已解析的思考</think>')).toHaveCount(0)
|
||||||
|
await message.locator('.tool-block-head').click()
|
||||||
|
await expect(message.getByText('完整输出留存')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps a child completion note when the final reply uses the same status words', async ({ page }) => {
|
||||||
|
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
|
||||||
|
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
|
||||||
|
await fixture(page, [], 14, { events: [
|
||||||
|
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
|
||||||
|
event(2, 'run.phase', { phase: 'finalizing' }),
|
||||||
|
event(3, 'message.delta', { index: 0, text: '已完成' }),
|
||||||
|
event(4, 'agent.result', { agentRun: 'child', status: 'completed', summary: '已完成' }, 'child'),
|
||||||
|
], savedParts: [{ type: 'text', process: true, text: '记录已完成' }, { type: 'text', text: '已完成' }] })
|
||||||
|
const message = page.locator('[data-msg-id="m4"]')
|
||||||
|
await message.getByRole('button', { name: /执行记录/ }).click()
|
||||||
|
await expect(message.getByText('协作结束:已完成')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('locates a completed card even when work is filtered to project pending items', async ({ page }) => {
|
||||||
|
const result = work('result1', 'done', { kind: 'result', title: '材料核对结果',
|
||||||
|
body: [{ type: 'markdown', markdown: '完整结论在这里' }] })
|
||||||
|
await fixture(page, [result], 14, { events: [], cardRef: 'result1' })
|
||||||
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||||
|
const workPane = page.locator('.right-panel')
|
||||||
|
await workPane.locator('.inbox-scope').getByRole('button', { name: '项目' }).click()
|
||||||
|
await workPane.locator('.inbox-scope').getByRole('button', { name: '待处理' }).click()
|
||||||
|
await expect(workPane.getByText('完整结论在这里')).toHaveCount(0)
|
||||||
|
await page.locator('[data-msg-id="m4"] .card-anchor-locate').click()
|
||||||
|
await expect(workPane.getByText('完整结论在这里')).toBeVisible()
|
||||||
|
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '本会话' })).toHaveClass(/active/)
|
||||||
|
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '全部' })).toHaveClass(/active/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('opens the one file preview and preserves reading during background updates', async ({ page }) => {
|
||||||
|
const result = work('result1', 'done', { kind: 'result', title: '材料摘要已准备好', body: [{ type: 'doc-ref', docId: 'doc1' }] })
|
||||||
|
const state = await fixture(page, [result])
|
||||||
|
await page.locator('[data-conversation-work]').getByText(artifact.name, { exact: true }).click()
|
||||||
|
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||||
|
await expect(page.locator('.preview-pane-body')).toContainText('评审意见仍待核对')
|
||||||
|
await state.emit('surface.updated', { identity: { surfaceId: 'new-page', owner: 'reader', pluginId: 'generative-ui', conversationId: 'c1' }, revision: 1, activationEpoch: 0, compat: { schemaVersion: 1, catalogVersion: '1' }, spec: { title: '新任务页面', nodes: [] } }, 11)
|
||||||
|
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||||
|
await page.getByRole('button', { name: '展开预览', exact: true }).click()
|
||||||
|
await expect(page.locator('.main')).toBeHidden()
|
||||||
|
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||||
|
await expect(page.locator('.main')).toBeVisible()
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sends three attachments without text and states foreground queueing truthfully', async ({ page }) => {
|
||||||
|
const state = await fixture(page, [])
|
||||||
|
await page.locator('input[type=file]').first().setInputFiles([
|
||||||
|
{ name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议内容') },
|
||||||
|
{ name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求内容') },
|
||||||
|
{ name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审内容') },
|
||||||
|
])
|
||||||
|
await expect(page.locator('.attach-chip')).toHaveCount(3)
|
||||||
|
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||||
|
await expect.poll(() => state.sent?.attachmentIds.length).toBe(3)
|
||||||
|
expect(state.sent?.text).toBe('')
|
||||||
|
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('请优先核对时间安排')
|
||||||
|
await expect(page.getByRole('button', { name: '加入队列', exact: true })).toBeEnabled()
|
||||||
|
await page.getByRole('button', { name: '加入队列', exact: true }).click()
|
||||||
|
await expect(page.getByText('请优先核对时间安排', { exact: true })).toBeVisible()
|
||||||
|
await expect(page.locator('.input-status')).toContainText('排队')
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const width of [1440, 1024, 736, 390, 320]) {
|
||||||
|
test(`keeps navigation and key actions usable at ${width}px with large UI type`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 960 })
|
||||||
|
const state = await fixture(page, [progress], 18)
|
||||||
|
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||||
|
await expect(page.locator('.right-panel')).toBeVisible()
|
||||||
|
await expect(page.locator('.right-panel').getByText('评审意见仍待核查')).toBeVisible()
|
||||||
|
if (width < 1024) {
|
||||||
|
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||||
|
await expect(page.locator('.main')).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||||
|
await expect(page.getByRole('button', { name: '新建对话', exact: true })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||||
|
}
|
||||||
|
const layout = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }))
|
||||||
|
expect(layout.scroll).toBeLessThanOrEqual(layout.width)
|
||||||
|
expect(state.errors).toEqual([])
|
||||||
|
})
|
||||||
|
}
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"exports": 43
|
"exports": 58
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { loadSignedInWorkspace } from './App'
|
||||||
|
import { store } from './store'
|
||||||
|
|
||||||
|
afterEach(() => { vi.restoreAllMocks() })
|
||||||
|
|
||||||
|
function stubLoad() {
|
||||||
|
const order: string[] = []
|
||||||
|
vi.spyOn(store, 'refreshMe').mockImplementation(async () => { order.push('me') })
|
||||||
|
vi.spyOn(store, 'refreshProfiles').mockImplementation(async () => {})
|
||||||
|
vi.spyOn(store, 'refreshPlugins').mockImplementation(async () => {})
|
||||||
|
vi.spyOn(store, 'refreshProjects').mockImplementation(async () => { order.push('projects') })
|
||||||
|
vi.spyOn(store, 'startProjectSync').mockImplementation(() => { order.push('sync') })
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AutoDiscover ownership after sign-in', () => {
|
||||||
|
it('checks projects immediately on sign-in, then hands re-checks to the single sync owner', async () => {
|
||||||
|
const order = stubLoad()
|
||||||
|
await loadSignedInWorkspace({ current: true })
|
||||||
|
expect(order).toEqual(['me', 'projects', 'sync'])
|
||||||
|
expect(store.startProjectSync).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not start a sync owner for a page that was torn down during loading', async () => {
|
||||||
|
const order = stubLoad()
|
||||||
|
const active = { current: true }
|
||||||
|
vi.mocked(store.refreshProjects).mockImplementation(async () => { order.push('projects'); active.current = false })
|
||||||
|
await loadSignedInWorkspace(active)
|
||||||
|
expect(store.startProjectSync).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
+112
-55
@@ -16,6 +16,8 @@ import { SettingsModal } from './components/SettingsModal'
|
|||||||
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
|
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
|
||||||
import { TopBar } from './components/TopBar'
|
import { TopBar } from './components/TopBar'
|
||||||
import { workspaceEntries } from './components/Workspaces'
|
import { workspaceEntries } from './components/Workspaces'
|
||||||
|
import { selectWorkbenchLayout } from './workbenchLayout'
|
||||||
|
import './companion-shell.css'
|
||||||
|
|
||||||
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
|
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
|
||||||
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
|
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
|
||||||
@@ -23,39 +25,90 @@ const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
|
|||||||
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
||||||
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
||||||
|
|
||||||
export function App() {
|
/** 放行后的首轮装载:首次项目列表就是登录即时核对;其后由唯一的自动发现所有者接管重查。 */
|
||||||
useEffect(() => {
|
export async function loadSignedInWorkspace(active: { current: boolean }) {
|
||||||
const h = (e: KeyboardEvent) => workspaceShortcuts(e)
|
await store.refreshMe()
|
||||||
window.addEventListener('keydown', h, true)
|
if (!active.current) return
|
||||||
return () => window.removeEventListener('keydown', h, true)
|
void store.refreshProfiles()
|
||||||
}, [])
|
void store.refreshPlugins()
|
||||||
|
await store.refreshProjects()
|
||||||
|
if (active.current) store.startProjectSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
const ui = useStore(s => s.ui)
|
const ui = useStore(s => s.ui)
|
||||||
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
||||||
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
|
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
|
||||||
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
||||||
|
const previewOpenIntent = useStore(s => s.previewOpenIntent)
|
||||||
const rightTab = useStore(s => s.ui.rightTab)
|
const rightTab = useStore(s => s.ui.rightTab)
|
||||||
const toast = useStore(s => s.toast)
|
const toast = useStore(s => s.toast)
|
||||||
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
||||||
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
|
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
|
||||||
const filesWide = rightTab === 'files' && previewOpen
|
const filesWide = rightTab === 'files' && ui.filesView === 'preview' && previewOpen
|
||||||
const rightPanelWidth = filesWide ? ui.previewWidth : ui.rightWidth
|
const [viewportWidth, setViewportWidth] = useState(() => typeof window === 'undefined' ? 1440 : window.innerWidth)
|
||||||
const previewExpanded = filesWide && ui.previewExpanded
|
const [contextOpen, setContextOpen] = useState(false)
|
||||||
|
const layout = selectWorkbenchLayout({ ...ui, width: viewportWidth, contextOpen, previewOpen })
|
||||||
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
||||||
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
|
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
|
||||||
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
|
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
|
||||||
const [mustChangeOwner, setMustChangeOwner] = useState('')
|
const [mustChangeOwner, setMustChangeOwner] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => setViewportWidth(window.innerWidth)
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
return () => window.removeEventListener('resize', onResize)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Explicit openings leave mobile context even if pane values match; metadata restores keep the reading choice.
|
||||||
|
useEffect(() => { setContextOpen(false) }, [ui.rightCollapsed, rightTab, ui.filesView, ui.previewExpanded, previewOpenIntent])
|
||||||
|
useEffect(() => { if (!layout.compact) setContextOpen(false) }, [layout.compact])
|
||||||
|
|
||||||
|
const returnToConversation = () => {
|
||||||
|
setContextOpen(false)
|
||||||
|
store.setUI(layout.compact ? { rightCollapsed: true, previewExpanded: false } : { previewExpanded: false })
|
||||||
|
}
|
||||||
|
const openContext = () => {
|
||||||
|
if (!layout.compact) store.toggleLeft()
|
||||||
|
else if (contextOpen) returnToConversation()
|
||||||
|
else setContextOpen(true)
|
||||||
|
}
|
||||||
|
const openWork = () => {
|
||||||
|
setContextOpen(false)
|
||||||
|
store.openInbox()
|
||||||
|
store.setUI({ inboxFilter: 'all', previewExpanded: false })
|
||||||
|
}
|
||||||
|
const openPending = () => { setContextOpen(false); store.focusPending() }
|
||||||
|
const openFiles = () => { setContextOpen(false); store.openFiles() }
|
||||||
|
const conversationOpened = () => { if (layout.compact) returnToConversation() }
|
||||||
|
|
||||||
|
// Keep conversation/context shortcuts available even when their pane is hidden.
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
if (authState === 'ok' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
const key = e.key.toLowerCase()
|
||||||
|
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened).catch(() => undefined); return }
|
||||||
|
if (key === 'b') { e.preventDefault(); openContext(); return }
|
||||||
|
}
|
||||||
|
workspaceShortcuts(e)
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', h, true)
|
||||||
|
return () => window.removeEventListener('keydown', h, true)
|
||||||
|
}, [authState, layout.compact, contextOpen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await auth.fetch('/api/health')
|
const r = await auth.fetch('/api/health')
|
||||||
if (r.status === 401) { setAuthState('login'); return }
|
if (r.status === 401) { setAuthState('login'); return }
|
||||||
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
|
// 已持有有效会话 cookie(或密钥)时直接放行。设密门依据 sessionScope 判定
|
||||||
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
|
//(2026-10-13 合同):恢复 Key 兑换与 must-change 首登都建立
|
||||||
// key 持有者清 key 回登录页,改走密码登录完成改密。
|
// set_password_only 受限会话,仅看 mustChangePassword 会漏掉已有密码的
|
||||||
|
// 恢复会话。改密接口只认会话登录:API key 凭证进改密门是死路(提交必
|
||||||
|
// 401),key 持有者清 key 回登录页,改走密码登录完成改密。
|
||||||
const me = await api.me().catch(() => null)
|
const me = await api.me().catch(() => null)
|
||||||
if (me?.mustChangePassword) {
|
const gated = me?.sessionScope === 'set_password_only' || me?.mustChangePassword
|
||||||
|
if (gated) {
|
||||||
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
|
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
|
||||||
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
|
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
|
||||||
}
|
}
|
||||||
@@ -71,10 +124,9 @@ export function App() {
|
|||||||
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
||||||
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
||||||
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
||||||
store.refreshMe()
|
const active = { current: true }
|
||||||
store.refreshProjects()
|
void loadSignedInWorkspace(active)
|
||||||
store.refreshProfiles()
|
return () => { active.current = false; store.stopProjectSync() }
|
||||||
store.refreshPlugins()
|
|
||||||
}, [authState])
|
}, [authState])
|
||||||
|
|
||||||
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
||||||
@@ -82,10 +134,10 @@ export function App() {
|
|||||||
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
|
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
|
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
|
||||||
const wide = side === 'right' && rightTab === 'files' && previewOpen
|
const wide = side === 'right' && filesWide
|
||||||
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
||||||
drag.current = {
|
drag.current = {
|
||||||
side, startX: e.clientX, startW: ui[key], overshoot: false,
|
side, startX: e.clientX, startW: side === 'left' ? layout.leftWidth : layout.rightWidth, overshoot: false,
|
||||||
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
|
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
|
||||||
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
|
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
|
||||||
key,
|
key,
|
||||||
@@ -115,15 +167,29 @@ export function App() {
|
|||||||
window.addEventListener('mouseup', up)
|
window.addEventListener('mouseup', up)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onDividerKey = (side: 'left' | 'right') => (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||||
|
e.preventDefault()
|
||||||
|
const wide = side === 'right' && filesWide
|
||||||
|
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
||||||
|
const min = side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN
|
||||||
|
const max = side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX
|
||||||
|
const current = side === 'left' ? layout.leftWidth : layout.rightWidth
|
||||||
|
const delta = (e.key === 'ArrowRight' ? 16 : -16) * (side === 'left' ? 1 : -1)
|
||||||
|
store.setUI({ [key]: Math.min(max, Math.max(min, current + delta)) })
|
||||||
|
}
|
||||||
|
|
||||||
if (authState === 'login') {
|
if (authState === 'login') {
|
||||||
return <LoginPage
|
return <LoginPage
|
||||||
onAuthed={() => setAuthState('ok')}
|
onAuthed={() => setAuthState('ok')}
|
||||||
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
|
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
|
||||||
}
|
}
|
||||||
if (authState === 'mustchange') {
|
if (authState === 'mustchange') {
|
||||||
|
// 受限会话设密成功后服务端销毁全部会话并返回 reloginRequired=true:
|
||||||
|
// 唯一后继动作是回密码登录页,不得因历史 onDone 误放行进工作台。
|
||||||
return <ChangePasswordGate
|
return <ChangePasswordGate
|
||||||
owner={mustChangeOwner}
|
owner={mustChangeOwner}
|
||||||
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
|
onDone={() => { api.logout().catch(() => undefined); setAuthState('login') }}
|
||||||
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
|
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
|
||||||
}
|
}
|
||||||
if (authState !== 'ok') {
|
if (authState !== 'ok') {
|
||||||
@@ -132,22 +198,20 @@ export function App() {
|
|||||||
|
|
||||||
const tools = workspaceEntries()
|
const tools = workspaceEntries()
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className={`app companion-app${layout.compact ? ' compact' : ''}`}>
|
||||||
<TopBar />
|
<TopBar compact={layout.compact} activePane={layout.pane} showReturn={layout.previewExpanded}
|
||||||
<div className="app-body">
|
onContext={openContext} onConversation={returnToConversation} onWork={openWork} onFiles={openFiles} />
|
||||||
{!ui.leftCollapsed ? (
|
<div className="app-body" data-active-pane={layout.pane}>
|
||||||
<>
|
<aside className={`left-nav${layout.contextCollapsed ? ' collapsed' : ''}`} aria-label="上下文"
|
||||||
<div className="left-nav" style={{ width: ui.leftWidth }}>
|
hidden={!layout.showContext} style={{ width: layout.leftWidth }}>
|
||||||
<TaskSidebar />
|
<div className="shell-context-content" hidden={layout.contextCollapsed}>
|
||||||
|
<TaskSidebar onCollapse={layout.compact ? returnToConversation : store.toggleLeft}
|
||||||
|
onConversationOpen={conversationOpened} onFilesOpen={openFiles} onWorkOpen={openPending} />
|
||||||
</div>
|
</div>
|
||||||
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
|
<div className="icon-only" hidden={!layout.contextCollapsed}>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="left-nav collapsed" style={{ width: 52 }}>
|
|
||||||
<div className="icon-only">
|
|
||||||
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||||||
<button title="新建任务(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
|
<button title="新建对话(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
|
||||||
<button title="收件箱" onClick={() => store.focusPending()}><Icon name="inbox" />
|
<button title="工作" onClick={openWork}><Icon name="inbox" />
|
||||||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||||||
</button>
|
</button>
|
||||||
{tools.map(entry => (
|
{tools.map(entry => (
|
||||||
@@ -155,31 +219,24 @@ export function App() {
|
|||||||
))}
|
))}
|
||||||
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
|
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
)}
|
{layout.leftDivider && <div className="divider" role="separator" tabIndex={0} aria-label="调整上下文宽度" aria-orientation="vertical"
|
||||||
|
aria-valuemin={LEFT_MIN} aria-valuemax={LEFT_MAX} aria-valuenow={Math.round(layout.leftWidth)}
|
||||||
|
onKeyDown={onDividerKey('left')} onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft}
|
||||||
|
title="拖动或用方向键调整宽度;双击收起" />}
|
||||||
|
|
||||||
<div className="main" hidden={previewExpanded}>
|
<main className="main" hidden={!layout.showConversation} aria-label="对话">
|
||||||
<Conversation />
|
<Conversation />
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
{!ui.rightCollapsed ? (
|
{layout.rightDivider && <div className="divider" role="separator" tabIndex={0} aria-label="调整工作或文件宽度" aria-orientation="vertical"
|
||||||
<>
|
aria-valuemin={filesWide ? PREVIEW_MIN : RIGHT_MIN} aria-valuemax={filesWide ? PREVIEW_MAX : RIGHT_MAX}
|
||||||
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
|
aria-valuenow={Math.round(layout.rightWidth)} onKeyDown={onDividerKey('right')}
|
||||||
<div className={`right-panel mk-panel-grow${previewExpanded ? ' expanded' : ''}`}
|
onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动或用方向键调整宽度;双击收起" />}
|
||||||
style={previewExpanded ? undefined : { width: rightPanelWidth }}>
|
<aside className={`right-panel mk-panel-grow${layout.previewExpanded ? ' expanded' : ''}`}
|
||||||
|
aria-label={rightTab === 'files' ? '文件' : '工作'} hidden={!layout.showRight} style={{ width: layout.rightWidth }}>
|
||||||
<Workbench />
|
<Workbench />
|
||||||
</div>
|
</aside>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="right-panel collapsed" style={{ width: 44 }}>
|
|
||||||
<div className="icon-only">
|
|
||||||
<button title="展开右栏" onClick={store.toggleRight}>◧
|
|
||||||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
|
||||||
</button>
|
|
||||||
<button title="文件(搜索、预览、编辑、下载)" onClick={() => store.openFiles()}><Icon name="folder" size={16} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Workspaces />
|
<Workspaces />
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// 账号管理动作呈现门控(超级管理员 R3/R4/R8):只决定 UI 暴露哪些入口,
|
||||||
|
// 服务端授权仍是唯一权威——隐藏的入口经直连 API 调用同样会被拒绝。
|
||||||
|
interface AccountIdentity { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||||||
|
|
||||||
|
type AccountIdentityKind = 'super' | 'admin' | 'user'
|
||||||
|
|
||||||
|
export const accountIdentityKind = (account: AccountIdentity): AccountIdentityKind =>
|
||||||
|
account.isSuperAdmin ? 'super' : account.isAdmin ? 'admin' : 'user'
|
||||||
|
|
||||||
|
/** 创建管理员账号与任命/撤销管理员仅对超级管理员开放。 */
|
||||||
|
export const canAppointAdmins = (me: AccountIdentity | null | undefined) => !!me?.isSuperAdmin
|
||||||
|
|
||||||
|
interface AccountActionPolicy {
|
||||||
|
/** 资料、角色与系统权限维护。 */
|
||||||
|
maintain: boolean
|
||||||
|
toggleEnabled: boolean
|
||||||
|
/** 管理员重置临时密码与恢复提案。 */
|
||||||
|
resetPassword: boolean
|
||||||
|
adminChange: 'promote' | 'demote' | null
|
||||||
|
protectedReason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountActionPolicy(me: AccountIdentity | null | undefined, target: AccountIdentity): AccountActionPolicy {
|
||||||
|
if (target.isSuperAdmin) {
|
||||||
|
const self = !!me?.isSuperAdmin
|
||||||
|
return {
|
||||||
|
maintain: self, toggleEnabled: false, resetPassword: false, adminChange: null,
|
||||||
|
protectedReason: self
|
||||||
|
? '超级管理员账号不能停用、降级或由管理员重置密码;本人密码请在「设置 → 账号」修改'
|
||||||
|
: '超级管理员账号受保护:普通管理员不能编辑、停用、降级或重置其密码',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
maintain: true, toggleEnabled: true, resetPassword: true,
|
||||||
|
adminChange: canAppointAdmins(me) ? (target.isAdmin ? 'demote' : 'promote') : null,
|
||||||
|
protectedReason: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
afterEach(() => { vi.unstubAllGlobals() })
|
||||||
|
|
||||||
|
describe('AutoDiscover list request', () => {
|
||||||
|
it('passes the abort signal so an over-budget project list request is cancelled', async () => {
|
||||||
|
const seen: (RequestInit | undefined)[] = []
|
||||||
|
vi.stubGlobal('fetch', async (_path: string, init?: RequestInit) => {
|
||||||
|
seen.push(init)
|
||||||
|
return new Response('[]', { status: 200 })
|
||||||
|
})
|
||||||
|
const controller = new AbortController()
|
||||||
|
await api.listProjects(controller.signal)
|
||||||
|
await api.listProjects()
|
||||||
|
expect(seen[0]?.signal).toBe(controller.signal)
|
||||||
|
expect(seen[1]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the linked creation capability from the identity response', async () => {
|
||||||
|
vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ owner: 'bob', name: 'Bob', auth: true,
|
||||||
|
linkedCapabilities: { projectCreate: true, source: 'system-grant', linkedActions: ['project.members.manage'], authorityEpoch: 4 } }), { status: 200 }))
|
||||||
|
const me = await api.me()
|
||||||
|
expect(me.linkedCapabilities).toMatchObject({ projectCreate: true, source: 'system-grant' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the linked expansion from a role permission write receipt', async () => {
|
||||||
|
vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ receiptId: 'r1', roleVersion: 3, authorityEpoch: 9,
|
||||||
|
linkedExpansion: ['project.members.manage'] }), { status: 200 }))
|
||||||
|
const receipt = await api.replaceRolePermissions('role_pm', 2, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key')
|
||||||
|
expect(receipt.linkedExpansion).toEqual(['project.members.manage'])
|
||||||
|
})
|
||||||
|
})
|
||||||
+292
-28
@@ -1,10 +1,11 @@
|
|||||||
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
|
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
|
||||||
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
|
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
|
||||||
import { auth } from './auth'
|
import { auth } from './auth'
|
||||||
|
import { commandRequestBody, rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
|
||||||
import type {
|
import type {
|
||||||
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
||||||
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
||||||
ActivityCard,
|
ActivityCard, SurfaceMeta, SurfaceSnapshot, WorkCardAction,
|
||||||
} from './contracts'
|
} from './contracts'
|
||||||
|
|
||||||
const BASE = ''
|
const BASE = ''
|
||||||
@@ -13,56 +14,295 @@ const BASE = ''
|
|||||||
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
|
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
|
||||||
* 自身命令面 fail-closed。 */
|
* 自身命令面 fail-closed。 */
|
||||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||||
|
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
|
||||||
|
export interface RolePermissionRecord { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
|
||||||
|
export interface RoleRecord {
|
||||||
|
roleId: string; name: string; description: string; enabled: boolean; roleVersion: number
|
||||||
|
bindingCount: number; permissions: RolePermissionRecord[]
|
||||||
|
}
|
||||||
|
interface RoleListPage { roles: RoleRecord[]; nextCursor?: string }
|
||||||
|
export interface UserRoleBinding {
|
||||||
|
bindingId: string; userId: string; roleId: string; active: boolean; bindingVersion: number
|
||||||
|
assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number
|
||||||
|
}
|
||||||
|
/** getUserRole 返回的角色摘要:实测不含 permissions,权限补齐由角色列表条目提供。 */
|
||||||
|
export interface UserRoleSummary extends Omit<RoleRecord, 'permissions'> { permissions?: RolePermissionRecord[] }
|
||||||
|
export interface UserRoleView { binding?: UserRoleBinding; role?: UserRoleSummary }
|
||||||
|
/** Backend 计算的本人 project.create 联动(/api/me linkedCapabilities)。 */
|
||||||
|
export interface LinkedCapabilities {
|
||||||
|
projectCreate: boolean; source?: string; linkedActions: string[]; conflicts?: string[]; authorityEpoch: number
|
||||||
|
}
|
||||||
|
/** 角色/系统授权写入回执:linkedExpansion 为服务端为 project.create 联动补齐的动作。 */
|
||||||
|
interface PolicyWriteReceipt { receiptId: string; authorityEpoch: number; linkedExpansion?: string[]; migrationNote?: string }
|
||||||
|
|
||||||
async function json<T>(res: Response): Promise<T> {
|
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
|
||||||
|
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
|
||||||
|
* current_required 必须校验当前密码。 */
|
||||||
|
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
|
||||||
|
|
||||||
|
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
|
||||||
|
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||||||
|
export interface AccountRecord {
|
||||||
|
userId: string; owner: string; profile: AccountProfile; profileVersion: number
|
||||||
|
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; isSuperAdmin?: boolean; passwordSet?: boolean
|
||||||
|
initializationState?: string; initializationGeneration?: number; createdAt?: string
|
||||||
|
}
|
||||||
|
/** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */
|
||||||
|
export interface ManagedAccountRecord extends AccountRecord {
|
||||||
|
enabled: boolean; stateVersion: number; isAdmin: boolean; isSuperAdmin: boolean; passwordSet: boolean
|
||||||
|
initializationState: string; initializationGeneration: number
|
||||||
|
}
|
||||||
|
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
|
||||||
|
export interface AdminIssuedOneTimePassword {
|
||||||
|
owner: string
|
||||||
|
id: string
|
||||||
|
oneTimePassword: string
|
||||||
|
mustChangePassword: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecEnvView {
|
||||||
|
configured: boolean
|
||||||
|
root?: string
|
||||||
|
python?: string
|
||||||
|
base?: string
|
||||||
|
layers?: string[]
|
||||||
|
requests?: { id: string; conversationId: string; owner: string; spec: string; reason: string; status: string; layerId: string; detail: string; approver: string }[]
|
||||||
|
bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- W8b 操作记录投影(rdm.audit.operations):服务端已裁剪的业务 DTO ----
|
||||||
|
// 缺项 recorded=false 且 display 为「当时未记录」;current 只是当前状态补出的名称(marker「当前」),
|
||||||
|
// 不冒充历史。change.disclosure=restricted 时摘要已被服务端替换为「受限」,页面不得假设有正文。
|
||||||
|
export interface RdmOperationLabel { key: string; label: string }
|
||||||
|
export interface RdmOperationText { value?: string; recorded: boolean; display: string }
|
||||||
|
export interface RdmOperationCurrent { value: string; marker: string }
|
||||||
|
interface RdmOperationRevision { value?: number; recorded: boolean; display: string }
|
||||||
|
export interface RdmOperationRef { kind: RdmOperationLabel; id: string }
|
||||||
|
export interface RdmOperationRefs { items: RdmOperationRef[]; recorded: boolean; display: string }
|
||||||
|
export interface RdmOperationRecord {
|
||||||
|
id: string
|
||||||
|
operation: RdmOperationLabel
|
||||||
|
actor: { name: RdmOperationText; duty: RdmOperationText; current?: RdmOperationCurrent }
|
||||||
|
initiatedAt: string
|
||||||
|
effectiveAt: { value?: string; recorded: boolean; display: string }
|
||||||
|
object: { recorded: boolean; kind: RdmOperationLabel; id?: string; display: string; current?: RdmOperationCurrent }
|
||||||
|
change: { before: RdmOperationRevision; after: RdmOperationRevision; summary: RdmOperationText; disclosure: string }
|
||||||
|
basis: { reason: RdmOperationText; evidence: RdmOperationRefs }
|
||||||
|
outcome: RdmOperationLabel
|
||||||
|
impact: { direct: RdmOperationRefs; indirect: RdmOperationRefs; analyses: RdmOperationRefs }
|
||||||
|
run: { recorded: boolean; runId?: string; stepId?: string; attempt?: number; display: string }
|
||||||
|
}
|
||||||
|
export interface RdmOperationDictionary { types: RdmOperationLabel[]; outcomes: RdmOperationLabel[]; kinds: RdmOperationLabel[] }
|
||||||
|
export interface RdmOperationPage {
|
||||||
|
items: RdmOperationRecord[]
|
||||||
|
nextCursor?: string
|
||||||
|
hasMore: boolean
|
||||||
|
cutoffSeq: number
|
||||||
|
total: number
|
||||||
|
dictionary: RdmOperationDictionary
|
||||||
|
}
|
||||||
|
/** 游标绑定项目、主体、授权 epoch、筛选与截止水位;续页须带回首页的 cutoffSeq。 */
|
||||||
|
export interface RdmOperationQuery {
|
||||||
|
targetKind?: string; targetId?: string
|
||||||
|
types?: string[]; outcomes?: string[]
|
||||||
|
from?: string; to?: string
|
||||||
|
cutoffSeq?: number; cursor?: string; limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- W5 受控影响分析 / 覆盖 / 待复核(rdm.compliance.impact.* / rdm.trace.*) ----
|
||||||
|
export interface RdmObjectRef { projectId?: string; kind: string; id: string; revision?: string; anchor?: string }
|
||||||
|
/** 不可变影响快照:后续边变化不改写历史结果;重算生成新 ID。 */
|
||||||
|
export interface RdmImpactAnalysis {
|
||||||
|
id: string
|
||||||
|
start: RdmObjectRef
|
||||||
|
direct: RdmObjectRef[]
|
||||||
|
layers: RdmObjectRef[][]
|
||||||
|
involved: RdmObjectRef[]
|
||||||
|
truncated: boolean
|
||||||
|
cursor?: { frontier: RdmObjectRef[]; depth: number }
|
||||||
|
maxNodes: number
|
||||||
|
maxDepth: number
|
||||||
|
at: string
|
||||||
|
actor?: string
|
||||||
|
}
|
||||||
|
interface RdmImpactStart { startKind: string; startId: string; startRevision: number; maxNodes?: number; maxDepth?: number }
|
||||||
|
export interface RdmTraceGap { rule: number; node: string; reason: string }
|
||||||
|
/** confirmed 为正式覆盖;candidate 分列且不计正式覆盖。 */
|
||||||
|
export interface RdmTraceCoverage { confirmed: number; candidate: number; gaps: RdmTraceGap[] }
|
||||||
|
export interface RdmPendingRecheckEdge {
|
||||||
|
kind: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
fromOldRevision: string
|
||||||
|
fromNewRevision: string
|
||||||
|
toOldRevision: string
|
||||||
|
toNewRevision: string
|
||||||
|
state: string
|
||||||
|
source?: string
|
||||||
|
edgeId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyRefs = (refs?: RdmOperationRefs | null): RdmOperationRefs =>
|
||||||
|
refs ? { ...refs, items: refs.items ?? [] } : { items: [], recorded: false, display: '当时未记录' }
|
||||||
|
|
||||||
|
function normalizeOperationPage(page: RdmOperationPage): RdmOperationPage {
|
||||||
|
const dictionary = page.dictionary ?? { types: [], outcomes: [], kinds: [] }
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
items: (page.items ?? []).map(r => ({
|
||||||
|
...r,
|
||||||
|
basis: { ...r.basis, evidence: emptyRefs(r.basis?.evidence) },
|
||||||
|
impact: { direct: emptyRefs(r.impact?.direct), indirect: emptyRefs(r.impact?.indirect), analyses: emptyRefs(r.impact?.analyses) },
|
||||||
|
})),
|
||||||
|
hasMore: Boolean(page.hasMore),
|
||||||
|
dictionary: { types: dictionary.types ?? [], outcomes: dictionary.outcomes ?? [], kinds: dictionary.kinds ?? [] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImpact(a: RdmImpactAnalysis): RdmImpactAnalysis {
|
||||||
|
return { ...a, direct: a.direct ?? [], layers: (a.layers ?? []).map(l => l ?? []), involved: a.involved ?? [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationParams(projectId: string, q: RdmOperationQuery): Record<string, unknown> {
|
||||||
|
const params: Record<string, unknown> = { projectId }
|
||||||
|
for (const [k, v] of Object.entries(q)) {
|
||||||
|
if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) continue
|
||||||
|
if (k === 'cutoffSeq' && !v) continue
|
||||||
|
params[k] = v
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checked(res: Response): Promise<Response> {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
throw new Error(`${res.status}: ${text}`)
|
throw new Error(`${res.status}: ${text}`)
|
||||||
}
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
async function json<T>(res: Response): Promise<T> {
|
||||||
|
await checked(res)
|
||||||
return res.json() as Promise<T>
|
return res.json() as Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export type ConversationView = Conversation & { /** 无项目对话:服务端内部项目只承载这一条会话,不出现在项目列表里。 */ scratch?: boolean }
|
||||||
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
|
|
||||||
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
|
||||||
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
|
||||||
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
|
export const api = {
|
||||||
|
permissionCatalog: () => auth.fetch(`${BASE}/api/admin/permissions/catalog`).then(r => json<{ permissions: PermissionDescriptor[] }>(r)).then(d => d.permissions ?? []),
|
||||||
|
listRoles: (request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; action?: string; cursor?: string; limit?: number } = {}) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (request.query) params.set('query', request.query)
|
||||||
|
if (request.mode) params.set('mode', request.mode)
|
||||||
|
if (request.status) params.set('status', request.status)
|
||||||
|
if (request.action) params.set('action', request.action)
|
||||||
|
if (request.cursor) params.set('cursor', request.cursor)
|
||||||
|
if (request.limit) params.set('limit', String(request.limit))
|
||||||
|
const query = params.toString()
|
||||||
|
return auth.fetch(`${BASE}/api/admin/roles${query ? `?${query}` : ''}`).then(r => json<RoleListPage>(r))
|
||||||
|
},
|
||||||
|
getRole: (roleId: string) => auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`).then(r => json<RoleRecord>(r)),
|
||||||
|
createRole: (name: string, description: string) => auth.fetch(`${BASE}/api/admin/roles`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description }),
|
||||||
|
}).then(r => json<RoleRecord>(r)),
|
||||||
|
patchRole: (roleId: string, expectedRoleVersion: number, patch: { name?: string; description?: string; enabled?: boolean }) =>
|
||||||
|
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(r)),
|
||||||
|
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
|
||||||
|
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<PolicyWriteReceipt & { roleVersion: number }>(r)),
|
||||||
|
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
|
||||||
|
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
|
||||||
|
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ roleId, expectedStateVersion, idempotencyKey }) }).then(r => json<UserRoleBinding>(r)),
|
||||||
|
getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)),
|
||||||
|
replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) =>
|
||||||
|
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<PolicyWriteReceipt>(r)),
|
||||||
|
explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)),
|
||||||
|
listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).then(r => json<Project[]>(r)),
|
||||||
|
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||||
|
createScratchSession: (title?: string) => auth.fetch(`${BASE}/api/scratch-sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(title ? { title } : {}) }).then(r => json<{ project: Project; conversation: ConversationView }>(r)),
|
||||||
|
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||||
|
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
|
||||||
|
|
||||||
|
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<ConversationView[]>(r)),
|
||||||
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
|
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
|
||||||
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
|
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<ConversationView[]>(r)),
|
||||||
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||||
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||||
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||||||
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||||||
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
|
/** 会话内页面目录:扁平 SurfaceMeta(无 identity/spec),不可直接写入 surfaces 投影。 */
|
||||||
|
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||||
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
|
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
|
||||||
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
|
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
|
||||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<any[]>(r)),
|
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
|
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
|
||||||
|
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }>(r)),
|
||||||
// ---- 认证与会话(管理员发放制,R2)----
|
// ---- 认证与会话(管理员发放制,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 => {
|
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()}`)
|
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
|
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||||
|
auth.beginSession()
|
||||||
|
return me
|
||||||
}),
|
}),
|
||||||
logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }),
|
logout: () => {
|
||||||
|
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
|
||||||
|
auth.clear()
|
||||||
|
return request
|
||||||
|
},
|
||||||
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
|
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
|
||||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
|
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||||
}),
|
}),
|
||||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
|
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||||
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
|
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
|
||||||
resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
|
// isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。
|
||||||
|
createAdminUser: (owner: string, name: string, password?: string, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||||||
|
// 服务端严格解码 JSON 对象体:无 Content-Type 与请求体的 POST 会被拒为 400 EOF。
|
||||||
|
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<AdminIssuedOneTimePassword>(r)),
|
||||||
|
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
|
||||||
|
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
|
||||||
|
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
|
||||||
|
if (request.cursor) params.set('cursor', request.cursor)
|
||||||
|
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
|
||||||
|
},
|
||||||
|
createManagedUser: (owner: string, profile: Partial<AccountProfile>, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile, ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||||
|
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<ManagedAccountRecord>(r)),
|
||||||
|
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||||
|
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||||
|
// 任命/撤销管理员:仅超级管理员(R3);超级管理员本身不可降级(R5)。
|
||||||
|
adminSetUserAdmin: (userId: string, expectedStateVersion: number, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, isAdmin }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||||
|
// ---- 本人:资料(须完整密码会话)----
|
||||||
|
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
|
||||||
|
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
|
||||||
|
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
|
||||||
|
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
|
||||||
|
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
|
||||||
|
adminExecEnvGC: () => auth.fetch(`${BASE}/api/admin/exec-env/gc`, { method: 'POST' }).then(r => json<{ removed: string[] | null }>(r)),
|
||||||
|
adminExecEnvWheel: (file: File, conversationId: string, reason: string) => {
|
||||||
|
const body = new FormData()
|
||||||
|
body.set('wheel', file)
|
||||||
|
body.set('conversationId', conversationId)
|
||||||
|
body.set('reason', reason)
|
||||||
|
return auth.fetch(`${BASE}/api/admin/exec-env/wheels`, { method: 'POST', body }).then(r => json<{ file: string; outcome: { status: string; requestId?: string; detail?: string } }>(r))
|
||||||
|
},
|
||||||
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
|
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
|
||||||
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
|
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
|
||||||
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
|
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
|
||||||
|
|
||||||
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
|
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
|
||||||
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
|
json<{ conversation: ConversationView; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
|
||||||
|
|
||||||
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) =>
|
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
|
||||||
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId }) })
|
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
|
||||||
|
.then(r => json<{ events: StreamEvent[] }>(r)),
|
||||||
|
|
||||||
|
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string, taskRef?: { cardId: string; resultVersion: number }) =>
|
||||||
|
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId,
|
||||||
|
...(taskRef ? { taskRef: { cardId: taskRef.cardId, resultVersion: taskRef.resultVersion } } : {}) }) })
|
||||||
.then(async r => {
|
.then(async r => {
|
||||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||||
return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }>
|
return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }>
|
||||||
@@ -74,10 +314,12 @@ export const api = {
|
|||||||
auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) })
|
auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) })
|
||||||
.then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)),
|
.then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)),
|
||||||
|
|
||||||
|
taskSource: (cardId:string,sourceId:string) => auth.fetch(`${BASE}/api/cards/${encodeURIComponent(cardId)}/sources/${encodeURIComponent(sourceId)}`).then(r=>json<{source:import('./contracts').TaskSourceRef;versionState:string;artifact?:Artifact}>(r)),
|
||||||
|
listCardsSnapshot: (conversationId: string): Promise<{cards: ActivityCard[]; replaySeq?: number}> => auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{cards: ActivityCard[]; replaySeq?: number}>(r)),
|
||||||
listCards: (conversationId: string) =>
|
listCards: (conversationId: string) =>
|
||||||
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
|
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
|
||||||
cardAction: (cardId: string, actionId: string, freeText?: string) =>
|
cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) =>
|
||||||
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ freeText }) })
|
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(typeof payload === 'object' ? payload : { freeText: payload }) })
|
||||||
.then(r => json<{ card: ActivityCard; receipt?: Message }>(r)),
|
.then(r => json<{ card: ActivityCard; receipt?: Message }>(r)),
|
||||||
cardPin: (cardId: string, pinned: boolean) =>
|
cardPin: (cardId: string, pinned: boolean) =>
|
||||||
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
|
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
|
||||||
@@ -114,13 +356,17 @@ export const api = {
|
|||||||
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r =>
|
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r =>
|
||||||
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
|
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
|
||||||
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
||||||
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
|
||||||
|
// P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。
|
||||||
|
getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r =>
|
||||||
|
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown> }>(r)),
|
||||||
|
putSystemSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
||||||
|
|
||||||
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
||||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||||
adminZoneSections: () => auth.fetch(`${BASE}/api/admin-zone/sections`).then(r => r.json()) as Promise<AdminZoneSection[]>,
|
adminZoneSections: () => auth.fetch(`${BASE}/api/admin-zone/sections`).then(r => r.json()) as Promise<AdminZoneSection[]>,
|
||||||
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
||||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
|
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string; enabled?: boolean }[]>,
|
||||||
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||||
excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||||
selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||||
@@ -139,10 +385,15 @@ export const api = {
|
|||||||
|
|
||||||
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
|
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
|
||||||
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
|
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
|
||||||
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
|
rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}): Promise<T> => {
|
||||||
|
const error = rdmProjectCommandError(project, id)
|
||||||
|
if (error) throw new Error(error)
|
||||||
|
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
|
||||||
|
},
|
||||||
|
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}, source?: CommandSource): Promise<T> =>
|
||||||
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
|
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
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(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
|
||||||
.then(out => {
|
.then(out => {
|
||||||
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
|
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
|
||||||
@@ -151,6 +402,19 @@ export const api = {
|
|||||||
if (envelope && envelope.payload !== undefined) return envelope.payload
|
if (envelope && envelope.payload !== undefined) return envelope.payload
|
||||||
return out as T
|
return out as T
|
||||||
}),
|
}),
|
||||||
|
rdmAuditOperations: (projectId: string, query: RdmOperationQuery = {}) =>
|
||||||
|
api.rdmCommand<RdmOperationPage>('rdm.audit.operations', operationParams(projectId, query)).then(normalizeOperationPage),
|
||||||
|
rdmImpactAnalyze: (projectId: string, start: RdmImpactStart) =>
|
||||||
|
api.rdmCommand<RdmImpactAnalysis>('rdm.compliance.impact.analyze', { projectId, ...start }).then(normalizeImpact),
|
||||||
|
rdmImpactGet: (projectId: string, analysisId: string) =>
|
||||||
|
api.rdmCommand<RdmImpactAnalysis>('rdm.compliance.impact.get', { projectId, analysisId }).then(normalizeImpact),
|
||||||
|
rdmImpactRecompute: (projectId: string, start: RdmImpactStart) =>
|
||||||
|
api.rdmCommand<RdmImpactAnalysis>('rdm.compliance.impact.recompute', { projectId, ...start }).then(normalizeImpact),
|
||||||
|
rdmTracePendingRecheck: (projectId: string) =>
|
||||||
|
api.rdmCommand<{ edges?: RdmPendingRecheckEdge[] | null }>('rdm.trace.pending-recheck', { projectId }).then(r => r?.edges ?? []),
|
||||||
|
rdmTraceCoverage: (projectId: string) =>
|
||||||
|
api.rdmCommand<RdmTraceCoverage>('rdm.trace.coverage', { projectId })
|
||||||
|
.then(r => ({ confirmed: r?.confirmed ?? 0, candidate: r?.candidate ?? 0, gaps: r?.gaps ?? [] })),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
|
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
|
||||||
|
|||||||
+14
-3
@@ -17,11 +17,22 @@ function pickStorage(): KV {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = pickStorage()
|
const storage = pickStorage()
|
||||||
|
const identityListeners = new Set<() => void>()
|
||||||
|
function identityChanged() { identityListeners.forEach(listener => listener()) }
|
||||||
|
|
||||||
export const auth = {
|
export const auth = {
|
||||||
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
||||||
setKey(key: string) { storage.setItem(KEY, key.trim()) },
|
setKey(key: string) {
|
||||||
clear() { storage.removeItem(KEY) },
|
const next = key.trim()
|
||||||
|
if (next === this.apiKey()) return
|
||||||
|
storage.setItem(KEY, next)
|
||||||
|
identityChanged()
|
||||||
|
},
|
||||||
|
clear() { storage.removeItem(KEY); identityChanged() },
|
||||||
|
onIdentityChange(listener: () => void) {
|
||||||
|
identityListeners.add(listener)
|
||||||
|
return () => { identityListeners.delete(listener) }
|
||||||
|
},
|
||||||
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
|
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
|
||||||
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
|
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
|
||||||
beginSession() { this.clear() },
|
beginSession() { this.clear() },
|
||||||
@@ -33,7 +44,7 @@ export const auth = {
|
|||||||
headers.set('Authorization', `Bearer ${key}`)
|
headers.set('Authorization', `Bearer ${key}`)
|
||||||
return fetch(input, { ...init, headers })
|
return fetch(input, { ...init, headers })
|
||||||
},
|
},
|
||||||
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
|
// 退出登录:立即清客户端投影并回登录门;调用者先经 api.logout 注销服务端会话。
|
||||||
logout() {
|
logout() {
|
||||||
this.clear()
|
this.clear()
|
||||||
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
|
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
.work-companion-chat .chat-header {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
.work-companion-chat .chat-title { font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.025em; }
|
||||||
|
.work-companion-chat .chat-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.work-companion-chat .chat-header > .mk-col-root { position: relative; }
|
||||||
|
.work-companion-chat .chat-header > .mk-col-root > .mk-col {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
width: min(28rem, calc(100vw - 40px));
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
.companion-settings-trigger, .companion-operation-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: var(--fs-ui-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.companion-settings-trigger:hover, .companion-operation-trigger:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.companion-settings-body {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.companion-setting { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-bottom: 12px; color: var(--text); font-size: var(--fs-ui-caption); }
|
||||||
|
.companion-setting > span:first-child { flex: 1; min-width: 8rem; }
|
||||||
|
.companion-setting select { max-width: 100%; padding: 4px 8px; background: var(--bg-panel); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-caption); }
|
||||||
|
.companion-setting .session-note { flex: 1 1 100%; min-width: 0; width: 100%; }
|
||||||
|
.companion-setting-note { flex: 1 1 100%; margin: 0; color: var(--text-dim); font-size: var(--fs-ui-sm); line-height: 1.6; }
|
||||||
|
.work-companion-chat .chat-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 36px clamp(20px, 6vw, 96px) 48px;
|
||||||
|
scroll-padding-block: 36px;
|
||||||
|
}
|
||||||
|
.work-companion-chat .chat-body > * {
|
||||||
|
width: min(100%, 840px);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.work-companion-chat .chat-body > .msg { margin-bottom: 32px; }
|
||||||
|
.work-companion-chat .chat-body > .msg.user {
|
||||||
|
width: min(80%, 680px);
|
||||||
|
margin-right: max(0px, calc((100% - 840px) / 2));
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.work-companion-chat .chat-body > .msg.assistant {
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.work-companion-chat .msg-role {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: var(--fs-ui-caption);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.work-companion-chat .msg.user .msg-role { justify-content: flex-end; }
|
||||||
|
.message-timestamp { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 400; white-space: nowrap; }
|
||||||
|
.work-companion-chat .msg.assistant .message-timestamp { margin-left: auto; }
|
||||||
|
.work-result-notice, .composer-work-reference { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; box-sizing: border-box; padding: 8px 12px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-raised); color: var(--text-dim); font-size: var(--fs-ui-caption); text-align: left; }
|
||||||
|
.work-result-notice { cursor: pointer; }
|
||||||
|
.work-result-notice:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.work-result-notice > span:last-child { margin-left: auto; color: var(--accent-text); }
|
||||||
|
.composer-work-reference > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.work-execution-record { min-width: 0; padding: 16px; color: var(--text); background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); }
|
||||||
|
.work-record-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||||
|
.work-record-heading strong { min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-ui-base); }
|
||||||
|
.work-record-heading .btn { flex-shrink: 0; font-size: var(--fs-ui-caption); }
|
||||||
|
.work-execution-record .tl { margin-top: 16px; }
|
||||||
|
.work-execution-record .part-text { overflow-wrap: anywhere; }
|
||||||
|
.work-companion-chat .msg.assistant .role-mark {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
.work-companion-chat .msg.assistant .part-text { color: var(--text); font-size: var(--fs-ui-base); line-height: 1.8; }
|
||||||
|
.work-companion-chat .input-bar {
|
||||||
|
padding: 12px clamp(20px, 6vw, 96px) 20px;
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
.work-companion-chat .input-bar > * { width: min(100%, 840px); margin-left: auto; margin-right: auto; }
|
||||||
|
.work-companion-chat .input-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 14px;
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.work-companion-chat .input-row textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 4px; line-height: 1.6; font-size: var(--fs-ui-base); }
|
||||||
|
.companion-compose-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.companion-compose-spacer { flex: 1; }
|
||||||
|
.companion-add-material { position: relative; display: inline-flex; align-items: center; gap: 4px; padding: 4px; cursor: pointer; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); }
|
||||||
|
.companion-add-material:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.companion-add-material:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.companion-file-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); }
|
||||||
|
.companion-input-hint { font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||||
|
.companion-operation-trigger { margin-bottom: 8px; color: var(--text-dim); }
|
||||||
|
.companion-operation-body { padding: 12px; margin-bottom: 12px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); }
|
||||||
|
.companion-operation-body .companion-setting { margin-bottom: 8px; }
|
||||||
|
.work-companion-chat .input-status { flex-wrap: wrap; gap: 4px 8px; padding: 8px 0; line-height: 1.5; }
|
||||||
|
.work-companion-chat .input-status-note { color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||||
|
.companion-reconnect, .companion-upload-status, .companion-action-feedback { padding: 8px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); line-height: 1.5; }
|
||||||
|
.companion-upload-error { padding: 8px 0; color: var(--err-text); font-size: var(--fs-ui-caption); overflow-wrap: anywhere; }
|
||||||
|
.companion-remove-material { display: inline-flex; align-items: center; justify-content: center; padding: 4px; color: var(--text-dim); background: transparent; border: 0; border-radius: var(--radius-xs); cursor: pointer; }
|
||||||
|
.companion-remove-material:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.work-companion-chat .attach-chip { max-width: 100%; overflow-wrap: anywhere; }
|
||||||
|
.companion-empty { min-height: 320px; height: 100%; text-align: center; line-height: 1.7; }
|
||||||
|
.companion-empty .es-mark { width: 52px; height: 52px; margin-bottom: 8px; background: var(--bg-panel); box-shadow: var(--shadow-md); }
|
||||||
|
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.02em; }
|
||||||
|
.companion-examples { display: grid; gap: 8px; width: min(100%, 32rem); margin-top: 12px; }
|
||||||
|
.companion-example { padding: 14px 16px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.companion-example:hover { color: var(--text); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||||
|
.conversation-work { margin: 24px 0 16px; min-width: 0; }
|
||||||
|
.conversation-work-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 12px; color: var(--text-dim); font-size: var(--fs-ui-caption); }
|
||||||
|
.conversation-work-heading > span:first-child { color: var(--text); font-size: var(--fs-ui-lg); font-weight: 650; }
|
||||||
|
.conversation-work-current, .conversation-work-history { display: grid; gap: 12px; min-width: 0; }
|
||||||
|
.conversation-work-more { padding: 12px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); }
|
||||||
|
.conversation-work .acard { margin: 0; }
|
||||||
|
.conversation-action-anchor { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; width: 100%; margin-top: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); color: var(--text); text-align: left; font-size: var(--fs-ui-caption); cursor: pointer; }
|
||||||
|
.conversation-action-anchor > span:last-child { color: var(--accent-text); }
|
||||||
|
.conversation-action-anchor:hover { background: var(--bg-hover); }
|
||||||
|
.conversation-work-required { display: grid; gap: 8px; min-width: 0; }
|
||||||
|
.conversation-candidate { display: grid; gap: 8px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); font-size: var(--fs-ui-sm); min-width: 0; }
|
||||||
|
.conversation-candidate-head { color: var(--text); font-weight: 600; overflow-wrap: anywhere; }
|
||||||
|
.conversation-candidate-change { display: grid; gap: 6px; margin: 0; }
|
||||||
|
.conversation-candidate-change > div { display: grid; grid-template-columns: 4.5em minmax(0, 1fr); gap: 8px; }
|
||||||
|
.conversation-candidate-change dt { color: var(--text-dim); font-size: var(--fs-ui-caption); }
|
||||||
|
.conversation-candidate-change dd { margin: 0; color: var(--text); overflow-wrap: anywhere; }
|
||||||
|
.conversation-candidate .acard-actions { margin-top: 0; }
|
||||||
|
.conversation-basis { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 9px 14px; border-bottom: 1px solid var(--border-soft); color: var(--text-dim); font-size: var(--fs-ui-caption); min-width: 0; }
|
||||||
|
.conversation-basis-label { color: var(--text); font-weight: 600; }
|
||||||
|
.conversation-basis-chip { padding: 2px 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--bg-panel); overflow-wrap: anywhere; }
|
||||||
|
.conversation-basis-warn { color: var(--warn); }
|
||||||
|
.work-companion-chat .live-bar { flex-wrap: wrap; }
|
||||||
|
.work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||||
|
@media (max-width: 540px) {
|
||||||
|
.work-companion-chat .chat-header { padding: 12px 16px; gap: 8px; }
|
||||||
|
.work-companion-chat .chat-body { padding: 24px 16px 32px; }
|
||||||
|
.work-companion-chat .chat-body > .msg.user { width: 92%; margin-right: 0; }
|
||||||
|
.work-companion-chat .input-bar { padding: 10px 12px 14px; }
|
||||||
|
.companion-input-hint { display: none; }
|
||||||
|
.companion-empty { min-height: 200px; }
|
||||||
|
.work-companion-chat .hitl-choices { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-companion-chat .msg.user .bubble {
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: var(--primary);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) var(--radius-xs) var(--radius-lg);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
line-height: 1.7;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.work-companion-chat .process-collapse {
|
||||||
|
margin: 12px 0 16px;
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
.work-companion-chat .process-collapse .process-collapse-summary { padding: 9px 14px; color: var(--text-dim); }
|
||||||
|
.work-companion-chat .process-collapse .process-collapse-summary:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.work-companion-chat .process-collapse .body { padding: 8px 14px 14px; border-top: 1px solid var(--border-soft); }
|
||||||
|
.companion-message-copy { padding: 4px 8px; border: 0; background: transparent; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; }
|
||||||
|
.companion-message-copy:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.companion-message-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.work-companion-chat .msg:hover .companion-message-actions,
|
||||||
|
.work-companion-chat .msg:focus-within .companion-message-actions { opacity: 1; }
|
||||||
|
.work-companion-chat .msg-tools { gap: 4px; }
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.companion-message-actions { opacity: 1; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/* Responsive host chrome; the canonical theme and UI type scale stay in @ui. */
|
||||||
|
.app.companion-app {
|
||||||
|
height: 100dvh;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.companion-app .app-body { min-width: 0; overflow: hidden; }
|
||||||
|
.companion-app .left-nav,
|
||||||
|
.companion-app .main,
|
||||||
|
.companion-app .right-panel { min-height: 0; max-width: 100%; overflow: hidden; }
|
||||||
|
.companion-app .left-nav,
|
||||||
|
.companion-app .companion-topbar {
|
||||||
|
--text: var(--workbench-chrome-text);
|
||||||
|
--text-dim: var(--workbench-chrome-dim);
|
||||||
|
--text-faint: var(--workbench-chrome-faint);
|
||||||
|
--bg-hover: var(--workbench-chrome-hover);
|
||||||
|
--bg-active: var(--workbench-chrome-active);
|
||||||
|
--border: var(--workbench-chrome-active);
|
||||||
|
--border-soft: var(--workbench-chrome-hover);
|
||||||
|
--border-strong: var(--workbench-chrome-dim);
|
||||||
|
background: var(--workbench-chrome);
|
||||||
|
color: var(--workbench-chrome-text);
|
||||||
|
border-color: var(--workbench-chrome-hover);
|
||||||
|
}
|
||||||
|
.companion-app .left-nav { transition: none; }
|
||||||
|
.companion-app .left-nav[hidden],
|
||||||
|
.companion-app .right-panel[hidden],
|
||||||
|
.companion-app .shell-context-content[hidden],
|
||||||
|
.companion-app .icon-only[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
.shell-context-content { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
|
||||||
|
.companion-app .companion-topbar {
|
||||||
|
height: auto;
|
||||||
|
min-height: 52px;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.topbar-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.companion-topbar .topbar-name { font-size: var(--fs-ui-lg); font-weight: 650; letter-spacing: -0.02em; white-space: nowrap; }
|
||||||
|
.companion-app .right-panel .tabs {
|
||||||
|
min-height: 52px;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
.companion-app .right-panel .tab {
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 0 2px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: var(--fs-ui-base);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.companion-app .right-panel .tab:hover { color: var(--text); }
|
||||||
|
.companion-app .right-panel .tab-body { padding: 20px; }
|
||||||
|
.companion-app .right-panel .inbox-zone-label { margin: 20px 0 8px; color: var(--text); font-size: var(--fs-ui-caption); font-weight: 650; }
|
||||||
|
.companion-app .right-panel .acard { margin: 12px 0; border-color: var(--border); padding: 18px; }
|
||||||
|
.companion-app .right-panel .acard.status-active {
|
||||||
|
border-left: 3px solid var(--work-focus-border);
|
||||||
|
background: var(--work-focus-surface);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.companion-app .right-panel .acard-title { color: var(--text); font-size: var(--fs-ui-lg); line-height: 1.4; }
|
||||||
|
.companion-app .right-panel .acard-status {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--bg-active);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.companion-app .right-panel .acard-body { margin-top: 16px; }
|
||||||
|
.companion-app .right-panel .acard-summary-row { grid-template-columns: minmax(64px, auto) minmax(0, 1fr); gap: 12px; margin: 12px 0; }
|
||||||
|
.companion-app .right-panel .acard-summary-row > span,
|
||||||
|
.companion-app .right-panel .acard-summary-label { color: var(--text-dim); font-weight: 600; }
|
||||||
|
.companion-app .right-panel .acard-summary-row p,
|
||||||
|
.companion-app .right-panel .acard-findings li { color: var(--text); font-size: var(--fs-ui-base); }
|
||||||
|
.companion-topbar .brand-mark {
|
||||||
|
background: var(--workbench-chrome-text);
|
||||||
|
color: var(--workbench-chrome);
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: var(--radius-sm) !important;
|
||||||
|
}
|
||||||
|
.shell-navigation { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.shell-nav-button,
|
||||||
|
.shell-return {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--workbench-chrome-dim);
|
||||||
|
font-size: var(--fs-ui-caption);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.shell-nav-button:hover,
|
||||||
|
.shell-return:hover { background: var(--workbench-chrome-hover); color: var(--workbench-chrome-text); }
|
||||||
|
.shell-nav-button[aria-pressed='true'] { background: var(--workbench-chrome-active); color: var(--workbench-chrome-text); }
|
||||||
|
.shell-nav-button svg,
|
||||||
|
.shell-return svg { flex: none; }
|
||||||
|
.shell-back-icon { transform: rotate(180deg); }
|
||||||
|
.shell-pending {
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: var(--workbench-chrome-active);
|
||||||
|
color: var(--workbench-chrome-text);
|
||||||
|
font-size: var(--fs-ui-xs);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.companion-app .tsb-brand { gap: 8px; padding: 16px 14px 12px; border-bottom: 1px solid var(--border-soft); }
|
||||||
|
.companion-app .tsb-projrow { min-height: 40px; height: auto; border-color: transparent; background: none; padding: 6px 10px; }
|
||||||
|
.companion-app .tsb-projrow:hover { background: var(--bg-hover); }
|
||||||
|
.companion-app .tsb-projrow-name { flex: 1; font-size: var(--fs-ui-base); font-weight: 650; }
|
||||||
|
.companion-app .tsb-projrow-stage { max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); border-radius: var(--radius-sm); }
|
||||||
|
.companion-app .tsb-iconbtn { width: 32px; height: 32px; flex: none; }
|
||||||
|
.companion-app .tsb-actions { padding: 12px 14px; gap: 10px; }
|
||||||
|
.companion-app .tsb-newtask {
|
||||||
|
min-height: 40px;
|
||||||
|
height: auto;
|
||||||
|
background: var(--workbench-chrome-text);
|
||||||
|
color: var(--workbench-chrome);
|
||||||
|
box-shadow: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.companion-app .tsb-newtask:hover { background: var(--workbench-chrome-text); }
|
||||||
|
.companion-app .tsb-newtask-main { justify-content: center; padding: 8px 12px; }
|
||||||
|
.companion-app .tsb-newtask-caret { width: 36px; flex: none; border-left-color: var(--workbench-chrome-inverse-border); }
|
||||||
|
.companion-app .tsb-newtask-caret:hover { background: var(--workbench-chrome-inverse-hover); }
|
||||||
|
.companion-app .tsb-search { min-height: 38px; height: auto; padding: 8px 12px; background: var(--workbench-chrome-hover); border-color: var(--workbench-chrome-hover); }
|
||||||
|
.companion-app .tsb-search:focus-within { background: var(--workbench-chrome-active); border-color: var(--workbench-chrome-dim); }
|
||||||
|
.companion-app .tsb-orgrow { padding: 4px 16px 8px; }
|
||||||
|
.companion-app .tsb-seclabel { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 600; }
|
||||||
|
.companion-app .tsb-scroll { padding: 0 8px 12px; }
|
||||||
|
.companion-app .tsb-task { min-height: 42px; height: auto; gap: 6px; padding: 0 10px; transition: none; }
|
||||||
|
.companion-app .tsb-task.active { background: var(--bg-active); color: var(--text); }
|
||||||
|
.companion-app .tsb-task-open {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.companion-app .tsb-task-open > svg { flex: none; }
|
||||||
|
.companion-app .tsb-task-t { font-size: var(--fs-ui-base); }
|
||||||
|
.companion-app .tsb-task-tm { color: var(--text-dim); }
|
||||||
|
.companion-app .tsb-task:focus-within .tsb-row-acts { display: inline-flex; }
|
||||||
|
.companion-app .tsb-task:focus-within .tsb-task-tm { display: none; }
|
||||||
|
.companion-app .tsb-row-menu {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: none;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.companion-app .tsb-row-menu:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.companion-app .tsb-bottom { padding: 10px 12px; gap: 4px; background: var(--workbench-chrome-hover); }
|
||||||
|
.companion-app .tsb-tool { min-height: 38px; padding: 7px 10px; }
|
||||||
|
.companion-app .tsb-avatar { background: var(--bg-active); color: var(--text); box-shadow: none; border-radius: var(--radius-full); }
|
||||||
|
.companion-app .tsb-user-sub { color: var(--text-dim); }
|
||||||
|
.companion-app .tsb-footer { padding: 10px 12px; }
|
||||||
|
.companion-app .tsb-user { min-height: 46px; height: auto; }
|
||||||
|
.companion-app .right-panel .inbox-scope {
|
||||||
|
gap: 4px;
|
||||||
|
margin: 14px 20px 0;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.companion-app .right-panel .inbox-scope button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 5px 10px;
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: var(--fs-ui-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.companion-app .right-panel .inbox-scope button.active { background: var(--bg-raised); box-shadow: var(--shadow-md); color: var(--text); }
|
||||||
|
.companion-app .tsb-menu {
|
||||||
|
min-width: min(216px, calc(100vw - 16px));
|
||||||
|
max-width: min(360px, calc(100vw - 16px));
|
||||||
|
max-height: calc(100dvh - 16px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.companion-app .tsb-mi { overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px 12px; }
|
||||||
|
.companion-app.compact .shell-navigation { width: 100%; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.companion-app.compact .shell-nav-button { min-width: 0; min-height: 40px; flex-wrap: wrap; gap: 0 4px; padding: 4px; }
|
||||||
|
.companion-app.compact .shell-nav-button > svg { display: none; }
|
||||||
|
.companion-app.compact .shell-return { min-height: 36px; padding: 4px 8px; font-size: var(--fs-ui-sm); }
|
||||||
|
.companion-app.compact .left-nav,
|
||||||
|
.companion-app.compact .main,
|
||||||
|
.companion-app.compact .right-panel { flex: 1 1 100%; width: 100%; }
|
||||||
|
.companion-app.compact .tsb-tool { min-height: 40px; }
|
||||||
|
.companion-app.compact .tsb-footer { padding: 8px 12px; }
|
||||||
|
.companion-app.compact .tsb-row-acts,
|
||||||
|
.companion-app.compact .tsb-task:focus-within .tsb-row-acts { display: none; }
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import type { PermissionDescriptor } from '../api'
|
||||||
|
import { GrantChangePreview, GrantGroupEditor, ProjectCreateSourceNote, toggleGrantGroup } from './AccountUsersPanel'
|
||||||
|
import { buildCapabilityGroups } from '../rolePermissions'
|
||||||
|
|
||||||
|
const descriptor = (actionId: string, label: string, group: string, scope: 'system' | 'project' = 'system'): PermissionDescriptor =>
|
||||||
|
({ actionId, label, description: '', group, scope, protected: scope === 'system' && actionId !== 'project.create' })
|
||||||
|
const catalog = [
|
||||||
|
descriptor('system.users.view', '查看用户', '用户管理'),
|
||||||
|
descriptor('system.users.create', '创建用户', '用户管理'),
|
||||||
|
descriptor('system.roles.view', '查看角色', '角色管理'),
|
||||||
|
descriptor('project.create', '创建项目', '项目入口'),
|
||||||
|
descriptor('project.members.manage', '管理项目成员', '项目管理', 'project'),
|
||||||
|
]
|
||||||
|
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||||||
|
|
||||||
|
describe('CapabilityGroup explicit grants', () => {
|
||||||
|
it('grants system capabilities by group instead of one checkbox per action', () => {
|
||||||
|
const html = renderToStaticMarkup(<GrantGroupEditor catalog={catalog} grants={['system.users.view']} disabled={false} onChange={vi.fn()} />)
|
||||||
|
expect(html.match(/type="checkbox"/g)).toHaveLength(3)
|
||||||
|
expect(html).toContain('授予 用户管理')
|
||||||
|
expect(html).toContain('用户管理(部分)')
|
||||||
|
expect(html).toContain('联动本人创建项目的设置、成员、项目权限与文档职责管理')
|
||||||
|
expect(html).not.toContain('管理项目成员')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles every action of a group together', () => {
|
||||||
|
const users = buildCapabilityGroups(catalog.filter(item => item.scope === 'system')).find(group => group.id === 'catalog:用户管理')!
|
||||||
|
expect(toggleGrantGroup(['system.roles.view'], users, true)).toEqual(['system.roles.view', 'system.users.create', 'system.users.view'])
|
||||||
|
expect(toggleGrantGroup(['system.users.view', 'system.roles.view'], users, false)).toEqual(['system.roles.view'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previews the grant difference with its source and the single affected user', () => {
|
||||||
|
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['project.create']} labels={labels} owner="bob" />)
|
||||||
|
expect(html).toContain('影响来源:明确系统授权 · 影响 1 名用户(bob)')
|
||||||
|
expect(html).toContain('新增:创建项目(联动本人创建项目的管理资格)')
|
||||||
|
expect(html).toContain('撤销:查看用户')
|
||||||
|
const revoke = renderToStaticMarkup(<GrantChangePreview baseline={['project.create']} grants={[]} labels={labels} owner="bob" />)
|
||||||
|
expect(revoke).toContain('已创建项目的管理资格不被反向删除')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CreatorOwnerMemberDuties capability source', () => {
|
||||||
|
it('shows where an account gets project creation and what it links to', () => {
|
||||||
|
const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }
|
||||||
|
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={role} grants={['project.create']} />))
|
||||||
|
.toContain('创建项目来源:职位角色「项目经理」 + 明确系统授权')
|
||||||
|
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={[]} />))
|
||||||
|
.toContain('被加入项目后由成员关系提供基础协作')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists each project creation source separately when only one is present', () => {
|
||||||
|
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={{ name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }} grants={[]} />))
|
||||||
|
.toContain('创建项目来源:职位角色「项目经理」')
|
||||||
|
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={['project.create']} />))
|
||||||
|
.toContain('创建项目来源:明确系统授权')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CapabilityGroup empty preview', () => {
|
||||||
|
it('renders a no-change preview when baseline equals grants', () => {
|
||||||
|
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['system.users.view']} labels={labels} owner="bob" />)
|
||||||
|
expect(html).toContain('系统授权没有变化')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still names the affected user when nothing visible changes', () => {
|
||||||
|
const html = renderToStaticMarkup(<GrantChangePreview baseline={[]} grants={[]} labels={labels} owner="bob" />)
|
||||||
|
expect(html).toContain('系统授权没有变化')
|
||||||
|
expect(html).not.toContain('影响来源')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView,
|
||||||
|
} from '../api'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
import { Collapsible, Icon } from '@ui'
|
||||||
|
import { hasSystemAction } from './RolePermissionsPanel'
|
||||||
|
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
|
||||||
|
import {
|
||||||
|
PROJECT_CREATE_ACTION, buildCapabilityGroups, projectCreateSources, rebaseGrantDraft, type CapabilityGroup, type GroupLevel,
|
||||||
|
} from '../rolePermissions'
|
||||||
|
|
||||||
|
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||||||
|
|
||||||
|
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
|
||||||
|
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||||
|
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
||||||
|
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
|
||||||
|
account.passwordSet ? '已设置密码' : '待设置密码'
|
||||||
|
|
||||||
|
function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) {
|
||||||
|
return <div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
||||||
|
<label className="lg-label" htmlFor={`${prefix}-${field.key}`}>{field.label}(可空)</label>
|
||||||
|
<input id={`${prefix}-${field.key}`} className="lg-input" value={profile[field.key]}
|
||||||
|
onChange={event => onChange({ ...profile, [field.key]: event.target.value })} />
|
||||||
|
</div>)}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IdentityChip({ account }: { account: IdentityFlags }) {
|
||||||
|
const kind = accountIdentityKind(account)
|
||||||
|
if (kind === 'super') return <span className="ad-chip ad-chip-super">超级管理员</span>
|
||||||
|
if (kind === 'admin') return <span className="ad-chip ad-chip-admin">管理员</span>
|
||||||
|
return <span className="ad-chip">普通用户</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateAdminOption({ me, checked, disabled, onChange }: { me: IdentityFlags | null; checked: boolean; disabled: boolean; onChange: (value: boolean) => void }) {
|
||||||
|
if (!canAppointAdmins(me)) return <p className="ad-hint">仅超级管理员可创建或任命管理员;此处创建普通账号</p>
|
||||||
|
return <label className="ad-check"><input type="checkbox" checked={checked} disabled={disabled}
|
||||||
|
onChange={event => onChange(event.target.checked)} />创建为管理员账号</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminAppointmentButton({ me, account, busy, onChange }: { me: IdentityFlags | null; account: IdentityFlags; busy: boolean; onChange: (isAdmin: boolean) => void }) {
|
||||||
|
const change = accountActionPolicy(me, account).adminChange
|
||||||
|
if (!change) return null
|
||||||
|
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
function grantLevel(grants: string[], group: CapabilityGroup): GroupLevel {
|
||||||
|
const held = group.actions.filter(id => grants.includes(id)).length
|
||||||
|
return held === 0 ? 'off' : held === group.actions.length ? 'on' : 'mixed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleGrantGroup(grants: string[], group: CapabilityGroup, checked: boolean): string[] {
|
||||||
|
return checked ? [...new Set([...grants, ...group.actions])].sort() : grants.filter(id => !group.actions.includes(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 明确系统授权按业务组授予;创建项目组说明联动形成的本人项目管理资格。 */
|
||||||
|
export function GrantGroupEditor({ catalog, grants, disabled, onChange }: {
|
||||||
|
catalog: PermissionDescriptor[]
|
||||||
|
grants: string[]
|
||||||
|
disabled: boolean
|
||||||
|
onChange: (grants: string[]) => void
|
||||||
|
}) {
|
||||||
|
const groups = buildCapabilityGroups(catalog.filter(item => item.scope === 'system'))
|
||||||
|
return <div className="rp-grants">{groups.map(group => {
|
||||||
|
const state = grantLevel(grants, group)
|
||||||
|
return <label className="ad-check" key={group.id} title={`内部动作 ${group.actions.join('、')}`}>
|
||||||
|
<input type="checkbox" aria-label={`授予 ${group.label}`} checked={state === 'on'} disabled={disabled}
|
||||||
|
onChange={event => onChange(toggleGrantGroup(grants, group, event.target.checked))} />
|
||||||
|
{group.label}{state === 'mixed' ? '(部分)' : ''}
|
||||||
|
{group.actions.includes(PROJECT_CREATE_ACTION) && <span className="ad-dim">:联动本人创建项目的设置、成员、项目权限与文档职责管理,不涉及他人项目</span>}
|
||||||
|
</label>
|
||||||
|
})}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 授权差异预览:受影响来源为明确系统授权,影响人数为当前账号。 */
|
||||||
|
export function GrantChangePreview({ baseline, grants, labels, owner }: {
|
||||||
|
baseline: string[]; grants: string[]; labels: Record<string, string>; owner: string
|
||||||
|
}) {
|
||||||
|
const added = grants.filter(id => !baseline.includes(id))
|
||||||
|
const removed = baseline.filter(id => !grants.includes(id))
|
||||||
|
if (added.length === 0 && removed.length === 0) return <p className="ad-hint">系统授权没有变化</p>
|
||||||
|
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
|
||||||
|
return <div>
|
||||||
|
<p className="ad-hint" role="note">影响来源:明确系统授权 · 影响 1 名用户({owner})</p>
|
||||||
|
<ul className="rp-diff">
|
||||||
|
{added.length > 0 && <li>新增:{names(added)}{added.includes(PROJECT_CREATE_ACTION) ? '(联动本人创建项目的管理资格)' : ''}</li>}
|
||||||
|
{removed.length > 0 && <li>撤销:{names(removed)}{removed.includes(PROJECT_CREATE_ACTION) ? '(仅停止未来创建,已创建项目的管理资格不被反向删除)' : ''}</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账号的创建能力来源与联动说明(角色默认与明确授权可并存)。 */
|
||||||
|
export function ProjectCreateSourceNote({ role, grants }: {
|
||||||
|
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[]
|
||||||
|
}) {
|
||||||
|
const sources = projectCreateSources(role, grants)
|
||||||
|
if (sources.length === 0) return <p className="ad-hint">未获得创建项目能力:被加入项目后由成员关系提供基础协作</p>
|
||||||
|
return <p className="ad-hint">创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** getUserRole 的角色摘要不带 permissions:用已加载角色列表里同一 roleId 的条目补齐,
|
||||||
|
* 列表里也没有时按无角色默认权限处理(不新增请求,也不改写已有权限)。 */
|
||||||
|
export function roleWithPermissions(role: UserRoleView['role'], roles: RoleRecord[]): UserRoleView['role'] {
|
||||||
|
if (!role || role.permissions) return role
|
||||||
|
return roles.find(item => item.roleId === role.roleId) ?? role
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountUsersPanel() {
|
||||||
|
const me = useStore(state => state.me)
|
||||||
|
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
|
||||||
|
const canViewRoles = allowed('system.roles.view')
|
||||||
|
const canAssignRoles = canViewRoles && allowed('system.roles.assign')
|
||||||
|
const canViewGrants = allowed('system.permissions.view')
|
||||||
|
const canManageGrants = canViewGrants && allowed('system.permissions.manage')
|
||||||
|
const [accounts, setAccounts] = useState<ManagedAccountRecord[] | null>(null)
|
||||||
|
const [selected, setSelected] = useState<ManagedAccountRecord | null>(null)
|
||||||
|
const [roleView, setRoleView] = useState<UserRoleView | null>(null)
|
||||||
|
const [roles, setRoles] = useState<RoleRecord[]>([])
|
||||||
|
const [roleQuery, setRoleQuery] = useState('')
|
||||||
|
const [roleCursor, setRoleCursor] = useState('')
|
||||||
|
const [roleTrail, setRoleTrail] = useState<string[]>([])
|
||||||
|
const [nextRoleCursor, setNextRoleCursor] = useState('')
|
||||||
|
const [roleLoading, setRoleLoading] = useState(false)
|
||||||
|
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
||||||
|
const [grants, setGrants] = useState<string[]>([])
|
||||||
|
const [grantBaseline, setGrantBaseline] = useState<string[]>([])
|
||||||
|
const [authorityEpoch, setAuthorityEpoch] = useState(0)
|
||||||
|
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
|
||||||
|
const [newOwner, setNewOwner] = useState('')
|
||||||
|
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
|
||||||
|
const [newIsAdmin, setNewIsAdmin] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||||
|
const [field, setField] = useState('default')
|
||||||
|
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||||
|
const [cursor, setCursor] = useState('')
|
||||||
|
const [trail, setTrail] = useState<string[]>([])
|
||||||
|
const [nextCursor, setNextCursor] = useState('')
|
||||||
|
const [selectedRoleId, setSelectedRoleId] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const sequence = useRef(0)
|
||||||
|
const roleSequence = useRef(0)
|
||||||
|
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||||||
|
|
||||||
|
const search = async (pageCursor = cursor, afterWrite = false) => {
|
||||||
|
const request = ++sequence.current
|
||||||
|
try {
|
||||||
|
const page = await api.searchAdminUsers({ query: query.trim(), mode, status,
|
||||||
|
fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 })
|
||||||
|
if (request !== sequence.current) return
|
||||||
|
setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '')
|
||||||
|
} catch (error) {
|
||||||
|
if (request === sequence.current) setErr(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resetPaging = () => { setCursor(''); setTrail([]) }
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => { void search() }, 180)
|
||||||
|
return () => { window.clearTimeout(timer); sequence.current++ }
|
||||||
|
}, [query, mode, field, status, cursor])
|
||||||
|
|
||||||
|
const open = async (account: ManagedAccountRecord) => {
|
||||||
|
const request = ++sequence.current
|
||||||
|
roleSequence.current++
|
||||||
|
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(false)
|
||||||
|
setSelected(null); setRoleView(null); setErr(''); setNotice('')
|
||||||
|
const detailResult = await Promise.allSettled([api.adminGetUser(account.userId)])
|
||||||
|
if (request !== sequence.current) return
|
||||||
|
if (detailResult[0].status === 'rejected') { setErr(String(detailResult[0].reason)); return }
|
||||||
|
const detail = detailResult[0].value
|
||||||
|
setSelected(detail); setDraft(detail.profile)
|
||||||
|
const [roleResult, catalogResult, grantsResult] = await Promise.allSettled([
|
||||||
|
canViewRoles ? api.getUserRole(account.userId) : Promise.resolve(null),
|
||||||
|
canViewGrants ? api.permissionCatalog() : Promise.resolve(null),
|
||||||
|
canViewGrants ? api.getSystemGrants(account.userId) : Promise.resolve(null),
|
||||||
|
])
|
||||||
|
if (request !== sequence.current) return
|
||||||
|
if (roleResult.status === 'fulfilled' && roleResult.value) {
|
||||||
|
const currentRole = roleResult.value as UserRoleView
|
||||||
|
setRoleView(currentRole); setSelectedRoleId(currentRole.role?.roleId ?? '')
|
||||||
|
} else { setRoleView(null); setSelectedRoleId('') }
|
||||||
|
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
|
||||||
|
else setCatalog([])
|
||||||
|
if (grantsResult.status === 'fulfilled' && grantsResult.value) {
|
||||||
|
const actions = [...(grantsResult.value.actions ?? [])].sort()
|
||||||
|
setGrants(actions); setGrantBaseline(actions); setAuthorityEpoch(grantsResult.value.authorityEpoch)
|
||||||
|
} else { setGrants([]); setGrantBaseline([]); setAuthorityEpoch(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
|
||||||
|
const request = ++roleSequence.current
|
||||||
|
setRoleLoading(true); setNextRoleCursor('')
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void api.listRoles({ query: roleQuery.trim(), mode: 'fuzzy', status: 'enabled', cursor: roleCursor, limit: 50 })
|
||||||
|
.then(page => {
|
||||||
|
if (request !== roleSequence.current) return
|
||||||
|
setRoles(page.roles ?? []); setNextRoleCursor(page.nextCursor ?? ''); setRoleLoading(false)
|
||||||
|
})
|
||||||
|
.catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } })
|
||||||
|
}, 180)
|
||||||
|
return () => { window.clearTimeout(timer) }
|
||||||
|
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
|
||||||
|
|
||||||
|
// 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。
|
||||||
|
const refreshFirst = async () => {
|
||||||
|
resetPaging(); setNextCursor('')
|
||||||
|
await search('', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
if (!newOwner.trim()) return
|
||||||
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
try {
|
||||||
|
const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin)
|
||||||
|
setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false)
|
||||||
|
await open(account)
|
||||||
|
setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`)
|
||||||
|
await refreshFirst()
|
||||||
|
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveProfile = async () => {
|
||||||
|
if (!selected) return
|
||||||
|
const patch: Partial<AccountProfile> = {}
|
||||||
|
for (const item of fields) if (draft[item.key] !== selected.profile[item.key]) patch[item.key] = draft[item.key]
|
||||||
|
if (!Object.keys(patch).length) { setNotice('资料没有变化'); return }
|
||||||
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
try {
|
||||||
|
const updated = await api.adminPatchProfile(selected.userId, selected.profileVersion, patch)
|
||||||
|
setSelected(updated); setDraft(updated.profile); await refreshFirst(); setNotice('用户资料已保存')
|
||||||
|
} catch (error) { setErr(String(error).includes('409') ? '资料版本冲突,请重新打开用户' : String(error)) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignRole = async () => {
|
||||||
|
if (!selected || !selectedRoleId) return
|
||||||
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
try {
|
||||||
|
await api.assignUserRole(selected.userId, selectedRoleId, selected.stateVersion, crypto.randomUUID())
|
||||||
|
await open(selected); await refreshFirst(); setNotice('用户角色已替换')
|
||||||
|
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveGrants = async () => {
|
||||||
|
if (!selected) return
|
||||||
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
const userId = selected.userId
|
||||||
|
try {
|
||||||
|
const result = await api.replaceSystemGrants(userId, grants, authorityEpoch, crypto.randomUUID())
|
||||||
|
setAuthorityEpoch(result.authorityEpoch); setGrantBaseline(grants)
|
||||||
|
const expanded = result.linkedExpansion ?? []
|
||||||
|
setNotice(expanded.length ? `系统权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '系统权限已保存')
|
||||||
|
} catch (error) {
|
||||||
|
const raw = String(error)
|
||||||
|
if (raw.includes('final permission manager')) setErr('不能撤销最后一个有效权限管理者')
|
||||||
|
else if (raw.includes('409')) {
|
||||||
|
// 版本冲突:读取最新授权并重放本地增删,草稿不丢。
|
||||||
|
try {
|
||||||
|
const fresh = await api.getSystemGrants(userId)
|
||||||
|
const server = [...(fresh.actions ?? [])].sort()
|
||||||
|
setGrants(rebaseGrantDraft(grantBaseline, grants, server)); setGrantBaseline(server); setAuthorityEpoch(fresh.authorityEpoch)
|
||||||
|
setErr('系统权限版本冲突;已基于最新授权重放本地修改,请核对后再次保存')
|
||||||
|
} catch (refreshError) { setErr(`${String(refreshError)};本地草稿已保留`) }
|
||||||
|
} else setErr(`${raw.replace(/^Error:\s*/, '')};本地草稿已保留`)
|
||||||
|
}
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleState = async () => {
|
||||||
|
if (!selected) return
|
||||||
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
try {
|
||||||
|
const updated = await api.adminSetUserEnabled(selected.userId, selected.stateVersion, !selected.enabled)
|
||||||
|
setSelected(updated); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话、Key 与进行中的恢复提案已撤销')
|
||||||
|
await refreshFirst()
|
||||||
|
} catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开用户' : String(error)) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAdmin = async (isAdmin: boolean) => {
|
||||||
|
if (!selected) return
|
||||||
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
try {
|
||||||
|
const updated = await api.adminSetUserAdmin(selected.userId, selected.stateVersion, isAdmin)
|
||||||
|
setSelected(updated); await refreshFirst(); setNotice(isAdmin ? '已任命为管理员' : '已撤销管理员')
|
||||||
|
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = selected ? accountActionPolicy(me, selected) : null
|
||||||
|
|
||||||
|
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
|
||||||
|
return <div>
|
||||||
|
{allowed('system.users.create') && <div className="ad-create">
|
||||||
|
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
|
||||||
|
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
|
||||||
|
<CreateAdminOption me={me} checked={newIsAdmin} disabled={busy} onChange={setNewIsAdmin} />
|
||||||
|
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
|
||||||
|
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
|
||||||
|
</Collapsible>
|
||||||
|
</div>}
|
||||||
|
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
|
||||||
|
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||||
|
{selected && policy ? <div className="rp-editor">
|
||||||
|
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
|
||||||
|
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
|
||||||
|
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
|
||||||
|
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
|
||||||
|
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
|
||||||
|
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
|
||||||
|
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
|
||||||
|
</Collapsible>}
|
||||||
|
<div className="ad-row">
|
||||||
|
{canAssignRoles && policy.maintain && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
|
||||||
|
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
|
||||||
|
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
|
||||||
|
{allowed('system.users.disable') && policy.toggleEnabled && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
|
||||||
|
<AdminAppointmentButton me={me} account={selected} busy={busy} onChange={setAdmin} />
|
||||||
|
</div>
|
||||||
|
{canAssignRoles && policy.maintain && <div className="ad-row">
|
||||||
|
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
|
||||||
|
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
|
||||||
|
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||||
|
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
||||||
|
</div>}
|
||||||
|
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleWithPermissions(roleView?.role, roles)} grants={grantBaseline} />}
|
||||||
|
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
|
||||||
|
<GrantGroupEditor catalog={catalog} grants={grants} disabled={!canManageGrants || !policy.maintain || busy} onChange={setGrants} />
|
||||||
|
<GrantChangePreview baseline={grantBaseline} grants={grants} labels={labels} owner={selected.owner} />
|
||||||
|
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
|
||||||
|
</Collapsible>}
|
||||||
|
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
|
||||||
|
</div> : <>
|
||||||
|
<div className="ad-row rp-filters">
|
||||||
|
<input className="lg-input" aria-label="搜索用户" placeholder="搜索用户资料" value={query} onChange={event => { setQuery(event.target.value); resetPaging() }} />
|
||||||
|
<select className="lg-input" aria-label="用户匹配方式" value={mode} onChange={event => { setMode(event.target.value as typeof mode); resetPaging() }}><option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option></select>
|
||||||
|
<select className="lg-input" aria-label="用户搜索字段" value={field} onChange={event => { setField(event.target.value); resetPaging() }}>
|
||||||
|
<option value="default">账号/姓名/ID</option><option value="owner">登录名</option><option value="name">姓名</option><option value="userId">userId</option>
|
||||||
|
<option value="phone">电话</option><option value="email">邮箱</option><option value="staffNo">工号</option><option value="department">部门</option><option value="notes">备注</option>
|
||||||
|
</select>
|
||||||
|
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); resetPaging() }}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
|
||||||
|
</div>
|
||||||
|
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
|
||||||
|
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
|
||||||
|
<td>{account.profile.staffNo || '—'}</td><td><IdentityChip account={account} /></td><td>{account.enabled ? '有效' : '停用'}</td>
|
||||||
|
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
|
||||||
|
{accounts !== null && accounts.length === 0 && <tr><td colSpan={7} className="ad-dim">没有匹配用户</td></tr>}
|
||||||
|
{accounts === null && <tr><td colSpan={7} className="ad-dim">加载中…</td></tr>}</tbody></table>
|
||||||
|
<div className="ad-row"><button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页</button>
|
||||||
|
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页</button></div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
|||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
import { Icon } from '@ui'
|
import { Icon } from '@ui'
|
||||||
|
import { humanChangePasswordError } from './loginErrors'
|
||||||
|
|
||||||
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +21,10 @@ function Shell({ title, onClose, children }: { title: string; onClose: () => voi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
|
/** 主动改密(账户菜单入口):用户名(只读关联)+ 当前密码 + 新密码 + 确认。
|
||||||
|
* 表单语义(2026-10-12 增量 3):<form onSubmit> 承载提交(Enter 可用)、autocomplete
|
||||||
|
* 三段标注(username/current-password/new-password)、label 与 input 关联;
|
||||||
|
* 错误文案复用 humanChangePasswordError——401 不再一刀切成「当前密码不正确」。 */
|
||||||
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||||
const me = useStore(s => s.me)
|
const me = useStore(s => s.me)
|
||||||
const [current, setCurrent] = useState('')
|
const [current, setCurrent] = useState('')
|
||||||
@@ -41,23 +45,28 @@ export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
|||||||
store.toast('密码已更新')
|
store.toast('密码已更新')
|
||||||
setTimeout(onClose, 900)
|
setTimeout(onClose, 900)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(String(e).includes('401') ? '当前密码不正确' : String(e))
|
setErr(humanChangePasswordError(String(e)))
|
||||||
} finally { setBusy(false) }
|
} finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell title="修改密码" onClose={onClose}>
|
<Shell title="修改密码" onClose={onClose}>
|
||||||
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
|
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
|
||||||
<div className="ad-changepw">
|
<form className="ad-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||||
<label className="lg-label">当前密码({me?.owner ?? ''})</label>
|
<label className="lg-label" htmlFor="changepw-username">用户名</label>
|
||||||
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
|
<input id="changepw-username" className="lg-input" value={me?.owner ?? ''} readOnly autoComplete="username" />
|
||||||
<label className="lg-label">新密码(≥8 位)</label>
|
<label className="lg-label" htmlFor="changepw-current">当前密码</label>
|
||||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} />
|
<input id="changepw-current" className="lg-input" type="password" autoComplete="current-password"
|
||||||
<label className="lg-label">确认新密码</label>
|
value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
|
||||||
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
|
<label className="lg-label" htmlFor="changepw-next">新密码(≥8 位)</label>
|
||||||
|
<input id="changepw-next" className="lg-input" type="password" autoComplete="new-password"
|
||||||
|
value={next} onChange={e => setNext(e.target.value)} />
|
||||||
|
<label className="lg-label" htmlFor="changepw-confirm">确认新密码</label>
|
||||||
|
<input id="changepw-confirm" className="lg-input" type="password" autoComplete="new-password"
|
||||||
|
value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||||
<button className="lg-submit" disabled={busy || !current || !next || !confirm} onClick={submit}>{busy ? '提交中…' : '更新密码'}</button>
|
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '更新密码'}</button>
|
||||||
</div>
|
</form>
|
||||||
)}
|
)}
|
||||||
</Shell>
|
</Shell>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
import { EmptyState, Icon, type IconName } from '@ui'
|
import { EmptyState, Icon, type IconName } from '@ui'
|
||||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
|
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
|
||||||
|
import { ExecEnvPanel } from './ExecEnvPanel'
|
||||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||||
|
import { AccountUsersPanel } from './AccountUsersPanel'
|
||||||
|
import { RolePermissionsPanel, roleDraftLeaveGuard, hasSystemAction } from './RolePermissionsPanel'
|
||||||
import type { AdminZoneSection } from '../api'
|
import type { AdminZoneSection } from '../api'
|
||||||
|
|
||||||
interface AdminSectionDef {
|
interface AdminSectionDef {
|
||||||
@@ -16,6 +19,8 @@ interface AdminSectionDef {
|
|||||||
label: string
|
label: string
|
||||||
icon: IconName
|
icon: IconName
|
||||||
desc: string
|
desc: string
|
||||||
|
requiredAction?: string
|
||||||
|
rootOnly?: boolean
|
||||||
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
|
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
|
||||||
render: () => React.ReactNode
|
render: () => React.ReactNode
|
||||||
}
|
}
|
||||||
@@ -25,11 +30,30 @@ function ProfilesPanelWrapper() {
|
|||||||
return <ProfilesSection profiles={profiles} />
|
return <ProfilesSection profiles={profiles} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 账号目录(按系统权限开放)+ 登录凭据管理(根管理员专属)。 */
|
||||||
|
function UsersSection() {
|
||||||
|
const isAdmin = useStore(s => !!s.me?.isAdmin)
|
||||||
|
return <>
|
||||||
|
<AccountUsersPanel />
|
||||||
|
{isAdmin && <>
|
||||||
|
<div className="az-section-head" style={{ marginTop: 18 }}>
|
||||||
|
<h3>登录凭据</h3>
|
||||||
|
<p>建号并发放初始密码、生成一次性登录密码(仅根管理员)</p>
|
||||||
|
</div>
|
||||||
|
<UsersPanel />
|
||||||
|
</>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
const SECTIONS: AdminSectionDef[] = [
|
const SECTIONS: AdminSectionDef[] = [
|
||||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
|
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员可发放登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
|
||||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
{ key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => <RolePermissionsPanel /> },
|
||||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
|
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => <ProfilesPanelWrapper /> },
|
||||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
|
||||||
|
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', rootOnly: true, render: () => <SystemSettingsPanel /> },
|
||||||
|
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', rootOnly: true, render: () => <PluginsSection /> },
|
||||||
|
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', rootOnly: true, render: () => <ExecEnvPanel /> },
|
||||||
|
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', rootOnly: true, render: () => <PluginWorkspacesPanel /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
|
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
|
||||||
@@ -59,11 +83,13 @@ export function AdminZone() {
|
|||||||
const declared = useStore(s => s.adminSections)
|
const declared = useStore(s => s.adminSections)
|
||||||
const [sectionKey, setSectionKey] = useState('users')
|
const [sectionKey, setSectionKey] = useState('users')
|
||||||
const isAdmin = !!me?.isAdmin
|
const isAdmin = !!me?.isAdmin
|
||||||
|
const systemActions = me?.systemActions ?? []
|
||||||
|
const canOpenSystem = isAdmin || systemActions.length > 0
|
||||||
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
|
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
|
||||||
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
|
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
|
||||||
if (!isAdmin && declared.length === 0) return null
|
if (!canOpenSystem && declared.length === 0) return null
|
||||||
const sections: AdminSectionDef[] = [
|
const sections: AdminSectionDef[] = [
|
||||||
...(isAdmin ? SECTIONS : []),
|
...SECTIONS.filter(section => section.rootOnly ? isAdmin : isAdmin || hasSystemAction(systemActions, section.requiredAction ?? '')),
|
||||||
...declared.map(s => ({
|
...declared.map(s => ({
|
||||||
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
|
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
|
||||||
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
|
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
|
||||||
@@ -76,11 +102,15 @@ export function AdminZone() {
|
|||||||
return (
|
return (
|
||||||
<div className="az" role="dialog" aria-label="系统管理">
|
<div className="az" role="dialog" aria-label="系统管理">
|
||||||
<div className="az-head">
|
<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} /> 返回工作台
|
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||||
</button>
|
</button>
|
||||||
<span className="az-title">系统管理</span>
|
<span className="az-title">系统管理</span>
|
||||||
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
|
<span className="az-sub">{me?.isSuperAdmin ? '超级管理员' : isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<span className="az-tenant">{me?.owner ?? ''}</span>
|
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Collapsible, Icon } from '@ui'
|
|||||||
import { artifactIcon, humanSize } from '../messageparts'
|
import { artifactIcon, humanSize } from '../messageparts'
|
||||||
import type { Artifact } from '../contracts'
|
import type { Artifact } from '../contracts'
|
||||||
import { groupArtifactsByName } from '../artifactGrouping'
|
import { groupArtifactsByName } from '../artifactGrouping'
|
||||||
|
import { surfaceKey } from '../surfaceKey'
|
||||||
|
|
||||||
export function ArtifactsPanel() {
|
export function ArtifactsPanel() {
|
||||||
const artifacts = useStore(s => s.artifacts)
|
const artifacts = useStore(s => s.artifacts)
|
||||||
@@ -42,20 +43,23 @@ export function ArtifactsPanel() {
|
|||||||
{surfaces.length > 0 && (
|
{surfaces.length > 0 && (
|
||||||
<div className="ap-surfaces" data-ap-surfaces>
|
<div className="ap-surfaces" data-ap-surfaces>
|
||||||
<div className="ap-section-title">生成页面</div>
|
<div className="ap-section-title">生成页面</div>
|
||||||
{surfaces.map(sv => (
|
{surfaces.filter(sv => surfaceKey(sv) != null).map(sv => {
|
||||||
<div key={String(sv.identity.surfaceId)} className="artifact-row" data-surface-row={String(sv.identity.surfaceId)}
|
const id = surfaceKey(sv)!
|
||||||
onClick={() => store.openSurface(String(sv.identity.surfaceId))} title={`打开页面 ${sv.spec?.title ?? ''}`}>
|
return (
|
||||||
|
<div key={id} className="artifact-row" data-surface-row={id}
|
||||||
|
onClick={() => store.openSurface(id)} title={`打开页面 ${sv.spec?.title ?? ''}`}>
|
||||||
<span className="ar-icon"><Icon name="grid" size={15} /></span>
|
<span className="ar-icon"><Icon name="grid" size={15} /></span>
|
||||||
<span className="ar-main">
|
<span className="ar-main">
|
||||||
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
|
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
|
||||||
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity.pluginId}</span>
|
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity?.pluginId}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{artifacts.length === 0
|
{artifacts.length === 0
|
||||||
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
|
? <div className="empty-note">暂无文件,可添加材料或在对话中生成文档</div>
|
||||||
: visible.length === 0
|
: visible.length === 0
|
||||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||||
: groups.map(g => g.versions.length === 1
|
: groups.map(g => g.versions.length === 1
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
// 文档驾驶舱 I3:阅读器(连续正文 + 版本摘要/差异折叠)与右侧依据/影响面板。
|
||||||
|
// 消费 I1/I3 命令面 rdm.cockpit.node(+versions)/node.history(Dev 仓 docs/superpowers
|
||||||
|
// specs 2026-10-16 view-spec §5–§6)。不新增第四栏:右栏是选中节点的同一依据面,
|
||||||
|
// 全景与阅读器共享 selectedNodeRef;面板刷新原位进行,不抢占滚动位置(DV-R05/§5)。
|
||||||
|
// 折叠只用 @ui Collapsible;颜色/字号只消费 @ui/tokens.css。
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { Collapsible, EmptyState } from '@ui'
|
||||||
|
import {
|
||||||
|
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
|
||||||
|
PANEL_TABS, type CockpitContentBlock, type CockpitHistorySummary,
|
||||||
|
type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode,
|
||||||
|
type CockpitVersionSummary, type Density, type PanelTabId,
|
||||||
|
} from '../documentCockpit'
|
||||||
|
import type { CitationView } from '../regCitations'
|
||||||
|
import { CitedBody } from './RegCitation'
|
||||||
|
|
||||||
|
export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string }
|
||||||
|
|
||||||
|
interface NodeDetailShape {
|
||||||
|
meta: CockpitMeta
|
||||||
|
node: CockpitTreeNode
|
||||||
|
body?: CockpitContentBlock[]
|
||||||
|
/** U7:章节正文 [cit-…] 标记的引用视图(契约 §3)。 */
|
||||||
|
citations?: CitationView[] | null
|
||||||
|
children: CockpitTreeNode[]
|
||||||
|
sources: { items: any[] }
|
||||||
|
history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string }
|
||||||
|
impacts: { items: any[] }
|
||||||
|
conflicts: { items: any[] }
|
||||||
|
versions?: CockpitVersionSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function useNodeDetail(pid: string, sel?: CockpitNodeRef) {
|
||||||
|
const [detail, setDetail] = useState<NodeDetailShape | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const gen = useRef(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const g = ++gen.current
|
||||||
|
setDetail(null); setErr('')
|
||||||
|
if (!sel) return
|
||||||
|
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id })
|
||||||
|
.then(d => {
|
||||||
|
if (g !== gen.current) return
|
||||||
|
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
|
||||||
|
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
|
||||||
|
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
|
||||||
|
}
|
||||||
|
setDetail(d)
|
||||||
|
})
|
||||||
|
.catch(e => { if (g === gen.current) setErr(e.message) })
|
||||||
|
return () => { gen.current++ }
|
||||||
|
}, [pid, sel?.kind, sel?.id])
|
||||||
|
return { detail, err }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 版本块:当前版本 + 前一版本摘要;完整差异(行级标记)折叠。受限只给元数据。 */
|
||||||
|
export function VersionBlock({ versions, density }: { versions?: CockpitVersionSummary[]; density: Density }) {
|
||||||
|
if (!versions || versions.length === 0) return null
|
||||||
|
const { current, previous, redacted } = versionSummary(versions)
|
||||||
|
if (redacted || !current || current.content === undefined) {
|
||||||
|
return <div className="empty-note">版本受限(仅元数据可见)</div>
|
||||||
|
}
|
||||||
|
const prev = previous && previous.content !== undefined ? previous : undefined
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
id={`cockpit-versions-${current.version}`}
|
||||||
|
trigger={<span className="cockpit-versions-toggle">版本 {current.version}{previous ? `(前一版 ${previous.version})` : '(首版)'}与完整差异</span>}
|
||||||
|
>
|
||||||
|
<div className={`cockpit-versions cockpit-density-${density}`}>
|
||||||
|
{prev ? (
|
||||||
|
<>
|
||||||
|
<div className="cockpit-versions-head">当前 {current.version} · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
|
||||||
|
<pre className="cockpit-diff">
|
||||||
|
{diffLines(current.content || '', prev.content || '').map((l, i) => (
|
||||||
|
<div key={i} className={`cockpit-diff-${l.flag === '=' ? 'same' : l.flag === '+' ? 'add' : 'del'}`}>{l.flag} {l.text}</div>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
<div className="cockpit-versions-head">前一版 {prev.version} · {prev.actor || '未知'} · {new Date(prev.at).toLocaleString('zh-CN')}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="cockpit-versions-head">首版 · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阅读器中栏:选中章节=该节连续正文+版本块;选中文档=全章节连续装配(每节详情按需拉取)。 */
|
||||||
|
export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
||||||
|
pid: string
|
||||||
|
selection: ReaderSelection
|
||||||
|
sections: CockpitTreeNode[]
|
||||||
|
density: Density
|
||||||
|
onBack: () => void
|
||||||
|
}) {
|
||||||
|
const { detail, err } = useNodeDetail(pid, selection.ref)
|
||||||
|
const isSection = selection.ref.kind === 'section'
|
||||||
|
const [sectionDetails, setSectionDetails] = useState<Record<string, NodeDetailShape | undefined>>({})
|
||||||
|
const gen = useRef(0)
|
||||||
|
// 文档阅读器:按树顺序装配全部章节详情(上限 20;失败章节给受限占位不拖垮整篇)。
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSection || selection.ref.kind !== 'document') return
|
||||||
|
const g = ++gen.current
|
||||||
|
const list = sections.slice(0, 20)
|
||||||
|
setSectionDetails({})
|
||||||
|
for (const sec of list) {
|
||||||
|
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: 'section', id: sec.ref.id })
|
||||||
|
.then(d => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: d })) })
|
||||||
|
.catch(() => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: undefined })) })
|
||||||
|
}
|
||||||
|
return () => { gen.current++ }
|
||||||
|
}, [pid, isSection, selection.ref.id, sections])
|
||||||
|
const anchorRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
useEffect(() => { anchorRef.current?.scrollIntoView({ block: 'start' }) }, [selection.ref.id])
|
||||||
|
if (err) return <EmptyState icon="alert">阅读器加载失败:{err}</EmptyState>
|
||||||
|
return (
|
||||||
|
<div className={`cockpit-reader cockpit-density-${density}`}>
|
||||||
|
<div className="cockpit-nodehead" ref={anchorRef}>
|
||||||
|
<button type="button" className="cockpit-back" onClick={onBack}>← 返回全景</button>
|
||||||
|
<span className="cockpit-nodetitle">{detail?.node?.title || (isSection ? '章节' : '文档')}</span>
|
||||||
|
</div>
|
||||||
|
{isSection && detail && (
|
||||||
|
<>
|
||||||
|
{renderBody(detail.body, detail.meta, detail.citations, `sec-${selection.ref.id}`)}
|
||||||
|
<VersionBlock versions={detail.versions} density={density} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isSection && (
|
||||||
|
<>
|
||||||
|
{sections.length === 0 && <div className="empty-note">该文档暂无章节</div>}
|
||||||
|
{assembleReader(sections, sectionDetails).map(sec => (
|
||||||
|
<section key={sec.ref.id} id={`sec-${sec.ref.id}`} className="cockpit-reader-section">
|
||||||
|
<div className="cockpit-reader-sechead">
|
||||||
|
<span className="cockpit-reader-sectitle">{sec.title}</span>
|
||||||
|
{sec.blocks[0]?.kind === 'restricted'
|
||||||
|
? <span className="cockpit-badge">内容受限({sec.blocks[0].reason || '权限'})</span>
|
||||||
|
: sec.contentState === 'empty' && <span className="cockpit-badge">空</span>}
|
||||||
|
</div>
|
||||||
|
{sec.blocks[0]?.kind === 'text' && (
|
||||||
|
<CitedBody texts={sec.blocks.filter(b => b.kind === 'text').map(b => b.text || '')}
|
||||||
|
citations={sectionDetails[sec.ref.id]?.citations} scope={`doc-${sec.ref.id}`} />
|
||||||
|
)}
|
||||||
|
{sec.blocks.map((b, i) => b.kind !== 'text' && (
|
||||||
|
<p key={i} className="empty-note">内容受限——标题与状态可见,正文不展示</p>
|
||||||
|
))}
|
||||||
|
<VersionBlock versions={sec.versions} density={density} />
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta, citations: CitationView[] | null | undefined, scope: string) {
|
||||||
|
if (!body || body.length === 0) return <div className="empty-note">暂无正文</div>
|
||||||
|
if (body[0].kind === 'restricted') {
|
||||||
|
return <div className="empty-note">内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CitedBody texts={body.map(b => b.text || '')} citations={citations} scope={scope} />
|
||||||
|
{meta.truncated && <div className="cockpit-truncated">详情已截断:{meta.truncationReasons?.join(';')}</div>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右栏:依据/历史/影响/冲突四标签(同一选中节点;刷新原位,不抢占滚动)。 */
|
||||||
|
export function CockpitPanel({ pid, selection, tab, onTab, density }: {
|
||||||
|
pid: string
|
||||||
|
selection: ReaderSelection
|
||||||
|
tab: PanelTabId
|
||||||
|
onTab: (t: PanelTabId) => void
|
||||||
|
density: Density
|
||||||
|
}) {
|
||||||
|
const { detail, err } = useNodeDetail(pid, selection.ref)
|
||||||
|
const [historyItems, setHistoryItems] = useState<CockpitHistorySummary[]>([])
|
||||||
|
const [historyMore, setHistoryMore] = useState(false)
|
||||||
|
const [historyCursor, setHistoryCursor] = useState<string | undefined>(undefined)
|
||||||
|
useEffect(() => {
|
||||||
|
setHistoryItems([]); setHistoryMore(false); setHistoryCursor(undefined)
|
||||||
|
}, [selection.ref.kind, selection.ref.id])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detail?.history) return
|
||||||
|
// 严格合并:只接受 seq 更新的条目,乱序/重复丢弃;不重置已渲染内容。
|
||||||
|
setHistoryItems(prev => mergeHistoryStrict(prev, detail.history.items).items)
|
||||||
|
setHistoryMore(detail.history.hasMore)
|
||||||
|
setHistoryCursor(detail.history.nextCursor)
|
||||||
|
}, [detail])
|
||||||
|
const loadMoreHistory = useCallback(() => {
|
||||||
|
if (!historyCursor) return
|
||||||
|
api.rdmCommand<any>('rdm.cockpit.node.history', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id, cursor: historyCursor, limit: 20 })
|
||||||
|
.then(page => {
|
||||||
|
setHistoryItems(prev => mergeHistoryStrict(prev, page.items).items)
|
||||||
|
setHistoryMore(page.hasMore)
|
||||||
|
setHistoryCursor(page.nextCursor)
|
||||||
|
})
|
||||||
|
.catch(() => { /* 保留已渲染历史;可再次点击重试 */ })
|
||||||
|
}, [pid, selection.ref.kind, selection.ref.id, historyCursor])
|
||||||
|
const node = detail?.node
|
||||||
|
return (
|
||||||
|
<div className={`cockpit-panel cockpit-density-${density}`}>
|
||||||
|
<div className="cockpit-panel-tabs" role="tablist" aria-label="依据与影响">
|
||||||
|
{PANEL_TABS.map(t => (
|
||||||
|
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id}
|
||||||
|
className={`cockpit-panel-tab${tab === t.id ? ' cockpit-panel-tab-active' : ''}`}
|
||||||
|
onClick={() => onTab(t.id)}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{err && <div className="empty-note">依据面板加载失败:{err}</div>}
|
||||||
|
{!err && !detail && <div className="empty-note">加载依据…</div>}
|
||||||
|
{detail && tab === 'sources' && (
|
||||||
|
<div className="cockpit-panel-body">
|
||||||
|
<div className="cockpit-panel-count">来源 {node?.state.sourceCount ?? detail.sources.items.length} 条</div>
|
||||||
|
{detail.sources.items.length === 0 && <div className="empty-note">无来源——章节正文未挂依据切片</div>}
|
||||||
|
{detail.sources.items.map((s: any) => (
|
||||||
|
<div key={s.ref.id} className="cockpit-panel-row">
|
||||||
|
<span className="cockpit-panel-kind">{s.ref.kind}</span>
|
||||||
|
<span className="cockpit-panel-label">{s.label}</span>
|
||||||
|
{s.confidence && <span className="cockpit-badge">置信度 {s.confidence}</span>}
|
||||||
|
{s.redactedReason
|
||||||
|
? <span className="cockpit-badge">受限({s.redactedReason})</span>
|
||||||
|
: s.verification && s.verification !== 'not_applicable' && <span className="cockpit-badge">{s.verification}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail && tab === 'history' && (
|
||||||
|
<div className="cockpit-panel-body">
|
||||||
|
<div className="cockpit-panel-count">已提交事件(最新在前)</div>
|
||||||
|
{historyItems.length === 0 && <div className="empty-note">暂无历史</div>}
|
||||||
|
{historyItems.map(h => <div key={h.seq} className="cockpit-panel-row">{historyLine(h)}</div>)}
|
||||||
|
{historyMore && <button type="button" className="cockpit-tree-more" onClick={loadMoreHistory}>加载更早历史</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail && tab === 'impacts' && (
|
||||||
|
<div className="cockpit-panel-body">
|
||||||
|
<div className="cockpit-panel-count">影响 {node?.state.impactCount ?? detail.impacts.items.length} 项(一跳)</div>
|
||||||
|
{detail.impacts.items.length === 0 && <div className="empty-note">无一跳影响对象</div>}
|
||||||
|
{detail.impacts.items.map((r: any, i: number) => (
|
||||||
|
<div key={`${r.ref.kind}-${r.ref.id}-${i}`} className="cockpit-panel-row">
|
||||||
|
<span className="cockpit-panel-kind">{r.direction === 'incoming' ? '←' : '→'}</span>
|
||||||
|
<span className="cockpit-panel-label">{r.ref.kind}:{r.ref.id}</span>
|
||||||
|
<span className="cockpit-badge">{r.edgeType}</span>
|
||||||
|
<span className="cockpit-badge">{r.edgeState}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail && tab === 'conflicts' && (
|
||||||
|
<div className="cockpit-panel-body">
|
||||||
|
<div className="cockpit-panel-count">冲突 {node?.state.conflictCount ?? 0} 项</div>
|
||||||
|
{detail.conflicts.items.length === 0 && <div className="empty-note">无开放冲突</div>}
|
||||||
|
{detail.conflicts.items.map(c => (
|
||||||
|
<div key={c.id} className="cockpit-panel-row cockpit-panel-conflict">
|
||||||
|
<span className="cockpit-badge">{c.state === 'open' ? '开放' : c.state}</span>
|
||||||
|
<div className="cockpit-conflict-side">甲方:{c.left.visibility === 'full' ? c.left.claim : `受限(${c.left.redactedReason || '权限'})`}</div>
|
||||||
|
<div className="cockpit-conflict-side">乙方:{c.right.visibility === 'full' ? c.right.claim : `受限(${c.right.redactedReason || '权限'})`}</div>
|
||||||
|
<div className="cockpit-changetime">{new Date(c.detectedAt).toLocaleString('zh-CN')}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+250
-125
@@ -5,14 +5,17 @@ import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpe
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
|
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
|
||||||
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
|
import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline } from '../live'
|
||||||
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
|
|
||||||
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
|
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
|
||||||
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
|
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
|
||||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
|
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
|
||||||
import { docBridge } from './Workbench'
|
import { docBridge, openPendingConfirmation, pendingConfirmations } from './Workbench'
|
||||||
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
|
import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
|
||||||
import { ProcessTimeline } from './ProcessTimeline'
|
import { ProcessTimeline } from './ProcessTimeline'
|
||||||
|
import { ConversationWork } from './ConversationWork'
|
||||||
|
import { MessageTimestamp } from './MessageTimestamp'
|
||||||
|
import { loadRunEvents } from '../runEvents'
|
||||||
|
import '../companion-conversation.css'
|
||||||
|
|
||||||
function hasFiles(dt: DataTransfer | null): boolean {
|
function hasFiles(dt: DataTransfer | null): boolean {
|
||||||
return !!dt && Array.from(dt.types).includes('Files')
|
return !!dt && Array.from(dt.types).includes('Files')
|
||||||
@@ -23,14 +26,13 @@ function hasFiles(dt: DataTransfer | null): boolean {
|
|||||||
|
|
||||||
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
|
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
|
||||||
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||||
|
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
|
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
|
||||||
markdown={markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))} />
|
markdown={markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))} />
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
|
return null
|
||||||
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
|
|
||||||
return <ThinkingBlock part={part} idKey={part.text?.slice(0, 24) ?? 't'} defaultOpen={!!live} />
|
|
||||||
case 'tool_call':
|
case 'tool_call':
|
||||||
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
|
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
|
||||||
case 'tool_result': {
|
case 'tool_result': {
|
||||||
@@ -46,12 +48,12 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
|||||||
case 'plan':
|
case 'plan':
|
||||||
return null // 计划在右栏 Plan 标签呈现
|
return null // 计划在右栏 Plan 标签呈现
|
||||||
case 'card': {
|
case 'card': {
|
||||||
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
|
// 右栏工作可见时保留定位入口;详细正文只在右栏。关闭右栏时中栏可展开全文。
|
||||||
if (part.card?.cardId) {
|
if (part.card?.cardId) {
|
||||||
const ref = part.card.cardId
|
const ref = part.card.cardId
|
||||||
const card = findCard(ref)
|
const card = findCard(ref)
|
||||||
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
|
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
|
||||||
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={card}
|
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={workPaneOwnsCards || card?.work ? undefined : card}
|
||||||
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
|
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
|
||||||
}
|
}
|
||||||
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
|
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
|
||||||
@@ -105,18 +107,6 @@ function findCard(id: string) {
|
|||||||
return cardCache[id]
|
return cardCache[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 思考块(Collapsible 契约):左线弱色,流式默认展开、终态默认收起;展开态记忆。
|
|
||||||
function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string; defaultOpen?: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="part-thinking">
|
|
||||||
<Collapsible id={`think:${idKey}`} defaultOpen={defaultOpen} triggerClassName="part-thinking-summary"
|
|
||||||
trigger={<><span className="part-thinking-chev">▸</span>思考过程</>}>
|
|
||||||
<div className="part-thinking-body">{part.text}</div>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。
|
// 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。
|
||||||
function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: string; name: string; kind?: string }; updates?: number }) {
|
function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: string; name: string; kind?: string }; updates?: number }) {
|
||||||
const [menu, setMenu] = useState(false)
|
const [menu, setMenu] = useState(false)
|
||||||
@@ -189,10 +179,14 @@ function useElapsed(startedAt: number): string {
|
|||||||
// 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。
|
// 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。
|
||||||
function LiveBar({ live }: { live: LiveState }) {
|
function LiveBar({ live }: { live: LiveState }) {
|
||||||
const elapsed = useElapsed(live.startedAt)
|
const elapsed = useElapsed(live.startedAt)
|
||||||
|
const phase = livePhaseLabel(live)
|
||||||
|
const label = phase.startsWith('正在编写') ? '正在准备操作'
|
||||||
|
: phase.startsWith('正在运行') ? '正在处理'
|
||||||
|
: phase === '思考中' ? '正在梳理' : phase
|
||||||
return (
|
return (
|
||||||
<span className="live-bar">
|
<span className="live-bar">
|
||||||
<span className="live-dot" />
|
<span className="live-dot" />
|
||||||
<span className="mk-sweep">{livePhaseLabel(live)}</span>
|
<span className="mk-sweep">{label}</span>
|
||||||
<span className="live-elapsed">已工作 {elapsed}</span>
|
<span className="live-elapsed">已工作 {elapsed}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -205,21 +199,42 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
|||||||
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
|
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
|
||||||
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
|
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
|
||||||
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
|
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
|
||||||
|
const childStamp = useStore(s => msg.runId ? s.runs.filter(r => r.parentRunId === msg.runId)
|
||||||
|
.map(r => `${r.id}:${r.status}:${r.endedAt ?? ''}`).join('|') : '')
|
||||||
const parts = msg.parts ?? []
|
const parts = msg.parts ?? []
|
||||||
useLiveClock(!!live)
|
useLiveClock(!!live)
|
||||||
const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl'
|
const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl'
|
||||||
const split = useMemo(() => splitTimeline(parts, finished), [parts, finished])
|
const split = useMemo(() => splitTimeline(parts, finished), [parts, finished])
|
||||||
const showProcess = showProcessInline(live ?? null, autoCollapse)
|
const showProcess = showProcessInline(live ?? null, autoCollapse)
|
||||||
const memKey = `process:${msg.runId || msg.id}`
|
const memKey = `process:${msg.runId || msg.id}`
|
||||||
const touched = !!(msg.runId && touchedProcess.has(msg.runId))
|
const touched = touchedProcess.has(memKey)
|
||||||
const [open, setOpen] = useState(() => {
|
const [open, setOpen] = useState(() => {
|
||||||
if (live && !touched) return true
|
|
||||||
if (touched) return getPersistedOpen(memKey)
|
if (touched) return getPersistedOpen(memKey)
|
||||||
return showProcess
|
return showProcess
|
||||||
})
|
})
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (live && msg.runId && !touchedProcess.has(msg.runId)) setOpen(true)
|
if (!touchedProcess.has(memKey)) setOpen(showProcess)
|
||||||
}, [live, msg.runId])
|
}, [memKey, showProcess])
|
||||||
|
const [replayed, setReplayed] = useState<Part[] | null>(null)
|
||||||
|
const [processError, setProcessError] = useState(false)
|
||||||
|
useEffect(() => { setReplayed(null); setProcessError(false) }, [childStamp])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) { setReplayed(null); setProcessError(false) }
|
||||||
|
}, [open])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || live || !msg.runId || !convId || replayed || processError) return
|
||||||
|
const controller = new AbortController()
|
||||||
|
let active = true
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const events = await loadRunEvents(convId, msg.runId!, controller.signal)
|
||||||
|
if (active) setReplayed(runEventsToTimeline(events, msg.runId))
|
||||||
|
} catch {
|
||||||
|
if (active) setProcessError(true)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => { active = false; controller.abort() }
|
||||||
|
}, [open, live, msg.runId, convId, replayed, processError])
|
||||||
const extras = live ? [] : citedArtifactCards(split.final)
|
const extras = live ? [] : citedArtifactCards(split.final)
|
||||||
const cards = live ? [] : dedupeDeliverables(split.final)
|
const cards = live ? [] : dedupeDeliverables(split.final)
|
||||||
const lastText = (() => {
|
const lastText = (() => {
|
||||||
@@ -228,14 +243,15 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
|||||||
})()
|
})()
|
||||||
const streamingText = !!(live && live.phase === 'responding' && lastText >= 0
|
const streamingText = !!(live && live.phase === 'responding' && lastText >= 0
|
||||||
&& !live.items.some(it => it.kind === 'tool' && it.state !== 'done'))
|
&& !live.items.some(it => it.kind === 'tool' && it.state !== 'done'))
|
||||||
const toolCount = split.timeline.filter(p => p.type === 'tool_call').length
|
const processParts = replayed === null ? split.timeline : mergeProcessTimeline(split.timeline, replayed, split.final)
|
||||||
const headline = live ? livePhaseLabel(live) : processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)
|
const toolCount = processParts.filter(p => p.type === 'tool_call').length
|
||||||
|
const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}`
|
||||||
const timeline = (
|
const timeline = (
|
||||||
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
|
<ProcessTimeline parts={processParts} live={!!live} idKey={msg.id}
|
||||||
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
|
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
|
||||||
)
|
)
|
||||||
const toggleProcess = (next: boolean) => {
|
const toggleProcess = (next: boolean) => {
|
||||||
if (msg.runId) touchedProcess.add(msg.runId)
|
touchedProcess.add(memKey)
|
||||||
setPersistedOpen(memKey, next)
|
setPersistedOpen(memKey, next)
|
||||||
setOpen(next)
|
setOpen(next)
|
||||||
}
|
}
|
||||||
@@ -244,16 +260,21 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
|||||||
<div className="msg-role">
|
<div className="msg-role">
|
||||||
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
|
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
|
||||||
<span>助手</span>
|
<span>助手</span>
|
||||||
|
<MessageTimestamp value={msg.createdAt || run?.createdAt} />
|
||||||
{live && <LiveBar live={live} />}
|
{live && <LiveBar live={live} />}
|
||||||
</div>
|
</div>
|
||||||
{split.timeline.length > 0 && (
|
{(split.timeline.length > 0 || !!msg.runId) && (
|
||||||
<div className="process-collapse">
|
<div className="process-collapse">
|
||||||
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
|
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||||||
trigger={<>
|
trigger={<>
|
||||||
<span className="process-collapse-chev">▸</span>
|
<span className="process-collapse-chev">▸</span>
|
||||||
<span className={live ? 'mk-sweep' : ''}>{headline}</span>
|
<span className={live ? 'mk-sweep' : ''}>{headline}</span>
|
||||||
</>}>
|
</>}>
|
||||||
<div className="body">{timeline}</div>
|
<div className="body">
|
||||||
|
{replayed === null && !live && !processError && <div className="process-load-note">正在读取完整记录…</div>}
|
||||||
|
{processError && <div className="process-load-note">完整记录暂不可用,以下为已保存的摘要</div>}
|
||||||
|
{processParts.length > 0 ? timeline : replayed && <div className="process-load-note">这轮没有可回看的过程记录</div>}
|
||||||
|
</div>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -268,7 +289,8 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
|||||||
{extras.map(art => (
|
{extras.map(art => (
|
||||||
<DeliverableCard key={art.id} artifact={{ artifactId: art.id, name: art.name, kind: art.kind }} />
|
<DeliverableCard key={art.id} artifact={{ artifactId: art.id, name: art.name, kind: art.kind }} />
|
||||||
))}
|
))}
|
||||||
{live && <LiveAgentNotes live={live} />}
|
{live && Object.keys(live.agentNotes).length > 0 && <Collapsible id={`agent-notes:${live.runId}`}
|
||||||
|
triggerClassName="process-collapse-summary" trigger={<span>协作记录</span>}><LiveAgentNotes live={live} /></Collapsible>}
|
||||||
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
|
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
|
||||||
{convId && <MessageTools msg={msg} convId={convId} />}
|
{convId && <MessageTools msg={msg} convId={convId} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -282,16 +304,20 @@ function SessionNoteField({ convId, note }: { convId: string; note: string }) {
|
|||||||
<input className="search-input session-note" value={value} placeholder="本会话要求"
|
<input className="search-input session-note" value={value} placeholder="本会话要求"
|
||||||
title="附在上下文末尾,不改系统前缀"
|
title="附在上下文末尾,不改系统前缀"
|
||||||
onChange={e => setValue(e.target.value)}
|
onChange={e => setValue(e.target.value)}
|
||||||
onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => store.openConversation(convId)) }} />
|
onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => {
|
||||||
|
if (store.getState().currentConvId === convId) return store.openConversation(convId)
|
||||||
|
}) }} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageTools({ msg, convId }: { msg: Message; convId: string }) {
|
function MessageTools({ msg, convId }: { msg: Message; convId: string }) {
|
||||||
const text = msg.parts.filter(p => p.type === 'text').map(p => p.text ?? '').join('\n')
|
const text = msg.parts.filter(p => p.type === 'text').map(p => p.text ?? '').join('\n')
|
||||||
const refresh = () => { void store.openConversation(convId) }
|
const refresh = () => { if (store.getState().currentConvId === convId) void store.openConversation(convId) }
|
||||||
return (
|
return (
|
||||||
<div className="msg-tools">
|
<div className="msg-tools">
|
||||||
<button className="btn ghost" type="button" onClick={() => { void navigator.clipboard.writeText(text) }}>复制</button>
|
<button className="companion-message-copy" type="button" onClick={() => { void navigator.clipboard.writeText(text) }}>复制</button>
|
||||||
|
<Collapsible id={`message-operations:${convId}:${msg.id}`} trigger="消息操作" triggerClassName="companion-message-copy">
|
||||||
|
<div className="companion-message-actions">
|
||||||
<button className="btn ghost" type="button" onClick={() => { void api.excludeMessage(msg.id, !msg.excluded).then(refresh) }}>
|
<button className="btn ghost" type="button" onClick={() => { void api.excludeMessage(msg.id, !msg.excluded).then(refresh) }}>
|
||||||
{msg.excluded ? '纳入上下文' : '排除出上下文'}
|
{msg.excluded ? '纳入上下文' : '排除出上下文'}
|
||||||
</button>
|
</button>
|
||||||
@@ -301,20 +327,40 @@ function MessageTools({ msg, convId }: { msg: Message; convId: string }) {
|
|||||||
{msg.variantGroup && msg.excluded && (
|
{msg.variantGroup && msg.excluded && (
|
||||||
<button className="btn ghost" type="button" onClick={() => { void api.selectVariant(msg.id).then(refresh) }}>使用此候选</button>
|
<button className="btn ghost" type="button" onClick={() => { void api.selectVariant(msg.id).then(refresh) }}>使用此候选</button>
|
||||||
)}
|
)}
|
||||||
|
{msg.role === 'user' && <RewindControl convId={convId} messageId={msg.id} />}
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
{msg.variantGroup && <span className="process-collapse-meta">候选 {(msg.variantIndex ?? 0) + 1}</span>}
|
{msg.variantGroup && <span className="process-collapse-meta">候选 {(msg.variantIndex ?? 0) + 1}</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 本次实际依据:只列来源名称与 token 估算,不展开正文(正文不随观测下发)。 */
|
||||||
|
export function BasisStrip({ obs }: { obs: ContextObs }) {
|
||||||
|
const sources = obs.sections && obs.sections.length > 0
|
||||||
|
? obs.sections.map(s => ({ key: s.name, label: `${s.name} · 约 ${s.estTokens} token` }))
|
||||||
|
: obs.items.map(it => ({ key: it, label: it }))
|
||||||
|
if (sources.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="conversation-basis" data-conversation-basis aria-label="本次实际依据">
|
||||||
|
<span className="conversation-basis-label">本次依据</span>
|
||||||
|
{sources.map(s => <span key={s.key} className="conversation-basis-chip">{s.label}</span>)}
|
||||||
|
<span>共约 {obs.estChars} token</span>
|
||||||
|
{obs.truncations && obs.truncations.length > 0 && <span className="conversation-basis-warn">有 {obs.truncations.length} 处内容被裁剪</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
||||||
function ContextObsView({ obs }: { obs: ContextObs }) {
|
function ContextObsView({ obs }: { obs: ContextObs }) {
|
||||||
return (
|
return (
|
||||||
<div className="process-collapse">
|
<div className="process-collapse">
|
||||||
|
<BasisStrip obs={obs} />
|
||||||
<Collapsible id={`ctxobs:${obs.estChars}`} triggerClassName="process-collapse-summary" unmountDelay={300}
|
<Collapsible id={`ctxobs:${obs.estChars}`} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||||||
trigger={<>
|
trigger={<>
|
||||||
<span className="process-collapse-chev">▸</span>
|
<span className="process-collapse-chev">▸</span>
|
||||||
<span>🧩 上下文构成</span>
|
<span>🧩 上下文构成</span>
|
||||||
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} token)</span>
|
<span className="process-collapse-meta">查看构成明细</span>
|
||||||
</>}>
|
</>}>
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<ul className="ui-list">
|
<ul className="ui-list">
|
||||||
@@ -349,16 +395,32 @@ function LiveAgentNotes({ live }: { live: LiveState }) {
|
|||||||
|
|
||||||
function HITLCardView({ card }: { card: HITLCard }) {
|
function HITLCardView({ card }: { card: HITLCard }) {
|
||||||
const [free, setFree] = useState('')
|
const [free, setFree] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const actionState = useStore(s => s.cardActionStates?.[`hitl:${card.id}`])
|
||||||
|
const submitting = actionState !== undefined
|
||||||
|
// wfcompile.doScript 使用固定首行 + 脚本预览;其他多行问题可能包含
|
||||||
|
// 用户必须理解的业务后果,保持原样,不凭内容猜测其信息层级。
|
||||||
|
const scriptPrefix = '运行这段脚本?\n'
|
||||||
|
const hasScriptPreview = card.kind === 'confirm' && card.question.startsWith(scriptPrefix)
|
||||||
|
const question = hasScriptPreview ? scriptPrefix.trimEnd() : card.question
|
||||||
|
const detail = [hasScriptPreview ? card.question.slice(scriptPrefix.length) : '', card.detail]
|
||||||
|
.filter(value => value?.trim()).join('\n\n')
|
||||||
const answer = async (resp: { choiceId?: string; freeText?: string }) => {
|
const answer = async (resp: { choiceId?: string; freeText?: string }) => {
|
||||||
setSubmitting(true)
|
if (store.getState().cardActionStates?.[`hitl:${card.id}`]) return
|
||||||
await store.resolveHITL(card.id, resp)
|
await store.resolveHITL(card.id, resp)
|
||||||
setSubmitting(false)
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={`hitl-card${card.kind === 'confirm' ? ' wait' : ''}${card.kind === 'choice' || card.kind === 'input' ? ' ask' : ''}`}>
|
<div className={`hitl-card${card.kind === 'confirm' ? ' wait' : ''}${card.kind === 'choice' || card.kind === 'input' ? ' ask' : ''}`}>
|
||||||
<div className="hitl-q"><Icon name="check" size={15} />{card.question}</div>
|
<div className="hitl-q"><Icon name="check" size={15} />{question}</div>
|
||||||
{card.detail && <pre className="hitl-detail">{card.detail}</pre>}
|
{card.blocked && <div className="companion-action-feedback" role="status">
|
||||||
|
操作受限:{card.blockReason || '当前操作条件尚未满足'}
|
||||||
|
</div>}
|
||||||
|
{detail && <div className="process-collapse"><Collapsible id={`hitl-details:${card.conversationId}:${card.id}`}
|
||||||
|
triggerClassName="process-collapse-summary" trigger={<><span className="process-collapse-chev">▸</span>操作详情</>}>
|
||||||
|
<pre className="hitl-detail">{detail}</pre>
|
||||||
|
</Collapsible></div>}
|
||||||
|
{actionState && <div className="companion-action-feedback" role="status">
|
||||||
|
{actionState === 'unknown' ? '正在核实处理结果' : '正在提交你的选择'}
|
||||||
|
</div>}
|
||||||
{card.choices && (
|
{card.choices && (
|
||||||
<div className="hitl-choices">
|
<div className="hitl-choices">
|
||||||
{card.choices.map(c => (
|
{card.choices.map(c => (
|
||||||
@@ -373,7 +435,7 @@ function HITLCardView({ card }: { card: HITLCard }) {
|
|||||||
{card.allowFreeInput && (
|
{card.allowFreeInput && (
|
||||||
<div className="hitl-free">
|
<div className="hitl-free">
|
||||||
<input className="search-input" value={free} onChange={e => setFree(e.target.value)}
|
<input className="search-input" value={free} onChange={e => setFree(e.target.value)}
|
||||||
placeholder={card.freeInputHint || '自由输入…'} onKeyDown={e => { if (e.key === 'Enter' && free.trim()) answer({ freeText: free.trim() }) }} />
|
disabled={submitting} placeholder={card.freeInputHint || '补充你的要求'} onKeyDown={e => { if (e.key === 'Enter' && free.trim() && !submitting) void answer({ freeText: free.trim() }) }} />
|
||||||
<button className="btn ghost" disabled={!free.trim() || submitting} onClick={() => answer({ freeText: free.trim() })}>发送</button>
|
<button className="btn ghost" disabled={!free.trim() || submitting} onClick={() => answer({ freeText: free.trim() })}>发送</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -409,18 +471,19 @@ function OutboxPanel() {
|
|||||||
if (outbox.length === 0) return null
|
if (outbox.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<Collapsible id="composer-outbox" defaultOpen
|
<Collapsible id="composer-outbox" defaultOpen
|
||||||
trigger={<span>排队 {outbox.length}</span>}
|
trigger={<span>待发送 {outbox.length}</span>}
|
||||||
triggerClassName="outbox-trigger">
|
triggerClassName="outbox-trigger">
|
||||||
<ul className="outbox-list">
|
<ul className="outbox-list">
|
||||||
{outbox.map(item => (
|
{outbox.map(item => (
|
||||||
<li key={item.id} className="outbox-item">
|
<li key={item.id} className="outbox-item">
|
||||||
|
{item.deliveryError && <span className="outbox-text" role="status">{item.deliveryError}</span>}
|
||||||
{editing === item.id ? (
|
{editing === item.id ? (
|
||||||
<input className="search-input outbox-edit" value={draft} onChange={e => setDraft(e.target.value)}
|
<input className="search-input outbox-edit" value={draft} onChange={e => setDraft(e.target.value)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) }
|
if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) }
|
||||||
}} />
|
}} />
|
||||||
) : (
|
) : (
|
||||||
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}</span>
|
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}{item.taskRef ? ` · 基于「${item.taskRef.title}」` : ''}</span>
|
||||||
)}
|
)}
|
||||||
{editing === item.id ? (
|
{editing === item.id ? (
|
||||||
<button className="btn ghost" onClick={() => { store.updateOutbox(item.id, draft); setEditing(null) }}>保存</button>
|
<button className="btn ghost" onClick={() => { store.updateOutbox(item.id, draft); setEditing(null) }}>保存</button>
|
||||||
@@ -428,6 +491,7 @@ function OutboxPanel() {
|
|||||||
<button className="btn ghost" onClick={() => { setEditing(item.id); setDraft(item.text) }}>修改</button>
|
<button className="btn ghost" onClick={() => { setEditing(item.id); setDraft(item.text) }}>修改</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn ghost" onClick={() => store.sendOutboxNow(item.id)}>发送</button>
|
<button className="btn ghost" onClick={() => store.sendOutboxNow(item.id)}>发送</button>
|
||||||
|
{item.taskRef && <button className="btn ghost" onClick={() => store.clearOutboxReference(item.id)}>移除引用</button>}
|
||||||
<button className="icon-btn" title="移出队列" onClick={() => store.removeOutbox(item.id)}><Icon name="x" size={12} /></button>
|
<button className="icon-btn" title="移出队列" onClick={() => store.removeOutbox(item.id)}><Icon name="x" size={12} /></button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -442,7 +506,7 @@ export function NoticeMessage({ msg }: { msg: Message }) {
|
|||||||
return (
|
return (
|
||||||
<div className="msg notice" data-msg-id={msg.id}>
|
<div className="msg notice" data-msg-id={msg.id}>
|
||||||
<Collapsible id={`notice:${msg.id}`} triggerClassName="notice-summary"
|
<Collapsible id={`notice:${msg.id}`} triggerClassName="notice-summary"
|
||||||
trigger={<span className="notice-label">通知 · {preview}</span>}>
|
trigger={<><span className="notice-label">通知 · {preview}</span><MessageTimestamp value={msg.createdAt} /></>}>
|
||||||
<div className="notice-body">{text}</div>
|
<div className="notice-body">{text}</div>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
@@ -450,6 +514,13 @@ export function NoticeMessage({ msg }: { msg: Message }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Conversation() {
|
export function Conversation() {
|
||||||
|
const conversationId = useStore(s => s.currentConvId)
|
||||||
|
const projectId = useStore(s => s.currentProjectId)
|
||||||
|
// 本地草稿、附件、折叠设置与滚动跟随属于当前会话,切换时一起重置。
|
||||||
|
return <ConversationSession key={`${projectId}:${conversationId}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConversationSession() {
|
||||||
const currentConvId = useStore(s => s.currentConvId)
|
const currentConvId = useStore(s => s.currentConvId)
|
||||||
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
|
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
|
||||||
const messages = useStore(s => s.messages)
|
const messages = useStore(s => s.messages)
|
||||||
@@ -462,17 +533,27 @@ export function Conversation() {
|
|||||||
const artifacts = useStore(s => s.artifacts)
|
const artifacts = useStore(s => s.artifacts)
|
||||||
const profiles = useStore(s => s.profiles)
|
const profiles = useStore(s => s.profiles)
|
||||||
const cards = useStore(s => s.cards)
|
const cards = useStore(s => s.cards)
|
||||||
const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
|
const currentProjectId = useStore(s => s.currentProjectId)
|
||||||
|
const cardActionStates = useStore(s => s.cardActionStates)
|
||||||
const runtimeMock = useStore(s => s.runtimeMock)
|
const workReference = useStore(s => s.workReference)
|
||||||
|
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
||||||
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
|
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
|
||||||
const [dragDepth, setDragDepth] = useState(0)
|
const [dragDepth, setDragDepth] = useState(0)
|
||||||
|
const [uploading, setUploading] = useState(0)
|
||||||
|
const [uploadError, setUploadError] = useState('')
|
||||||
|
const composeActive = useRef(true)
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const bodyRef = useRef<HTMLDivElement>(null)
|
const bodyRef = useRef<HTMLDivElement>(null)
|
||||||
const stickRef = useRef(true)
|
const stickRef = useRef(true)
|
||||||
const [showJump, setShowJump] = useState(false)
|
const [showJump, setShowJump] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
composeActive.current = true
|
||||||
|
return () => { composeActive.current = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
|
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
|
||||||
cardCache = Object.fromEntries(cards.map(c => [c.id, c]))
|
cardCache = Object.fromEntries(cards.map(c => [c.id, c]))
|
||||||
|
|
||||||
@@ -484,77 +565,107 @@ export function Conversation() {
|
|||||||
})
|
})
|
||||||
const busy = isShellBusy(phase)
|
const busy = isShellBusy(phase)
|
||||||
const showStop = phase === 'running'
|
const showStop = phase === 'running'
|
||||||
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
|
// Background work stays in its own card and does not insert another chat turn.
|
||||||
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
|
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
|
||||||
|
const unreadResults = cards.filter(card => card.work?.result && card.work.resultVersion > card.work.readVersion)
|
||||||
|
const pendingHere = pendingConfirmations(cards, currentConvId, currentProjectId)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stickRef.current) return
|
if (!stickRef.current) return
|
||||||
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
||||||
}, [messages.length, live?.items, live?.phase, activeHitl.length])
|
}, [messages.length, live?.items, live?.phase, activeHitl.length])
|
||||||
|
|
||||||
|
const composeIsCurrent = () => {
|
||||||
|
const state = store.getState()
|
||||||
|
return composeActive.current && state.currentConvId === currentConvId && state.currentProjectId === currentProjectId
|
||||||
|
}
|
||||||
|
const addMaterials = async (files: File[]) => {
|
||||||
|
if (!files.length) return
|
||||||
|
setUploadError('')
|
||||||
|
setUploading(count => count + files.length)
|
||||||
|
for (const file of files) {
|
||||||
|
if (!composeIsCurrent()) return
|
||||||
|
const id = await store.uploadAttachment(file)
|
||||||
|
if (!composeIsCurrent()) return
|
||||||
|
setUploading(count => Math.max(0, count - 1))
|
||||||
|
if (id) {
|
||||||
|
setAttachmentIds(ids => [...ids, id])
|
||||||
|
setAttachmentNames(names => [...names, file.name])
|
||||||
|
} else {
|
||||||
|
setUploadError(message => [message, `「${file.name}」未能添加,请重新选择文件`].filter(Boolean).join(';'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
if (!text.trim() && attachmentIds.length === 0) return
|
if (uploading > 0 || (!text.trim() && attachmentIds.length === 0)) return
|
||||||
const t = text
|
const t = text
|
||||||
const ids = attachmentIds
|
const ids = attachmentIds
|
||||||
const names = attachmentNames
|
const names = attachmentNames
|
||||||
setText(''); setAttachmentIds([]); setAttachmentNames([])
|
const reference = workReference ?? undefined
|
||||||
if (busy) {
|
if (reference && ids.length > 0) {
|
||||||
store.enqueueOutbox(t, ids, names)
|
store.toast('这条消息已引用工作结果,请先移除引用或附件再发送')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await store.sendMessage(t, ids)
|
setText(''); setAttachmentIds([]); setAttachmentNames([])
|
||||||
|
store.clearWorkReference()
|
||||||
|
if (busy) {
|
||||||
|
store.enqueueOutbox(t, ids, names, reference)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await store.sendMessage(t, ids, undefined, reference)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentConvId) {
|
if (!currentConvId) {
|
||||||
return (
|
return (
|
||||||
<div className="chat">
|
<div className="chat work-companion-chat">
|
||||||
<div className="chat-body"><div className="empty-state">
|
<div className="chat-body"><div className="empty-state companion-empty">
|
||||||
<div style={{ fontSize: 40 }}>⌘</div>
|
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
|
||||||
<div>选择或创建一个会话开始</div>
|
<div className="companion-empty-title">从一件需要完成的工作开始</div>
|
||||||
{runtimeMock
|
<div>告诉助手你的目标,或准备好需要一起阅读的材料</div>
|
||||||
? <div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error</div>
|
<button type="button" className="btn" onClick={() => { void store.createTask() }}>开始新对话</button>
|
||||||
: <div style={{ fontSize: 'var(--fs-ui-sm)' }}>演示模式可在设置里打开,无需模型密钥</div>}
|
|
||||||
</div></div>
|
</div></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`chat ${dragDepth > 0 ? 'is-dragging' : ''}`}
|
<div className={`chat work-companion-chat ${dragDepth > 0 ? 'is-dragging' : ''}`}
|
||||||
onDragEnter={e => { if (!hasFiles(e.dataTransfer)) return; e.preventDefault(); setDragDepth(d => d + 1) }}
|
onDragEnter={e => { if (!hasFiles(e.dataTransfer)) return; e.preventDefault(); setDragDepth(d => d + 1) }}
|
||||||
onDragOver={e => { if (hasFiles(e.dataTransfer)) e.preventDefault() }}
|
onDragOver={e => { if (hasFiles(e.dataTransfer)) e.preventDefault() }}
|
||||||
onDragLeave={() => setDragDepth(d => Math.max(0, d - 1))}
|
onDragLeave={() => setDragDepth(d => Math.max(0, d - 1))}
|
||||||
onDrop={async e => {
|
onDrop={e => {
|
||||||
if (!e.dataTransfer?.files?.length) return
|
if (!e.dataTransfer?.files?.length) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDragDepth(0)
|
setDragDepth(0)
|
||||||
for (const f of Array.from(e.dataTransfer.files)) {
|
void addMaterials(Array.from(e.dataTransfer.files))
|
||||||
const id = await store.uploadAttachment(f)
|
|
||||||
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
|
|
||||||
}
|
|
||||||
}}>
|
}}>
|
||||||
{dragDepth > 0 && <div className="drop-veil">释放以上传资料</div>}
|
{dragDepth > 0 && <div className="drop-veil">释放以上传资料</div>}
|
||||||
<div className="chat-header">
|
<div className="chat-header">
|
||||||
<span className="project-badge" title="当前项目">{projectName}</span>
|
|
||||||
<div className="chat-title">{conversation?.title ?? '会话'}</div>
|
<div className="chat-title">{conversation?.title ?? '会话'}</div>
|
||||||
<select className="effect-badge" value={conversation?.profileId || 'default'} title="切换会话画像"
|
<Collapsible id={`conversation-settings:${currentProjectId}:${currentConvId}`} triggerClassName="companion-settings-trigger"
|
||||||
|
trigger={<><Icon name="settings" size={14} /><span>会话设置</span></>} bodyClassName="companion-settings-body">
|
||||||
|
<label className="companion-setting"><span>工作方式</span>
|
||||||
|
<select value={conversation?.profileId || 'default'} aria-label="工作方式"
|
||||||
onChange={async e => {
|
onChange={async e => {
|
||||||
if (!currentConvId) return
|
if (!currentConvId) return
|
||||||
await api.setConversationProfile(currentConvId, e.target.value)
|
await api.setConversationProfile(currentConvId, e.target.value)
|
||||||
await store.openConversation(currentConvId)
|
if (store.getState().currentConvId === currentConvId) await store.openConversation(currentConvId)
|
||||||
}}>
|
}}>
|
||||||
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<button className="btn ghost" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
</label>
|
||||||
|
{!conversation?.scratch && <div className="companion-setting"><span>读取同项目的其他对话</span><button className="btn ghost"
|
||||||
title={conversation?.allowCrossRead === false
|
title={conversation?.allowCrossRead === false
|
||||||
? '本会话不读取其他会话记录(点击开启)'
|
? '本会话不读取其他会话记录(点击开启)'
|
||||||
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
|
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
|
||||||
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
|
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
|
||||||
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
|
{conversation?.allowCrossRead === false ? '关闭' : '允许'}
|
||||||
</button>
|
</button>
|
||||||
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
|
<span className="companion-setting-note">实际读取范围还需项目设置允许</span></div>}
|
||||||
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
<label className="companion-setting"><span>这次工作的要求</span><SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} /></label>
|
||||||
{!connected && <span className="conn-label">重连中</span>}
|
</Collapsible>
|
||||||
|
<span title={connected ? '已连接' : '正在重新连接'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chat-body" ref={bodyRef}
|
<div className="chat-body" ref={bodyRef}
|
||||||
@@ -565,13 +676,6 @@ export function Conversation() {
|
|||||||
const stick = dist < 80
|
const stick = dist < 80
|
||||||
stickRef.current = stick
|
stickRef.current = stick
|
||||||
setShowJump(prev => (prev === !stick ? prev : !stick))
|
setShowJump(prev => (prev === !stick ? prev : !stick))
|
||||||
const ratio = scrollRatio(src)
|
|
||||||
if (ratio === null) return
|
|
||||||
const dst = document.querySelector('[data-scroll-sync="inbox"]')
|
|
||||||
if (!dst) return
|
|
||||||
const top = scrollTopForRatio(dst, ratio)
|
|
||||||
if (top === null) return
|
|
||||||
dst.scrollTop = top
|
|
||||||
}}>
|
}}>
|
||||||
{foldRunErrors(messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent')).map(m => {
|
{foldRunErrors(messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent')).map(m => {
|
||||||
if (m.role === 'user') {
|
if (m.role === 'user') {
|
||||||
@@ -581,16 +685,20 @@ export function Conversation() {
|
|||||||
<div className={`msg user${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
|
<div className={`msg user${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
|
||||||
<div className="msg-role">
|
<div className="msg-role">
|
||||||
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
|
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
|
||||||
|
<MessageTimestamp value={m.createdAt} />
|
||||||
</div>
|
</div>
|
||||||
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
|
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
|
||||||
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
|
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
|
||||||
{currentConvId && <RewindControl convId={currentConvId} messageId={m.id} />}
|
{anchored.map(c => workPaneOwnsCards
|
||||||
{anchored.map(c => (
|
? <button type="button" key={c.id} className="conversation-action-anchor" onClick={() => store.openInbox(c.id)}>
|
||||||
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
<span>{c.title}</span><span>在工作栏处理 ↗</span>
|
||||||
|
</button>
|
||||||
|
: <InlineConfirmCard key={c.id} card={c} callbacks={{
|
||||||
onAction: (card, actionId) => store.cardAction(card, actionId),
|
onAction: (card, actionId) => store.cardAction(card, actionId),
|
||||||
|
actionState: card => cardActionStates?.[card.id],
|
||||||
onLocate: card => store.openInbox(card.id),
|
onLocate: card => store.openInbox(card.id),
|
||||||
}} />
|
}} />
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -600,10 +708,16 @@ export function Conversation() {
|
|||||||
{showLiveBubble(live?.runId ?? null, messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string)) && live &&
|
{showLiveBubble(live?.runId ?? null, messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string)) && live &&
|
||||||
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
|
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
|
||||||
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
|
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
|
||||||
{messages.length === 0 && <div className="empty-state">
|
<ConversationWork />
|
||||||
|
{messages.length === 0 && !live && cards.length === 0 && activeHitl.length === 0 && <div className="empty-state companion-empty">
|
||||||
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
|
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
|
||||||
<div>发送消息开始对话</div>
|
<div className="companion-empty-title">这次想完成什么</div>
|
||||||
{runtimeMock && <div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>}
|
<div>说说目标,也可以先添加材料</div>
|
||||||
|
<div className="companion-examples" aria-label="可参考的工作目标">
|
||||||
|
{['帮我整理这些材料中的重点和缺项', '一起比较这两个方案,说明各自适合的情况', '帮我把已有想法整理成一份草稿'].map(example => (
|
||||||
|
<button type="button" className="companion-example" key={example} onClick={() => { setText(example); inputRef.current?.focus() }}>{example}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
{showJump && (
|
{showJump && (
|
||||||
@@ -616,59 +730,70 @@ export function Conversation() {
|
|||||||
|
|
||||||
<div className="input-bar">
|
<div className="input-bar">
|
||||||
<OutboxPanel />
|
<OutboxPanel />
|
||||||
|
{!workPaneOwnsCards && pendingHere > 0 && <button type="button" className="work-result-notice" data-pending-confirm
|
||||||
|
onClick={() => openPendingConfirmation(cards, currentConvId, currentProjectId)}>
|
||||||
|
<Icon name="message" size={14} /><span>{pendingHere} 项待你确认</span><span>在工作栏处理 ↗</span>
|
||||||
|
</button>}
|
||||||
|
{unreadResults.length > 0 && <button type="button" className="work-result-notice" onClick={() => store.openInbox(unreadResults[0].id)}>
|
||||||
|
<Icon name="check" size={14} /><span>{unreadResults.length} 项工作有新结果</span><span>查看工作 ↗</span>
|
||||||
|
</button>}
|
||||||
|
{workReference && <div className="composer-work-reference" role="group" aria-label="本条消息引用的工作结果">
|
||||||
|
<span>基于「{workReference.title}」的第 {workReference.resultVersion} 版结果</span>
|
||||||
|
<button type="button" className="icon-btn" aria-label="移除工作结果引用" onClick={() => store.clearWorkReference()}><Icon name="x" size={12} /></button>
|
||||||
|
</div>}
|
||||||
|
{!connected && <div className="companion-reconnect" role="status">正在重新连接,最新进展待确认</div>}
|
||||||
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
|
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
|
||||||
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
|
后台工作完成后更新原卡,用户按需打开。 */}
|
||||||
{(busy || bgRunning > 0) && (
|
{(busy || bgRunning > 0) && (
|
||||||
<div className="input-status" data-testid="input-status">
|
<div className="input-status" data-testid="input-status">
|
||||||
<span className="live-dot" />
|
<span className="live-dot" />
|
||||||
{busy
|
{busy
|
||||||
? <span className="mk-sweep">助手工作中{bgRunning > 0 ? ` · ${bgRunning} 个后台任务` : ''}</span>
|
? <span className="mk-sweep">助手正在处理{bgRunning > 0 ? ` · ${bgRunning} 项后台工作` : ''}</span>
|
||||||
: <span>{bgRunning} 个后台任务进行中</span>}
|
: <span>{bgRunning} 项后台工作</span>}
|
||||||
<span className="input-status-note">{busy ? '输入仍可排队' : '完成后回到对话汇报'}</span>
|
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,结果会更新到工作卡'}</span>
|
||||||
<span style={{ flex: 1 }} />
|
|
||||||
<button type="button" className="input-status-link" onClick={() => store.openInbox()}>在工作里查看</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{attachmentNames.length > 0 && (
|
{attachmentNames.length > 0 && (
|
||||||
<div className="attachments">
|
<div className="attachments">
|
||||||
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
|
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
|
||||||
<span style={{ cursor: 'pointer', display: 'inline-flex' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}><Icon name="x" size={11} /></span>
|
<button type="button" className="companion-remove-material" aria-label={`移除材料 ${n}`} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}><Icon name="x" size={11} /></button>
|
||||||
</span>)}
|
</span>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{uploading > 0 && <div className="companion-upload-status" role="status">正在添加 {uploading} 份材料,完成后即可发送</div>}
|
||||||
|
{uploadError && <div className="companion-upload-error" role="alert">{uploadError}</div>}
|
||||||
|
<Collapsible id={`operation-settings:${currentProjectId}:${currentConvId}`} triggerClassName="companion-operation-trigger"
|
||||||
|
trigger={<><Icon name="settings" size={13} /><span>操作设置</span></>} bodyClassName="companion-operation-body">
|
||||||
|
<label className="companion-setting"><span>操作权限</span><select aria-label="操作权限" value={conversation?.permissionMode || 'default'} onChange={e => {
|
||||||
|
const mode = e.target.value
|
||||||
|
if (mode === 'default' || mode === 'plan' || mode === 'auto_edit') void store.setPermissionMode(currentConvId, mode)
|
||||||
|
}}>
|
||||||
|
<option value="default">默认</option><option value="plan">计划</option><option value="auto_edit">自动改文件</option>
|
||||||
|
</select></label>
|
||||||
|
<p className="companion-setting-note">计划模式只进行读取与规划;自动改文件模式允许文件修改。重要操作仍按当前权限规则确认</p>
|
||||||
|
</Collapsible>
|
||||||
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
|
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
|
||||||
<div className="input-row">
|
<div className="input-row">
|
||||||
<label className="mode-select" title="本会话的权限模式">
|
<textarea ref={inputRef} rows={2} value={text} aria-label="工作目标或消息" placeholder={hasOpenConfirm(cards) ? '有一项操作等你确认,请先查看上方说明'
|
||||||
<span>权限</span>
|
: busy ? '继续补充,新消息会在当前回合结束后发送'
|
||||||
<select value={conversation?.permissionMode || 'default'} onChange={e => {
|
: '告诉助手你的目标,或添加需要一起阅读的材料'}
|
||||||
const mode = e.target.value
|
|
||||||
if (mode === 'default' || mode === 'plan' || mode === 'auto_edit') {
|
|
||||||
if (currentConvId) void store.setPermissionMode(currentConvId, mode)
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<option value="default">默认</option>
|
|
||||||
<option value="plan">计划</option>
|
|
||||||
<option value="auto_edit">自动改文件</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="icon-btn" title="上传附件(文档解析后注入上下文)">
|
|
||||||
<Icon name="paperclip" size={16} /><input type="file" multiple style={{ display: 'none' }} onChange={async e => {
|
|
||||||
for (const f of Array.from(e.target.files ?? [])) {
|
|
||||||
const id = await store.uploadAttachment(f)
|
|
||||||
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
|
|
||||||
}
|
|
||||||
e.target.value = ''
|
|
||||||
}} />
|
|
||||||
</label>
|
|
||||||
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
|
|
||||||
: busy ? '继续输入以排队'
|
|
||||||
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
|
||||||
onChange={e => setText(e.target.value)}
|
onChange={e => setText(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
|
||||||
|
<div className="companion-compose-actions">
|
||||||
|
<label className="companion-add-material" title="添加材料">
|
||||||
|
<Icon name="paperclip" size={16} /><span>添加材料</span><input type="file" multiple className="companion-file-input" aria-label="添加材料" onChange={e => {
|
||||||
|
const files = Array.from(e.currentTarget.files ?? [])
|
||||||
|
e.currentTarget.value = ''
|
||||||
|
void addMaterials(files)
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
<span className="companion-input-hint">Enter 发送 · Shift+Enter 换行</span>
|
||||||
|
<span className="companion-compose-spacer" />
|
||||||
{showStop && (
|
{showStop && (
|
||||||
<button className="btn danger" onClick={() => store.cancelActive()}>停止</button>
|
<button type="button" className="btn danger" onClick={() => store.cancelActive()}>停止</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>{busy ? '排队' : '发送'}</button>
|
<button type="button" className="btn" onClick={send} disabled={uploading > 0 || (!text.trim() && attachmentIds.length === 0)}>{busy ? '加入队列' : '发送'}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -697,7 +822,7 @@ function RewindControl({ convId, messageId }: { convId: string; messageId: strin
|
|||||||
try {
|
try {
|
||||||
await api.rewindFiles(convId, messageId)
|
await api.rewindFiles(convId, messageId)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
await store.openTask(convId)
|
if (store.getState().currentConvId === convId) await store.openTask(convId)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof Error ? e.message : String(e))
|
setErr(e instanceof Error ? e.message : String(e))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { ActivityCardView, Collapsible, activitySummary, groupActivityCards, pendingCount, type ActivityCard } from '@ui'
|
||||||
|
import { store, useStore } from '../store'
|
||||||
|
import { docBridge } from './Workbench'
|
||||||
|
import type { WorkCardAction } from '../contracts'
|
||||||
|
import { WorkExecutionRecord } from './WorkExecutionRecord'
|
||||||
|
|
||||||
|
interface CandidateChange { key: string; before?: string; after: string; impact?: string }
|
||||||
|
|
||||||
|
/** 候选差异与影响:只取服务端已给出的发现字段(corrects 为更正前,text 为候选,impact 为影响),不读取正文。 */
|
||||||
|
export function candidateChanges(card: ActivityCard): CandidateChange[] {
|
||||||
|
if (card.status !== 'action_required') return []
|
||||||
|
return (card.task?.findings ?? [])
|
||||||
|
.filter(f => f.status === 'active' && (f.corrects || f.impact))
|
||||||
|
.map(f => ({ key: `${f.id}:${f.revision}`, before: f.corrects, after: f.text, impact: f.impact }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIRM_IDS = new Set(['confirm', 'accept', 'approve'])
|
||||||
|
const REJECT_IDS = new Set(['reject', 'decline', 'deny'])
|
||||||
|
|
||||||
|
/** 确认/拒绝动作:沿用卡片自带 actions;非工作卡的动作行已由 ActivityCardView 渲染,这里只补工作卡。 */
|
||||||
|
export function candidateActions(card: ActivityCard) {
|
||||||
|
if (!card.work || card.status !== 'action_required') return []
|
||||||
|
return (card.actions ?? []).filter(a => CONFIRM_IDS.has(a.id) || REJECT_IDS.has(a.id))
|
||||||
|
.sort((a, b) => Number(REJECT_IDS.has(a.id)) - Number(REJECT_IDS.has(b.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function CandidateReview({ card, locked, onAction }: { card: ActivityCard; locked: boolean; onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => void }) {
|
||||||
|
const changes = candidateChanges(card)
|
||||||
|
const actions = candidateActions(card)
|
||||||
|
if (changes.length === 0 && actions.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="conversation-candidate" data-candidate-card={card.id}>
|
||||||
|
<div className="conversation-candidate-head">候选变更 · {card.title}</div>
|
||||||
|
{changes.map(c => (
|
||||||
|
<dl key={c.key} className="conversation-candidate-change">
|
||||||
|
{c.before && <div><dt>原内容</dt><dd>{c.before}</dd></div>}
|
||||||
|
<div><dt>{c.before ? '候选' : '候选内容'}</dt><dd>{c.after}</dd></div>
|
||||||
|
{c.impact && <div><dt>影响</dt><dd>{c.impact}</dd></div>}
|
||||||
|
</dl>
|
||||||
|
))}
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<div className="acard-actions">
|
||||||
|
{actions.map(a => (
|
||||||
|
<button key={a.id} type="button" className={`btn${REJECT_IDS.has(a.id) ? ' ghost' : ''}`} disabled={locked}
|
||||||
|
onClick={() => { if (!locked) onAction(card, a.id, { resultVersion: card.work!.resultVersion }) }}>{a.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
|
||||||
|
export function ConversationWork() {
|
||||||
|
const cards = useStore(s => s.cards)
|
||||||
|
const conversationId = useStore(s => s.currentConvId)
|
||||||
|
const projectId = useStore(s => s.currentProjectId)
|
||||||
|
const scratch = useStore(s => !!s.tasks.find(t => t.id === s.currentConvId)?.scratch)
|
||||||
|
const messages = useStore(s => s.messages)
|
||||||
|
const actionStates = useStore(s => s.cardActionStates)
|
||||||
|
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
|
||||||
|
const rightTab = useStore(s => s.ui.rightTab)
|
||||||
|
const record = useStore(s => s.workRecord)
|
||||||
|
const detailHere = rightCollapsed || rightTab !== 'work'
|
||||||
|
const [summaryOpen, setSummaryOpen] = useState(detailHere)
|
||||||
|
useEffect(() => setSummaryOpen(detailHere), [detailHere])
|
||||||
|
const { current, required, focused, history } = useMemo(() => {
|
||||||
|
const current = cards.filter(card => card.status !== 'dismissed'
|
||||||
|
&& card.conversationId === conversationId && card.projectId === projectId)
|
||||||
|
const required: ActivityCard[] = []
|
||||||
|
const remaining: ActivityCard[] = []
|
||||||
|
for (const card of current) {
|
||||||
|
// A confirmation already visible at its user message has one main action
|
||||||
|
// path in the conversation; assistant-anchored cards still need this path.
|
||||||
|
if (card.kind === 'confirm' && card.anchorMessageId
|
||||||
|
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
|
||||||
|
const summary = activitySummary(card)
|
||||||
|
const taskNotice = card.task?.limitations?.length || card.task?.phase.id === 'correction'
|
||||||
|
|| card.task?.findings?.some(f => f.status === 'active' && (f.kind === 'needs_user' || f.corrects))
|
||||||
|
if (taskNotice || card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
|
||||||
|
else remaining.push(card)
|
||||||
|
}
|
||||||
|
// Preserve the host's newest-first order for completed results and history.
|
||||||
|
const results = groupActivityCards(remaining).find(group => group.key === 'results')?.cards ?? []
|
||||||
|
const focused = remaining.find(card => card.pinned && card.status === 'active')
|
||||||
|
?? remaining.find(card => card.status === 'active')
|
||||||
|
?? results[0]
|
||||||
|
return { current, required, focused, history: remaining.filter(card => card !== focused).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) }
|
||||||
|
}, [cards, messages, conversationId, projectId])
|
||||||
|
if (!detailHere) return null
|
||||||
|
if (record) return <div className="conversation-work" data-conversation-work><WorkExecutionRecord /></div>
|
||||||
|
if (current.length === 0) return null
|
||||||
|
const pending = pendingCount(current)
|
||||||
|
const active = current.filter(card => card.status === 'active').length
|
||||||
|
const results = current.filter(card => card.status === 'done' || card.status === 'failed').length
|
||||||
|
const summary = [pending ? `${pending} 项待处理` : '', active ? `${active} 项工作` : '', results ? `${results} 项结果` : ''].filter(Boolean).join(' · ')
|
||||||
|
const callbacks = {
|
||||||
|
onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => { void store.cardAction(card, actionId, payload) },
|
||||||
|
onOpenRecord: (card: ActivityCard) => store.openWorkRecord(card),
|
||||||
|
onReference: (card: ActivityCard) => store.referenceWork(card),
|
||||||
|
actionState: (card: ActivityCard) => actionStates?.[card.id],
|
||||||
|
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
|
||||||
|
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
|
||||||
|
onLocate: (card: ActivityCard) => { store.locateMessage(card.anchorMessageId) },
|
||||||
|
...(scratch ? {} : { onArchive: (card: ActivityCard) => { void store.cardToProject(card) } }),
|
||||||
|
}
|
||||||
|
const view = (card: ActivityCard) => <ActivityCardView key={card.id} card={card} callbacks={callbacks} docBridge={docBridge} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="conversation-work" data-conversation-work aria-label="工作进展">
|
||||||
|
<div className="conversation-work-heading"><span>工作进展</span><span>{summary || '已处理的工作'}</span></div>
|
||||||
|
<div className="conversation-work-current">
|
||||||
|
{required.map(card => <div key={card.id} className="conversation-work-required">
|
||||||
|
<CandidateReview card={card} locked={actionStates?.[card.id] !== undefined} onAction={callbacks.onAction} />
|
||||||
|
{view(card)}
|
||||||
|
</div>)}
|
||||||
|
{focused && <Collapsible id={`conversation-current:${conversationId}:${focused.id}`} open={summaryOpen} onToggle={setSummaryOpen}
|
||||||
|
triggerClassName="conversation-work-more" trigger={<><span className="process-collapse-chev">▸</span>{focused.title} · 查看摘要</>}>
|
||||||
|
{view(focused)}
|
||||||
|
</Collapsible>}
|
||||||
|
</div>
|
||||||
|
{history.length > 0 && <Collapsible id={`conversation-work:${projectId}:${conversationId}`}
|
||||||
|
triggerClassName="conversation-work-more"
|
||||||
|
trigger={<><span className="process-collapse-chev">▸</span>更多工作与成果 · {history.length} 项</>}>
|
||||||
|
<div className="conversation-work-history">{history.map(view)}</div>
|
||||||
|
</Collapsible>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// 文档驾驶舱 I2(rollout I2):顶部统计带 + 左侧结构树 + 中央结构全景。
|
||||||
|
// 消费 I1 只读命令面 rdm.cockpit.*(Dev 仓 docs/superpowers/specs 2026-10-16
|
||||||
|
// view-spec §1–§4;四项取舍冻结见 rollout §7——首屏只显示章节概览与统计,
|
||||||
|
// 段落/正文摘要在选中与阅读器内按权限展示,本增量不含阅读器(I3))。
|
||||||
|
// 异步所有者:generation + contextKey(项目+筛选);不匹配的旧响应直接丢弃。
|
||||||
|
// 令牌驱动(DESIGN.md):颜色/字号只消费 @ui/tokens.css;折叠用 Collapsible。
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { store, useStore } from '../store'
|
||||||
|
import { EmptyState, SectionLabel } from '@ui'
|
||||||
|
import { classifyRdmError } from '../rdmErrors'
|
||||||
|
import {
|
||||||
|
contextKey, loadPrefs, nodeSummaryLine, sameSnapshot, savePrefs,
|
||||||
|
selectionSurvives, statDisplay, STAT_FILTERS, toggleFilter, treeBadges,
|
||||||
|
type CockpitMeta, type CockpitNodeRef, type CockpitOverview,
|
||||||
|
type CockpitTreeNode, type CockpitTreePage,
|
||||||
|
type Density, type PanelTabId, type ViewMode,
|
||||||
|
} from '../documentCockpit'
|
||||||
|
import { CockpitPanel, CockpitReader, VersionBlock } from './CockpitReader'
|
||||||
|
|
||||||
|
interface ProjectView { projectId: string; name: string; canAudit?: boolean }
|
||||||
|
|
||||||
|
function CockpitErrorState({ raw }: { raw: string }) {
|
||||||
|
const c = classifyRdmError(raw)
|
||||||
|
return <EmptyState icon="alert">{c.message}</EmptyState>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Selection { ref: CockpitNodeRef; parentDocId?: string }
|
||||||
|
|
||||||
|
export function DocumentCockpit() {
|
||||||
|
const identity = useStore(() => store.getIdentityEpoch())
|
||||||
|
const [projects, setProjects] = useState<ProjectView[] | null>(null)
|
||||||
|
const [pid, setPid] = useState('')
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
const [overview, setOverview] = useState<CockpitOverview | null>(null)
|
||||||
|
const [treePages, setTreePages] = useState<Record<string, { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }>>({})
|
||||||
|
const [selected, setSelected] = useState<Selection | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loadErr, setLoadErr] = useState('')
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('panorama')
|
||||||
|
const [density, setDensity] = useState<Density>('compact')
|
||||||
|
const [panelTab, setPanelTab] = useState<PanelTabId>('sources')
|
||||||
|
const gen = useRef(0)
|
||||||
|
|
||||||
|
const reload = useCallback((projectId: string, f: string) => {
|
||||||
|
if (!projectId) return
|
||||||
|
const g = ++gen.current
|
||||||
|
const ctx = contextKey(projectId, f)
|
||||||
|
setLoadErr('')
|
||||||
|
api.rdmCommand<CockpitOverview>('rdm.cockpit.overview', { projectId })
|
||||||
|
.then(ov => {
|
||||||
|
if (g !== gen.current || ctx !== contextKey(pid, filter)) return // 旧响应:项目/筛选已切换,丢弃
|
||||||
|
setOverview(ov)
|
||||||
|
setTreePages({}) // 树页随新快照整体重建
|
||||||
|
setSelected(sel => {
|
||||||
|
const kept = selectionSurvives(sel?.ref || null, (ov.documents || []).map(d => d.ref.id), sel?.parentDocId)
|
||||||
|
return kept ? { ref: kept, parentDocId: sel?.parentDocId } : null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(e => { if (g === gen.current && ctx === contextKey(pid, filter)) setLoadErr(e.message) })
|
||||||
|
}, [pid, filter])
|
||||||
|
|
||||||
|
// 项目清单(rdm.project.list:成员可见项目,canAudit 与否不影响文档读取入口)。
|
||||||
|
useEffect(() => {
|
||||||
|
let c = false
|
||||||
|
setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('')
|
||||||
|
api.rdmCommand<{ projects?: ProjectView[] } | ProjectView[]>('rdm.project.list')
|
||||||
|
.then(list => {
|
||||||
|
if (c) return
|
||||||
|
// rdm.project.list 返回 { projects: [...] };容忍裸数组形态。
|
||||||
|
const rows = Array.isArray(list) ? list : (list?.projects ?? [])
|
||||||
|
setProjects(rows)
|
||||||
|
// 设计 §6.2:进入驾驶舱默认选中第一个可见项目,驱动概览首屏。
|
||||||
|
if (rows.length > 0) setPid(prev => prev || rows[0].projectId)
|
||||||
|
})
|
||||||
|
.catch(e => { if (!c) setError(e.message) })
|
||||||
|
return () => { c = true }
|
||||||
|
}, [identity])
|
||||||
|
|
||||||
|
// 项目/筛选变化 → 重新取概览(统计与根文档来自同一快照)。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pid) return
|
||||||
|
reload(pid, filter)
|
||||||
|
}, [pid, filter, reload])
|
||||||
|
|
||||||
|
// 个人视图偏好(设计 §7.3):按 登录世代+项目 隔离,仅本地;恢复最近视图。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pid) return
|
||||||
|
const prefs = loadPrefs(`epoch-${identity}`, pid)
|
||||||
|
setViewMode(prefs.lastView)
|
||||||
|
setDensity(prefs.density)
|
||||||
|
setPanelTab(prefs.rightPanelTab)
|
||||||
|
}, [identity, pid])
|
||||||
|
const applyPrefs = useCallback((patch: { lastView?: ViewMode; density?: Density; rightPanelTab?: PanelTabId }) => {
|
||||||
|
if (!pid) return
|
||||||
|
setViewMode(v => patch.lastView ?? v)
|
||||||
|
setDensity(d => patch.density ?? d)
|
||||||
|
setPanelTab(t => patch.rightPanelTab ?? t)
|
||||||
|
const prefs = loadPrefs(`epoch-${identity}`, pid)
|
||||||
|
savePrefs(`epoch-${identity}`, pid, { ...prefs, ...patch })
|
||||||
|
}, [identity, pid])
|
||||||
|
|
||||||
|
// 展开文档:按需加载章节子页(键集分页;旧项目/旧筛选的响应丢弃;
|
||||||
|
// 加载失败只影响该分支——行内保留重试,不拖垮其它面板)。
|
||||||
|
const loadChildren = useCallback((docId: string, cursor?: string) => {
|
||||||
|
if (!pid) return
|
||||||
|
const g = gen.current
|
||||||
|
const ctx = contextKey(pid, filter)
|
||||||
|
api.rdmCommand<CockpitTreePage>('rdm.cockpit.tree', { projectId: pid, parent: docId, cursor, limit: 50, filter })
|
||||||
|
.then(page => {
|
||||||
|
if (g !== gen.current || ctx !== contextKey(pid, filter)) return
|
||||||
|
setTreePages(prev => {
|
||||||
|
const old = cursor ? prev[docId] : { items: [] as CockpitTreeNode[], hasMore: false }
|
||||||
|
const items = cursor ? [...(old?.items || []), ...page.items] : page.items
|
||||||
|
return { ...prev, [docId]: { items, nextCursor: page.nextCursor, hasMore: page.hasMore, meta: page.meta } }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => { /* 行内重试入口承担 */ })
|
||||||
|
}, [pid, filter])
|
||||||
|
|
||||||
|
if (error) return <CockpitErrorState raw={error} />
|
||||||
|
if (!projects) return <div className="empty-note">加载中…</div>
|
||||||
|
if (projects.length === 0) return <EmptyState icon="folder">暂无可见研发项目——项目成员身份由项目负责人授予</EmptyState>
|
||||||
|
|
||||||
|
const docIds = (overview?.documents || []).map(d => d.ref.id)
|
||||||
|
const splitNotice = !!overview && Object.values(treePages).some(p => !sameSnapshot(overview.meta, p.meta))
|
||||||
|
const selectDoc = (id: string) => setSelected({ ref: { kind: 'document', id } })
|
||||||
|
const selectSection = (docId: string, sec: CockpitTreeNode) => setSelected({ ref: sec.ref, parentDocId: docId })
|
||||||
|
return (
|
||||||
|
<div className="cockpit">
|
||||||
|
<div className="cockpit-header">
|
||||||
|
<select className="ui-input cockpit-project" value={pid} onChange={e => { setPid(e.target.value); setSelected(null); setFilter('') }} aria-label="选择项目">
|
||||||
|
{projects.map(p => <option key={p.projectId} value={p.projectId}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{filter && <button type="button" className="cockpit-filter-chip" onClick={() => setFilter('')}>
|
||||||
|
筛选:{STAT_FILTERS.find(s => s.filter === filter)?.label || filter} ✕
|
||||||
|
</button>}
|
||||||
|
<span className="cockpit-snap">{overview ? `快照 ${overview.meta.snapshotRevision}` : '加载中…'}</span>
|
||||||
|
<div className="cockpit-viewtoggle" role="group" aria-label="视图切换">
|
||||||
|
<button type="button" className={`cockpit-viewbtn${viewMode === 'panorama' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'panorama' })}>全景</button>
|
||||||
|
<button type="button" className={`cockpit-viewbtn${viewMode === 'reader' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'reader' })}>阅读器</button>
|
||||||
|
<button type="button" className="cockpit-viewbtn" onClick={() => applyPrefs({ density: density === 'compact' ? 'full' : 'compact' })} title="切换密度">{density === 'compact' ? '紧凑' : '宽松'}</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="cockpit-refresh" onClick={() => reload(pid, filter)} disabled={!pid}>刷新</button>
|
||||||
|
</div>
|
||||||
|
{splitNotice && (
|
||||||
|
<div className="cockpit-banner" role="status">统计与树快照不一致——请刷新以对齐最新快照</div>
|
||||||
|
)}
|
||||||
|
{loadErr && <div className="cockpit-banner cockpit-banner-err" role="alert">概览读取失败:{loadErr}(保留上一快照,可重试)</div>}
|
||||||
|
{overview && (
|
||||||
|
<div className="cockpit-stats" role="group" aria-label="项目统计">
|
||||||
|
<div className="cockpit-stat">
|
||||||
|
<span className="cockpit-stat-num">{statDisplay(overview.stats, 'completion').value}</span>
|
||||||
|
<span className="cockpit-stat-label">完成度{statDisplay(overview.stats, 'completion').hint ? `(${statDisplay(overview.stats, 'completion').hint})` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="cockpit-stat"><span className="cockpit-stat-num">{statDisplay(overview.stats, 'documents').value}</span><span className="cockpit-stat-label">文档</span></div>
|
||||||
|
{STAT_FILTERS.map(s => {
|
||||||
|
const d = statDisplay(overview.stats, s.key)
|
||||||
|
const active = filter === s.filter
|
||||||
|
return (
|
||||||
|
<button key={s.key} type="button" className={`cockpit-stat${active ? ' cockpit-stat-active' : ''}`}
|
||||||
|
onClick={() => setFilter(f => toggleFilter(f, s.filter))}
|
||||||
|
title={d.hint ? `${s.label}:${d.hint}` : `筛选:${s.label}`}>
|
||||||
|
<span className="cockpit-stat-num">{d.value}</span>
|
||||||
|
<span className="cockpit-stat-label">{s.label}{d.hint ? `(${d.hint})` : ''}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="cockpit-stat cockpit-stat-unavail"><span className="cockpit-stat-num">—</span><span className="cockpit-stat-label">待确认(未纳入投影)</span></div>
|
||||||
|
<div className="cockpit-stat"><span className="cockpit-stat-num">{overview.stats.updatedAt ? new Date(overview.stats.updatedAt).toLocaleString('zh-CN') : '—'}</span><span className="cockpit-stat-label">最近更新</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`cockpit-columns${selected ? ' cockpit-columns-3' : ''}`}>
|
||||||
|
<div className="cockpit-tree" role="tree" aria-label="文档结构树">
|
||||||
|
<SectionLabel>结构树</SectionLabel>
|
||||||
|
{!overview && <div className="empty-note">加载结构…</div>}
|
||||||
|
{overview && docIds.length === 0 && <div className="empty-note">暂无受控文档</div>}
|
||||||
|
{overview && overview.meta.truncated && (
|
||||||
|
<div className="cockpit-truncated">文档清单已截断({overview.meta.truncationReasons?.join(';') || '超出上限'})</div>
|
||||||
|
)}
|
||||||
|
{overview && overview.documents.map(d => (
|
||||||
|
<TreeRow key={d.ref.id} node={d} depth={0}
|
||||||
|
expanded={treePages[d.ref.id] !== undefined}
|
||||||
|
childPage={treePages[d.ref.id]}
|
||||||
|
selectedId={selected?.ref.id}
|
||||||
|
onSelectNode={n => (n.ref.kind === 'document' ? selectDoc(n.ref.id) : selectSection(d.ref.id, n))}
|
||||||
|
onExpand={() => { if (treePages[d.ref.id] === undefined) loadChildren(d.ref.id) }}
|
||||||
|
onLoadMore={() => { const p = treePages[d.ref.id]; if (p?.nextCursor) loadChildren(d.ref.id, p.nextCursor) }}
|
||||||
|
onRetryChild={() => { const p = treePages[d.ref.id]; loadChildren(d.ref.id, p?.nextCursor) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="cockpit-center">
|
||||||
|
{!overview && <div className="empty-note">加载概览…</div>}
|
||||||
|
{overview && !selected && (
|
||||||
|
<PanoramaHome overview={overview} onSelect={selectDoc} />
|
||||||
|
)}
|
||||||
|
{overview && selected && viewMode === 'panorama' && (
|
||||||
|
<PanoramaNode pid={pid} selection={selected} overview={overview}
|
||||||
|
snapRev={overview.meta.snapshotRevision}
|
||||||
|
childPage={selected.ref.kind === 'document' ? treePages[selected.ref.id] : undefined}
|
||||||
|
density={density}
|
||||||
|
onClear={() => setSelected(null)}
|
||||||
|
onOpenReader={() => applyPrefs({ lastView: 'reader' })} />
|
||||||
|
)}
|
||||||
|
{overview && selected && viewMode === 'reader' && (
|
||||||
|
<CockpitReader pid={pid} selection={selected}
|
||||||
|
sections={selected.ref.kind === 'document' ? (treePages[selected.ref.id]?.items || []) : []}
|
||||||
|
density={density} onBack={() => applyPrefs({ lastView: 'panorama' })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{overview && selected && (
|
||||||
|
<div className="cockpit-right">
|
||||||
|
<CockpitPanel pid={pid} selection={selected} tab={panelTab} onTab={t => applyPrefs({ rightPanelTab: t })} density={density} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeRow({ node, depth, expanded, childPage, selectedId, onSelectNode, onExpand, onLoadMore, onRetryChild }: {
|
||||||
|
node: CockpitTreeNode; depth: number
|
||||||
|
expanded: boolean
|
||||||
|
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
|
||||||
|
selectedId?: string
|
||||||
|
onSelectNode: (n: CockpitTreeNode) => void
|
||||||
|
onExpand: () => void
|
||||||
|
onLoadMore: () => void
|
||||||
|
onRetryChild: () => void
|
||||||
|
}) {
|
||||||
|
const badges = treeBadges(node)
|
||||||
|
const selected = selectedId === node.ref.id
|
||||||
|
return (
|
||||||
|
<div className="cockpit-tree-branch">
|
||||||
|
<div className={`cockpit-tree-row${selected ? ' cockpit-tree-row-active' : ''}`} style={{ paddingLeft: depth * 14 }} role="treeitem" aria-selected={selected}>
|
||||||
|
{node.ref.kind === 'document' && (
|
||||||
|
<button type="button" className="cockpit-tree-caret" aria-label={expanded ? '折叠' : '展开'}
|
||||||
|
aria-expanded={expanded} onClick={e => { e.stopPropagation(); if (!expanded) onExpand() }}>
|
||||||
|
{expanded ? '▾' : '▸'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="cockpit-tree-title" onClick={() => onSelectNode(node)}>{node.title}</button>
|
||||||
|
{badges.map(b => <span key={b} className="cockpit-badge">{b}</span>)}
|
||||||
|
{node.lastChangedAt && <span className="cockpit-tree-time">{new Date(node.lastChangedAt).toLocaleDateString('zh-CN')}</span>}
|
||||||
|
</div>
|
||||||
|
{expanded && !childPage && (
|
||||||
|
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onRetryChild}>章节加载失败,重试</button>
|
||||||
|
)}
|
||||||
|
{expanded && childPage && (
|
||||||
|
<div>
|
||||||
|
{childPage.items.length === 0 && <div className="cockpit-tree-empty" style={{ paddingLeft: (depth + 1) * 14 }}>无章节</div>}
|
||||||
|
{childPage.items.map(sec => (
|
||||||
|
<TreeRow key={sec.ref.id} node={sec} depth={depth + 1} expanded={false} childPage={undefined}
|
||||||
|
selectedId={selectedId} onSelectNode={onSelectNode}
|
||||||
|
onExpand={onExpand} onLoadMore={onLoadMore} onRetryChild={onRetryChild} />
|
||||||
|
))}
|
||||||
|
{childPage.hasMore && childPage.items.length > 0 && (
|
||||||
|
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onLoadMore}>加载更多章节</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSelect: (id: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>{overview.project.title} · 文档概览</SectionLabel>
|
||||||
|
{overview.documents.length === 0 && <EmptyState icon="folder">暂无受控文档——文档由模板实例化或导入后进入驾驶舱</EmptyState>}
|
||||||
|
<div className="cockpit-doclist">
|
||||||
|
{overview.documents.map(d => (
|
||||||
|
<button key={d.ref.id} type="button" className="cockpit-docrow" onClick={() => onSelect(d.ref.id)}>
|
||||||
|
<span className="cockpit-docrow-title">{d.title}</span>
|
||||||
|
<span className="cockpit-docrow-meta">{nodeSummaryLine(d)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<SectionLabel>最近变化</SectionLabel>
|
||||||
|
{overview.recentChanges.length === 0 && <div className="empty-note">暂无可见变化</div>}
|
||||||
|
<div className="cockpit-changes">
|
||||||
|
{overview.recentChanges.map((c, i) => (
|
||||||
|
<div key={`${c.node.id}-${i}`} className="cockpit-changerow">
|
||||||
|
<span className="cockpit-changesum">{c.summary} · {c.title}{c.actor ? ` · ${c.actor}` : ''}</span>
|
||||||
|
<span className="cockpit-changetime">{new Date(c.changedAt).toLocaleString('zh-CN')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanoramaNode({ pid, selection, overview, childPage, density, snapRev, onClear, onOpenReader }: {
|
||||||
|
pid: string; selection: Selection; overview: CockpitOverview
|
||||||
|
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
|
||||||
|
density: Density
|
||||||
|
snapRev: string
|
||||||
|
onClear: () => void
|
||||||
|
onOpenReader: () => void
|
||||||
|
}) {
|
||||||
|
const [detail, setDetail] = useState<any>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const gen = useRef(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const g = ++gen.current
|
||||||
|
setDetail(null); setErr('')
|
||||||
|
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id })
|
||||||
|
.then(d => {
|
||||||
|
if (g !== gen.current) return
|
||||||
|
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
|
||||||
|
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
|
||||||
|
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
|
||||||
|
}
|
||||||
|
setDetail(d)
|
||||||
|
})
|
||||||
|
.catch(e => { if (g === gen.current) setErr(e.message) })
|
||||||
|
return () => { gen.current++ }
|
||||||
|
}, [pid, selection.ref.kind, selection.ref.id, snapRev])
|
||||||
|
const node = (detail?.node || null) as CockpitTreeNode | null
|
||||||
|
const title = node?.title || selection.ref.id
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="cockpit-nodehead">
|
||||||
|
<button type="button" className="cockpit-back" onClick={onClear}>← 返回概览</button>
|
||||||
|
<span className="cockpit-nodetitle">{title}</span>
|
||||||
|
{node && <span className="cockpit-nodesum">{nodeSummaryLine(node)}</span>}
|
||||||
|
<button type="button" className="cockpit-filter-chip" onClick={onOpenReader}>在阅读器中打开</button>
|
||||||
|
</div>
|
||||||
|
{err && <CockpitErrorState raw={err} />}
|
||||||
|
{detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && (
|
||||||
|
<div className="empty-note">内容受限({detail.body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
||||||
|
)}
|
||||||
|
{selection.ref.kind === 'section' && detail?.versions && detail.versions.length > 0 && (
|
||||||
|
<VersionBlock versions={detail.versions} density={density} />
|
||||||
|
)}
|
||||||
|
{detail && detail.meta && !sameSnapshot(detail.meta, overview.meta) && (
|
||||||
|
<div className="cockpit-truncated">节点详情与统计快照不同({detail.meta.snapshotRevision} vs {overview.meta.snapshotRevision})——刷新对齐</div>
|
||||||
|
)}
|
||||||
|
{node && node.ref.kind === 'document' && (
|
||||||
|
<>
|
||||||
|
<SectionLabel>章节状态</SectionLabel>
|
||||||
|
{!childPage && <div className="empty-note">章节按需加载——在左侧树展开</div>}
|
||||||
|
{childPage && childPage.items.length === 0 && <div className="empty-note">无章节</div>}
|
||||||
|
{childPage && childPage.items.length > 0 && (
|
||||||
|
<table className="cockpit-childtable">
|
||||||
|
<thead><tr><th>章节</th><th>内容</th><th>核实</th><th>来源</th><th>冲突</th><th>最近更新</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{childPage.items.map(sec => (
|
||||||
|
<tr key={sec.ref.id}>
|
||||||
|
<td>{sec.title}</td>
|
||||||
|
<td>{{ complete: '完整', partial: '部分', empty: '空', inaccessible: '受限', unavailable: '不可读' }[sec.state.content] || sec.state.content}</td>
|
||||||
|
<td>{sec.state.verification === 'not_applicable' ? '—' : sec.state.verification}</td>
|
||||||
|
<td>{sec.state.sourceCount}</td>
|
||||||
|
<td>{sec.state.conflictCount}</td>
|
||||||
|
<td>{sec.lastChangedAt ? new Date(sec.lastChangedAt).toLocaleDateString('zh-CN') : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{node && node.ref.kind === 'section' && (
|
||||||
|
<div className="empty-note">点「在阅读器中打开」查看连续正文与版本差异;右侧为该节点的依据与影响面板</div>
|
||||||
|
)}
|
||||||
|
{detail?.meta?.truncated && (
|
||||||
|
<div className="cockpit-truncated">详情已截断:{detail.meta.truncationReasons?.join(';')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api, type ExecEnvView } from '../api'
|
||||||
|
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup } from '@ui'
|
||||||
|
|
||||||
|
export function ExecEnvPanel() {
|
||||||
|
const [view, setView] = useState<ExecEnvView | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [conv, setConv] = useState('')
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
const [wheel, setWheel] = useState<File | null>(null)
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
try { setView(await api.adminExecEnv()) } catch (e) { setErr(String(e)) }
|
||||||
|
}
|
||||||
|
useEffect(() => { refresh() }, [])
|
||||||
|
|
||||||
|
const run = async (work: () => Promise<string>) => {
|
||||||
|
setErr(''); setNotice(''); setBusy(true)
|
||||||
|
try { setNotice(await work()); await refresh() } catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!view) return <EmptyState icon="package">加载 Python 环境…</EmptyState>
|
||||||
|
if (!view.configured) return <EmptyState icon="package">这台服务没有配置分层 Python 环境根</EmptyState>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsGroup title="当前环境">
|
||||||
|
<SettingRow label="环境根" desc={view.root || '未设置'}><span /></SettingRow>
|
||||||
|
<SettingRow label="解释器" desc={view.python || '尚未安装托管解释器'}><span /></SettingRow>
|
||||||
|
<SettingRow label="基础层" desc={view.base || '尚未建立'}><span /></SettingRow>
|
||||||
|
<SettingRow label="层目录" desc={(view.layers ?? []).join('、') || '无'}>
|
||||||
|
<Btn variant="ghost" icon="undo" disabled={busy} onClick={() => run(async () => {
|
||||||
|
const out = await api.adminExecEnvRebase()
|
||||||
|
return `基础层已切换到 ${out.base}。已有会话仍用原来的绑定。`
|
||||||
|
})}>重建基础层</Btn>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsGroup>
|
||||||
|
<SettingsGroup title="申请">
|
||||||
|
{(view.requests ?? []).length === 0 && <EmptyState icon="inbox">还没有扩展包申请</EmptyState>}
|
||||||
|
{(view.requests ?? []).map(req => (
|
||||||
|
<SettingRow key={req.id} label={req.spec || req.id} desc={`${req.status} · ${req.conversationId} · ${req.detail}`}>
|
||||||
|
{req.status === 'pending_approval' && (
|
||||||
|
<span className="ad-row">
|
||||||
|
<Btn variant="primary" icon="check" disabled={busy} onClick={() => run(async () => {
|
||||||
|
const out = await api.adminExecEnvDecision(req.id, true, false)
|
||||||
|
return out.detail || out.status
|
||||||
|
})}>批准</Btn>
|
||||||
|
<Btn variant="ghost" disabled={busy} onClick={() => run(async () => {
|
||||||
|
const out = await api.adminExecEnvDecision(req.id, true, true)
|
||||||
|
return out.detail || '已用隔离层'
|
||||||
|
})}>隔离层</Btn>
|
||||||
|
<Btn variant="ghost" icon="x" disabled={busy} onClick={() => run(async () => {
|
||||||
|
const out = await api.adminExecEnvDecision(req.id, false, false)
|
||||||
|
return out.detail || '已拒绝'
|
||||||
|
})}>拒绝</Btn>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</SettingRow>
|
||||||
|
))}
|
||||||
|
</SettingsGroup>
|
||||||
|
<SettingsGroup title="会话绑定">
|
||||||
|
{(view.bindings ?? []).map(b => (
|
||||||
|
<SettingRow key={b.conversationId} label={b.conversationId} desc={b.isolated ? `隔离 ${b.isolated}` : (b.extensions ?? []).join('、') || '只用基础层'}>
|
||||||
|
<span />
|
||||||
|
</SettingRow>
|
||||||
|
))}
|
||||||
|
<SettingRow label="回收" desc="删掉 7 天内没有会话引用、也不是当前基础层的目录">
|
||||||
|
<Btn variant="ghost" icon="trash" disabled={busy} onClick={() => run(async () => {
|
||||||
|
const out = await api.adminExecEnvGC()
|
||||||
|
const n = out.removed?.length ?? 0
|
||||||
|
return n === 0 ? '没有可回收的层' : `已回收 ${n} 层`
|
||||||
|
})}>回收未引用层</Btn>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsGroup>
|
||||||
|
<SettingsGroup title="上传 wheel">
|
||||||
|
<SettingRow label="会话" desc="这个 wheel 只挂到指定会话,并进入审批">
|
||||||
|
<input className="lg-input" value={conv} onChange={e => setConv(e.target.value)} placeholder="会话 ID" />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="理由" desc="写给审批记录">
|
||||||
|
<input className="lg-input" value={reason} onChange={e => setReason(e.target.value)} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="文件" desc="只接受预编译 wheel,不执行里面的脚本">
|
||||||
|
<input type="file" accept=".whl" onChange={e => setWheel(e.target.files?.[0] ?? null)} />
|
||||||
|
</SettingRow>
|
||||||
|
<div className="ad-row">
|
||||||
|
<Btn variant="primary" icon="download" disabled={busy || !wheel || !conv.trim()} onClick={() => run(async () => {
|
||||||
|
const out = await api.adminExecEnvWheel(wheel as File, conv.trim(), reason.trim())
|
||||||
|
return `${out.file}:${out.outcome.status}`
|
||||||
|
})}>提交审批</Btn>
|
||||||
|
</div>
|
||||||
|
</SettingsGroup>
|
||||||
|
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||||
|
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
// 登录与改密(管理员发放制,R2):
|
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||||
// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口;
|
// LoginPage —— 用户名+密码唯一入口;
|
||||||
// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。
|
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
|
||||||
import { useState } from 'react'
|
// grant_setup 与 one_time_password 免当前密码,current_required 必须校验当前密码
|
||||||
import { auth } from '../auth'
|
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
||||||
import { api } from '../api'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api, type PasswordChangeMode } from '../api'
|
||||||
import { Icon, BrandMark } from '@ui'
|
import { Icon, BrandMark } from '@ui'
|
||||||
|
import { ForcedPasswordModeSection, isForcedPasswordMode, requiresCurrentPassword, submitForcedPassword } from './passwordChangeMode'
|
||||||
|
import { humanChangePasswordError, humanLoginError } from './loginErrors'
|
||||||
|
|
||||||
function Err({ msg }: { msg: string }) {
|
function Err({ msg }: { msg: string }) {
|
||||||
if (!msg) return null
|
if (!msg) return null
|
||||||
@@ -28,12 +31,12 @@ function BrandPanel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */
|
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。
|
||||||
|
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
|
||||||
|
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
|
||||||
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
|
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
|
||||||
const [tab, setTab] = useState<'password' | 'apikey'>('password')
|
|
||||||
const [owner, setOwner] = useState('')
|
const [owner, setOwner] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [key, setKey] = useState('')
|
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
@@ -41,10 +44,9 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
|||||||
setErr(''); setBusy(true)
|
setErr(''); setBusy(true)
|
||||||
try {
|
try {
|
||||||
const me = await api.login(owner.trim(), password)
|
const me = await api.login(owner.trim(), password)
|
||||||
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
|
// 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明;
|
||||||
auth.beginSession()
|
// 受限会话先进设密门,完成前不放行。
|
||||||
if (me.mustChangePassword) {
|
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
||||||
// 初始/重置密码:先完成强制改密再放行。
|
|
||||||
onMustChange(me.owner)
|
onMustChange(me.owner)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -54,81 +56,61 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
|||||||
} finally { setBusy(false) }
|
} finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitKey = async () => {
|
|
||||||
setErr(''); setBusy(true)
|
|
||||||
auth.setKey(key)
|
|
||||||
try {
|
|
||||||
const r = await auth.fetch('/api/health')
|
|
||||||
if (r.ok) {
|
|
||||||
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
|
|
||||||
// 不放行进死路改密门,引导改走账号密码登录。
|
|
||||||
const me = await api.me().catch(() => null)
|
|
||||||
if (me?.mustChangePassword) {
|
|
||||||
auth.clear()
|
|
||||||
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onAuthed(); return
|
|
||||||
}
|
|
||||||
throw new Error(String(r.status))
|
|
||||||
} catch {
|
|
||||||
auth.clear()
|
|
||||||
setErr('密钥无效或已撤销,请重试')
|
|
||||||
} finally { setBusy(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lg-wrap">
|
<div className="lg-wrap">
|
||||||
<BrandPanel />
|
<BrandPanel />
|
||||||
<div className="lg-formside">
|
<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>
|
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||||
<h2 className="lg-title">登录</h2>
|
<h2 className="lg-title">登录</h2>
|
||||||
<p className="lg-sub">账号由管理员发放;首次登录需修改初始密码</p>
|
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员重置临时密码</p>
|
||||||
<div className="lg-tabs" role="tablist">
|
|
||||||
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
|
|
||||||
<button type="button" role="tab" aria-selected={tab === 'apikey'} className={`lg-tab ${tab === 'apikey' ? 'on' : ''}`} onClick={() => setTab('apikey')}>API 密钥</button>
|
|
||||||
</div>
|
|
||||||
{tab === 'password' ? (
|
|
||||||
<>
|
|
||||||
<label className="lg-label">用户名</label>
|
<label className="lg-label">用户名</label>
|
||||||
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
||||||
<label className="lg-label">密码</label>
|
<label className="lg-label">密码</label>
|
||||||
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
||||||
<Err msg={err} />
|
<Err msg={err} />
|
||||||
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<label className="lg-label">API 访问密钥</label>
|
|
||||||
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="ask_…" />
|
|
||||||
<Err msg={err} />
|
|
||||||
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '验证中…' : '进入'}</button>
|
|
||||||
<p className="lg-hint">密钥由服务端创建:<code>agent-system apikey create --owner <name></code>。程序化客户端也可直接带 Authorization 头。</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */
|
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
|
||||||
|
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
|
||||||
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
||||||
|
const [mode, setMode] = useState<PasswordChangeMode | null>(null)
|
||||||
const [current, setCurrent] = useState('')
|
const [current, setCurrent] = useState('')
|
||||||
const [next, setNext] = useState('')
|
const [next, setNext] = useState('')
|
||||||
const [confirm, setConfirm] = useState('')
|
const [confirm, setConfirm] = useState('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api.me().then(me => {
|
||||||
|
if (!active) return
|
||||||
|
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
|
||||||
|
!isForcedPasswordMode(me.passwordChangeMode)) {
|
||||||
|
setErr('当前设密凭据不再有效,请重新登录。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMode(me.passwordChangeMode)
|
||||||
|
}).catch(() => { if (active) setErr('无法确认当前设密方式,请重新登录。') })
|
||||||
|
return () => { active = false }
|
||||||
|
}, [owner])
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setErr('')
|
setErr('')
|
||||||
|
if (!mode) { setErr('设密方式尚未由服务端确认'); return }
|
||||||
|
if (requiresCurrentPassword(mode) && !current) { setErr('请填写当前密码'); return }
|
||||||
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
|
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
|
||||||
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await api.changePassword(current, next)
|
await submitForcedPassword(mode, current, next)
|
||||||
onDone()
|
setDone(true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(humanChangePasswordError(String(e)))
|
setErr(humanChangePasswordError(String(e)))
|
||||||
} finally { setBusy(false) }
|
} finally { setBusy(false) }
|
||||||
@@ -138,42 +120,29 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
|||||||
<div className="lg-wrap">
|
<div className="lg-wrap">
|
||||||
<BrandPanel />
|
<BrandPanel />
|
||||||
<div className="lg-formside">
|
<div className="lg-formside">
|
||||||
|
{done ? (
|
||||||
|
<form className="lg-card" onSubmit={e => { e.preventDefault(); onDone() }}>
|
||||||
|
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||||
|
<h2 className="lg-title">密码已更新</h2>
|
||||||
|
<p className="lg-sub">请使用新密码重新登录账号 <b>{owner}</b></p>
|
||||||
|
<button className="lg-submit" type="submit">返回登录</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
|
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||||
<h2 className="lg-title">修改密码</h2>
|
<h2 className="lg-title">设置新密码</h2>
|
||||||
<p className="lg-sub">账号 <b>{owner}</b> 使用初始/重置密码首次登录,须设置新密码后继续</p>
|
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
|
||||||
<label className="lg-label">当前密码(初始/重置密码)</label>
|
<ForcedPasswordModeSection mode={mode} current={current} onCurrentChange={setCurrent} />
|
||||||
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus autoComplete="current-password" />
|
|
||||||
<label className="lg-label">新密码(≥8 位)</label>
|
<label className="lg-label">新密码(≥8 位)</label>
|
||||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoComplete="new-password" />
|
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
|
||||||
<label className="lg-label">确认新密码</label>
|
<label className="lg-label">确认新密码</label>
|
||||||
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
|
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
|
||||||
<Err msg={err} />
|
<Err msg={err} />
|
||||||
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '设置新密码并进入'}</button>
|
<button className="lg-submit" type="submit" disabled={busy || !mode || (requiresCurrentPassword(mode) && !current) || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
|
||||||
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>返回登录</button>}
|
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
|
||||||
export function humanLoginError(raw: string): string {
|
|
||||||
if (raw.includes('401')) return '用户名或密码不正确'
|
|
||||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
|
||||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
|
||||||
return raw.replace(/^\d{3}:\s*/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
|
|
||||||
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
|
|
||||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
|
||||||
export function humanChangePasswordError(raw: string): string {
|
|
||||||
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
|
||||||
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
|
||||||
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
|
||||||
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
|
||||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
|
||||||
if (raw.includes('401')) return '认证失败,请重新登录'
|
|
||||||
return raw.replace(/^\d{3}:\s*/, '')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** Stable server time, with the full local date available to keyboard and assistive readers. */
|
||||||
|
export function MessageTimestamp({ value }: { value?: string }) {
|
||||||
|
const date = value ? new Date(value) : null
|
||||||
|
if (!date || !Number.isFinite(date.getTime())) return null
|
||||||
|
const full = date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
||||||
|
const label = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
return <time className="message-timestamp" dateTime={date.toISOString()} title={full} aria-label={full}>{label}</time>
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
|
||||||
|
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, type AccountProfile, type AccountRecord } from '../api'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
import { Collapsible, Icon, SettingsGroup } from '@ui'
|
||||||
|
|
||||||
|
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||||
|
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
||||||
|
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function MyAccountSettings() {
|
||||||
|
const me = useStore(s => s.me)
|
||||||
|
const [account, setAccount] = useState<AccountRecord | null>(null)
|
||||||
|
const [draft, setDraft] = useState<AccountProfile | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const sequence = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!me?.auth) return
|
||||||
|
const seq = ++sequence.current
|
||||||
|
api.meProfile().then(profile => {
|
||||||
|
if (seq !== sequence.current) return
|
||||||
|
setAccount(profile); setDraft(profile.profile)
|
||||||
|
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
|
||||||
|
return () => { sequence.current++ }
|
||||||
|
}, [me?.owner, me?.auth])
|
||||||
|
|
||||||
|
if (!me?.auth) return null
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!account || !draft) return
|
||||||
|
const patch: Partial<AccountProfile> = {}
|
||||||
|
for (const field of fields) if (draft[field.key] !== account.profile[field.key]) patch[field.key] = draft[field.key]
|
||||||
|
if (Object.keys(patch).length === 0) { setNotice('资料没有变化'); return }
|
||||||
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
try {
|
||||||
|
const updated = await api.patchMeProfile(account.profileVersion, patch)
|
||||||
|
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存')
|
||||||
|
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SettingsGroup title="我的账号">
|
||||||
|
{account && draft ? <>
|
||||||
|
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空。</p>
|
||||||
|
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
|
||||||
|
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
||||||
|
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
|
||||||
|
<input id={`my-profile-${field.key}`} className="lg-input" value={draft[field.key]}
|
||||||
|
onChange={event => setDraft({ ...draft, [field.key]: event.target.value })} />
|
||||||
|
</div>)}
|
||||||
|
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
|
||||||
|
</Collapsible>
|
||||||
|
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
|
||||||
|
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密),原密码随生成即失效。</p>
|
||||||
|
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||||
|
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||||
|
</SettingsGroup>
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { Artifact } from '../contracts'
|
|||||||
import { humanSize } from '../messageparts'
|
import { humanSize } from '../messageparts'
|
||||||
import { previewTabsKey } from '../previewtabs'
|
import { previewTabsKey } from '../previewtabs'
|
||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
|
import { surfaceKey } from '../surfaceKey'
|
||||||
|
|
||||||
export function PreviewPane() {
|
export function PreviewPane() {
|
||||||
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
|
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
|
||||||
@@ -17,9 +18,13 @@ export function PreviewPane() {
|
|||||||
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
|
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
|
||||||
const surfaces = useStore(s => s.surfaces)
|
const surfaces = useStore(s => s.surfaces)
|
||||||
const surfaceSnap = active?.id.startsWith('surface:')
|
const surfaceSnap = active?.id.startsWith('surface:')
|
||||||
? surfaces.find(x => 'surface:' + String(x.identity.surfaceId) === active.id)
|
? surfaces.find(x => {
|
||||||
|
const id = surfaceKey(x)
|
||||||
|
return id != null && 'surface:' + id === active.id
|
||||||
|
})
|
||||||
: undefined
|
: undefined
|
||||||
const art = active ? artifacts.find(a => a.id === active.id) : undefined
|
const art = active ? artifacts.find(a => a.id === active.id) : undefined
|
||||||
|
const contentUrl=art?api.artifactContentUrl(art.id)+(active?.sourceContext?.expectedHash?`?expectedHash=${encodeURIComponent(active.sourceContext.expectedHash)}`:''):''
|
||||||
const textKind = !!art && isTextKind(art)
|
const textKind = !!art && isTextKind(art)
|
||||||
const [content, setContent] = useState<string | null>(null)
|
const [content, setContent] = useState<string | null>(null)
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
@@ -31,12 +36,12 @@ export function PreviewPane() {
|
|||||||
setContent(null)
|
setContent(null)
|
||||||
if (!art || !isTextKind(art)) return
|
if (!art || !isTextKind(art)) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
fetch(api.artifactContentUrl(art.id))
|
fetch(contentUrl)
|
||||||
.then(r => r.text())
|
.then(r => {if(!r.ok)throw new Error('source unavailable');return r.text()})
|
||||||
.then(text => { if (!cancelled) setContent(text) })
|
.then(text => { if (!cancelled) setContent(text) })
|
||||||
.catch(() => { if (!cancelled) setContent('(读取失败)') })
|
.catch(() => { if (!cancelled) setContent('(读取失败)') })
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [art?.id])
|
}, [art?.id,contentUrl])
|
||||||
|
|
||||||
if (!active) return null
|
if (!active) return null
|
||||||
if (active.id.startsWith('surface:')) {
|
if (active.id.startsWith('surface:')) {
|
||||||
@@ -56,7 +61,7 @@ export function PreviewPane() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="preview-pane-body">
|
<div className="preview-pane-body">
|
||||||
{surfaceSnap
|
{surfaceSnap
|
||||||
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), id, {})} docBridge={{ onOpenDoc: docId => {
|
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(surfaceKey(surfaceSnap) ?? '', id, {})} docBridge={{ onOpenDoc: docId => {
|
||||||
const art = artifacts.find(a => a.id === docId)
|
const art = artifacts.find(a => a.id === docId)
|
||||||
if (art) store.requestPreview(art)
|
if (art) store.requestPreview(art)
|
||||||
} }} />
|
} }} />
|
||||||
@@ -92,6 +97,7 @@ export function PreviewPane() {
|
|||||||
await api.saveArtifactContent(art.id, content ?? '')
|
await api.saveArtifactContent(art.id, content ?? '')
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
store.toast('已保存')
|
store.toast('已保存')
|
||||||
|
const conv=store.getState().currentConvId;if(conv)void store.refreshCards(conv)
|
||||||
}}>保存</Btn>}
|
}}>保存</Btn>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -104,6 +110,7 @@ export function PreviewPane() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{active.sourceContext&&<div className="acard-status-note" role="note"><p>{active.sourceContext.notice}</p>{active.sourceContext.quote&&<blockquote>{active.sourceContext.quote}</blockquote>}</div>}
|
||||||
<div className="preview-pane-body">
|
<div className="preview-pane-body">
|
||||||
{editing && textKind ? (
|
{editing && textKind ? (
|
||||||
<textarea className="search-input preview-pane-editor" value={content ?? ''}
|
<textarea className="search-input preview-pane-editor" value={content ?? ''}
|
||||||
@@ -114,7 +121,7 @@ export function PreviewPane() {
|
|||||||
id: art.id,
|
id: art.id,
|
||||||
name: art.name,
|
name: art.name,
|
||||||
mime: art.mime,
|
mime: art.mime,
|
||||||
contentUrl: api.artifactContentUrl(art.id),
|
contentUrl,
|
||||||
downloadUrl: api.artifactDownloadUrl(art.id),
|
downloadUrl: api.artifactDownloadUrl(art.id),
|
||||||
}} />
|
}} />
|
||||||
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
|
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
// 工作时间线:思考、对用户的解说、同类工具合并成一行。运行中展开;
|
// 执行记录按工作动作分组;展开状态由调用方控制。
|
||||||
// 终轮结束后由调用方套进「已工作 X」折叠。
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Collapsible, ToolBlock } from '@ui'
|
import { Collapsible, ToolBlock } from '@ui'
|
||||||
import type { Part } from '../contracts'
|
import type { Part } from '../contracts'
|
||||||
import { formatDuration, humanSize } from '../messageparts'
|
import { formatDuration, humanSize } from '../messageparts'
|
||||||
import { timelineEntries, toolGroupLabel, toolTail, type ToolGroupItem } from '../toolkind'
|
import { timelineEntries, toolGroupLabel, type ToolGroupItem } from '../toolkind'
|
||||||
|
|
||||||
export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
||||||
parts: Part[]
|
parts: Part[]
|
||||||
@@ -17,18 +16,10 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
|||||||
<div className="tl">
|
<div className="tl">
|
||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
if (row.kind === 'thinking') {
|
if (row.kind === 'thinking') {
|
||||||
const dur = formatDuration(row.durationMs)
|
return row.text && <div className="tl-thinking" key={`${idKey}:th:${i}`}>
|
||||||
const label = row.active ? '思考中' : (dur ? `思考 · 持续了 ${dur}` : '思考')
|
<span className="tl-thinking-label">思考过程</span>
|
||||||
return (
|
<div className="tl-thinking-body">{row.text}</div>
|
||||||
<div className="part-thinking" key={`${idKey}:th:${i}`}>
|
|
||||||
<Collapsible id={`think:${idKey}:${i}:${row.active ? 'live' : 'done'}`} defaultOpen={row.active} key={row.active ? 'open' : 'shut'}
|
|
||||||
triggerClassName="part-thinking-summary"
|
|
||||||
trigger={<><span className="part-thinking-chev">▸</span>
|
|
||||||
<span className={row.active ? 'mk-sweep' : ''}>{label}</span></>}>
|
|
||||||
<div className="part-thinking-body">{row.text}</div>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (row.kind === 'text') {
|
if (row.kind === 'text') {
|
||||||
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
|
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
|
||||||
@@ -51,6 +42,11 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
|||||||
<span className="tl-tools-name">{it.headline}</span>
|
<span className="tl-tools-name">{it.headline}</span>
|
||||||
{it.status === 'error' && <span className="status-error">失败</span>}
|
{it.status === 'error' && <span className="status-error">失败</span>}
|
||||||
{it.summary && <span className="tl-tools-sum">{it.summary}</span>}
|
{it.summary && <span className="tl-tools-sum">{it.summary}</span>}
|
||||||
|
{(it.arguments || it.output) && <Collapsible id={`tool-record:${idKey}:${it.id}:${j}`}
|
||||||
|
triggerClassName="tl-tool-detail" trigger={<span>查看记录 ▸</span>}>
|
||||||
|
{it.arguments && <pre className="tool-preview">{it.arguments}</pre>}
|
||||||
|
{it.output && <pre className="tool-preview">{it.output}</pre>}
|
||||||
|
</Collapsible>}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -70,7 +66,6 @@ function sizeLabel(n: number): string {
|
|||||||
|
|
||||||
function ToolCallBlock({ item }: { item: ToolGroupItem }) {
|
function ToolCallBlock({ item }: { item: ToolGroupItem }) {
|
||||||
const composing = item.phase === 'composing'
|
const composing = item.phase === 'composing'
|
||||||
const tail = item.running ? toolTail(item.arguments, item.output) : ''
|
|
||||||
const dur = !item.running ? formatDuration(item.durationMs) : ''
|
const dur = !item.running ? formatDuration(item.durationMs) : ''
|
||||||
const detail = composing ? undefined : (dur ? `${item.headline} · ${dur}` : item.headline)
|
const detail = composing ? undefined : (dur ? `${item.headline} · ${dur}` : item.headline)
|
||||||
return (
|
return (
|
||||||
@@ -88,7 +83,6 @@ function ToolCallBlock({ item }: { item: ToolGroupItem }) {
|
|||||||
{item.arguments && <pre className="tool-preview">{item.arguments}</pre>}
|
{item.arguments && <pre className="tool-preview">{item.arguments}</pre>}
|
||||||
{item.output && <pre className="tool-preview">{item.output}</pre>}
|
{item.output && <pre className="tool-preview">{item.output}</pre>}
|
||||||
</ToolBlock>
|
</ToolBlock>
|
||||||
{tail && <pre className="tool-live-preview">{tail}</pre>}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,25 +7,42 @@ import { store, useStore } from '../store'
|
|||||||
import type { SettingDesc } from '../contracts'
|
import type { SettingDesc } from '../contracts'
|
||||||
import { SettingsShell, Btn, EmptyState } from '@ui'
|
import { SettingsShell, Btn, EmptyState } from '@ui'
|
||||||
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
|
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
|
||||||
|
import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections'
|
||||||
|
import { rdmProjectReadError } from '../rdmManagement'
|
||||||
|
import { projectActionError, projectWorkError } from '../projectmeta'
|
||||||
|
|
||||||
export function ProjectSettingsModal() {
|
export function ProjectSettingsModal() {
|
||||||
const projectId = useStore(s => s.ui.projectSettingsId)
|
const projectId = useStore(s => s.ui.projectSettingsId)
|
||||||
const projects = useStore(s => s.projects)
|
const projects = useStore(s => s.projects)
|
||||||
const project = projects.find(p => p.id === projectId)
|
const project = projects.find(p => p.id === projectId)
|
||||||
|
const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable)
|
||||||
|
const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable)
|
||||||
|
const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable)
|
||||||
|
const owner = useStore(s => s.me?.owner)
|
||||||
|
const isAdmin = useStore(s => s.me?.isAdmin ?? false)
|
||||||
|
// P1-B 裁定:宿主覆盖设置(含模型与密钥)仅项目负责人/系统管理员可查看与修改;
|
||||||
|
// RDM 页签(成员/文档职责/项目权限)不在此列——数据面由 RDM 权限自行收敛,
|
||||||
|
// 授权的职责管理者仍可进入。
|
||||||
|
const projectPrivileged = !!project && (project.owner === owner || isAdmin)
|
||||||
|
const [tab, setTab] = useState('project')
|
||||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return
|
if (!projectId) return
|
||||||
setLoaded(false)
|
let active = true
|
||||||
|
setLoaded(false); setSaved(false); setError(''); setDraft({}); setSchema([])
|
||||||
api.getSettings(projectId).then(s => {
|
api.getSettings(projectId).then(s => {
|
||||||
|
if (!active) return
|
||||||
setSchema(s.schema)
|
setSchema(s.schema)
|
||||||
setDraft(flatten(s.project))
|
setDraft(flatten(s.project))
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}).catch(() => undefined)
|
}).catch(() => { if (active) setError('项目设置未能加载,请关闭后重新打开或联系项目负责人') })
|
||||||
}, [projectId])
|
return () => { active = false }
|
||||||
|
}, [projectId, owner])
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const g: Record<string, SettingDesc[]> = {}
|
const g: Record<string, SettingDesc[]> = {}
|
||||||
@@ -34,34 +51,51 @@ export function ProjectSettingsModal() {
|
|||||||
}, [schema])
|
}, [schema])
|
||||||
|
|
||||||
if (!projectId) return null
|
if (!projectId) return null
|
||||||
|
const rdmError = rdmProjectReadError(project)
|
||||||
|
const writeError = projectWorkError(project)
|
||||||
|
const rdmTab = tab === 'members' || tab === 'duties' || tab === 'matrix'
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!projectId) return
|
if (!projectId) return
|
||||||
|
if (writeError) { store.toast(writeError); return }
|
||||||
try {
|
try {
|
||||||
await api.putProjectSettings(projectId, unflatten(draft))
|
await api.putProjectSettings(projectId, unflatten(draft))
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 1500)
|
setTimeout(() => setSaved(false), 1500)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
store.toast(`项目设置保存失败:${e}`)
|
store.toast(`项目设置保存失败:${projectActionError(e)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsShell
|
<SettingsShell
|
||||||
title={`项目设置 · ${project?.name ?? projectId}`}
|
title={`项目设置 · ${project?.name ?? projectId}`}
|
||||||
active="project"
|
active={tab}
|
||||||
onSelect={() => undefined}
|
onSelect={setTab}
|
||||||
onClose={() => store.openProjectSettings(null)}
|
onClose={() => store.openProjectSettings(null)}
|
||||||
sections={[{ key: 'project', label: '本项目', icon: 'folder' }]}
|
sections={[
|
||||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
{ key: 'project', label: '本项目', icon: 'folder' },
|
||||||
|
// U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。
|
||||||
|
...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []),
|
||||||
|
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责', icon: 'shield' as const }] : []),
|
||||||
|
...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
|
||||||
|
]}
|
||||||
|
footer={saved ? <span style={{ color: 'var(--ok-text)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{!loaded ? (
|
{rdmTab && rdmError ? <EmptyState icon="folder">{rdmError}</EmptyState> : project && <>
|
||||||
<EmptyState icon="settings">加载中…</EmptyState>
|
{tab === 'members' && <RdmMembersSection key={project.id} project={project} />}
|
||||||
) : (
|
{tab === 'duties' && <RdmDutiesSection key={project.id} project={project} />}
|
||||||
|
{tab === 'matrix' && <RdmPermMatrixSection key={project.id} project={project} />}
|
||||||
|
</>}
|
||||||
|
{tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? (
|
||||||
|
<EmptyState icon="settings">{error || '加载中…'}</EmptyState>
|
||||||
|
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !projectPrivileged ? (
|
||||||
|
<EmptyState icon="settings">项目覆盖设置(含模型与密钥)仅项目负责人或系统管理员可查看与修改。</EmptyState>
|
||||||
|
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? (
|
||||||
<>
|
<>
|
||||||
<div className="empty-note" style={{ marginBottom: 10 }}>
|
<div className="empty-note" style={{ marginBottom: 10 }}>
|
||||||
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
|
{writeError ?? '此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。'}
|
||||||
</div>
|
</div>
|
||||||
{Object.entries(groups).map(([g, descs]) => (
|
{Object.entries(groups).map(([g, descs]) => (
|
||||||
<div className="settings-group" key={g}>
|
<div className="settings-group" key={g}>
|
||||||
@@ -77,9 +111,9 @@ export function ProjectSettingsModal() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
|
<Btn variant="primary" icon="check" disabled={!!writeError} onClick={save}>保存项目设置</Btn>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { setPersistedOpen } from '@ui'
|
||||||
|
import type { RdmImpactAnalysis, RdmOperationPage, RdmOperationQuery, RdmOperationRecord } from '../api'
|
||||||
|
import {
|
||||||
|
classifyOperationsError, createOperationsFeed, describeOperation, diffImpact, isLegacyOperation,
|
||||||
|
OperationRow, OperationsListView, startLatestFetch, TraceCoverageView, TreeNodeRow, visibleAuditTabs,
|
||||||
|
type OperationsFeedState,
|
||||||
|
} from './RdmAuditWorkspace'
|
||||||
|
import type { ActivityCard } from '../contracts'
|
||||||
|
import { store } from '../store'
|
||||||
|
import { RDM_AUDIT_COMMANDS, RDM_AUDIT_OPERATIONS_COMMANDS, rdmAuditOperationsReady } from '../workspaces'
|
||||||
|
import { BasisStrip } from './Conversation'
|
||||||
|
import { candidateActions, candidateChanges } from './ConversationWork'
|
||||||
|
import { openPendingConfirmation, pendingConfirmations } from './Workbench'
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (v: T) => void
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- W10 审计记录 ----
|
||||||
|
|
||||||
|
const NOT_RECORDED = { recorded: false, display: '当时未记录' }
|
||||||
|
const refs = (items: { key: string; label: string; id: string }[]) => ({
|
||||||
|
items: items.map(i => ({ kind: { key: i.key, label: i.label }, id: i.id })),
|
||||||
|
recorded: items.length > 0,
|
||||||
|
display: items.length > 0 ? `共 ${items.length} 项` : '当时未记录',
|
||||||
|
})
|
||||||
|
|
||||||
|
function record(id: string, patch: Partial<RdmOperationRecord> = {}): RdmOperationRecord {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
operation: { key: 'domain.mutate', label: '领域变更' },
|
||||||
|
actor: { name: { value: '张三', recorded: true, display: '张三' }, duty: { value: '设计负责人', recorded: true, display: '设计负责人' } },
|
||||||
|
initiatedAt: '2026-10-20T08:00:00Z',
|
||||||
|
effectiveAt: { value: '2026-10-20T08:00:01Z', recorded: true, display: '2026-10-20T08:00:01Z' },
|
||||||
|
object: { recorded: true, kind: { key: 'section', label: '章节' }, id: 'S-1', display: '章节 S-1' },
|
||||||
|
change: {
|
||||||
|
before: { value: 1, recorded: true, display: 'r1' },
|
||||||
|
after: { value: 2, recorded: true, display: 'r2' },
|
||||||
|
summary: { value: '补充风险控制措施', recorded: true, display: '补充风险控制措施' },
|
||||||
|
disclosure: 'ok',
|
||||||
|
},
|
||||||
|
basis: { reason: { value: '评审意见', recorded: true, display: '评审意见' }, evidence: refs([{ key: 'slice', label: '笔记切片', id: 'SL-9' }]) },
|
||||||
|
outcome: { key: 'succeeded', label: '成功' },
|
||||||
|
impact: { direct: refs([{ key: 'doc', label: '文档', id: 'D-1' }]), indirect: refs([]), analyses: refs([{ key: 'impact-analysis', label: '影响分析', id: 'IA-1' }]) },
|
||||||
|
run: { recorded: true, runId: 'run-7', stepId: 'step-2', attempt: 1, display: '宿主可信回执' },
|
||||||
|
...patch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(ids: string[], extra: Partial<RdmOperationPage> = {}): RdmOperationPage {
|
||||||
|
return {
|
||||||
|
items: ids.map(id => record(id)), hasMore: false, cutoffSeq: 42, total: ids.length,
|
||||||
|
dictionary: { types: [], outcomes: [], kinds: [] }, ...extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const READY: OperationsFeedState = {
|
||||||
|
phase: 'ready', items: [], nextCursor: '', hasMore: false, cutoffSeq: 42, total: 0,
|
||||||
|
dictionary: null, loadingMore: false, error: null,
|
||||||
|
}
|
||||||
|
const listHtml = (state: OperationsFeedState) =>
|
||||||
|
renderToStaticMarkup(<OperationsListView pid="p1" state={state} onMore={() => {}} onRestart={() => {}} />)
|
||||||
|
|
||||||
|
describe('classifyOperationsError', () => {
|
||||||
|
it('游标失效给出明确的从首页重查文案', () => {
|
||||||
|
const e = classifyOperationsError(new Error('操作记录游标已失效:游标绑定主体不符——请从首页重新查询'))
|
||||||
|
expect(e.kind).toBe('cursor')
|
||||||
|
expect(e.message).toContain('翻页位置已失效')
|
||||||
|
})
|
||||||
|
it('无审计授权与能力缺失分开呈现', () => {
|
||||||
|
expect(classifyOperationsError(new Error('权限拒绝:无项目审计授权')).kind).toBe('denied')
|
||||||
|
expect(classifyOperationsError(new Error('400: {"error":"unknown or disabled command"}')).kind).toBe('capability')
|
||||||
|
expect(classifyOperationsError(new Error('boom')).kind).toBe('query')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createOperationsFeed(审计列表单一异步所有者)', () => {
|
||||||
|
it('首页加载后续页带回游标与截止水位并追加', async () => {
|
||||||
|
const calls: RdmOperationQuery[] = []
|
||||||
|
const feed = createOperationsFeed(async q => {
|
||||||
|
calls.push(q)
|
||||||
|
return q.cursor ? page(['b']) : page(['a'], { hasMore: true, nextCursor: 'c1', total: 2 })
|
||||||
|
})
|
||||||
|
feed.first()
|
||||||
|
await flush()
|
||||||
|
expect(feed.getState().items.map(r => r.id)).toEqual(['a'])
|
||||||
|
feed.more()
|
||||||
|
expect(feed.getState().loadingMore).toBe(true)
|
||||||
|
await flush()
|
||||||
|
expect(calls[1]).toEqual({ cursor: 'c1', cutoffSeq: 42 })
|
||||||
|
expect(feed.getState().items.map(r => r.id)).toEqual(['a', 'b'])
|
||||||
|
expect(feed.getState().hasMore).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按 seq 丢弃迟到响应:重查后旧请求的结果不写回', async () => {
|
||||||
|
const older = deferred<RdmOperationPage>()
|
||||||
|
const newer = deferred<RdmOperationPage>()
|
||||||
|
const queue = [older, newer]
|
||||||
|
const feed = createOperationsFeed(() => queue.shift()!.promise)
|
||||||
|
const seen = vi.fn()
|
||||||
|
feed.subscribe(seen)
|
||||||
|
feed.first()
|
||||||
|
feed.first()
|
||||||
|
newer.resolve(page(['new']))
|
||||||
|
await flush()
|
||||||
|
older.resolve(page(['old']))
|
||||||
|
await flush()
|
||||||
|
expect(feed.getState().items.map(r => r.id)).toEqual(['new'])
|
||||||
|
older.reject(new Error('late'))
|
||||||
|
await flush()
|
||||||
|
expect(feed.getState().error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('翻页途中重查:迟到的续页不混入新列表', async () => {
|
||||||
|
const more = deferred<RdmOperationPage>()
|
||||||
|
let n = 0
|
||||||
|
const feed = createOperationsFeed(q => {
|
||||||
|
n++
|
||||||
|
if (q.cursor) return more.promise
|
||||||
|
return Promise.resolve(n === 1 ? page(['a'], { hasMore: true, nextCursor: 'c1' }) : page(['fresh']))
|
||||||
|
})
|
||||||
|
feed.first()
|
||||||
|
await flush()
|
||||||
|
feed.more()
|
||||||
|
feed.first()
|
||||||
|
await flush()
|
||||||
|
more.resolve(page(['stale-b']))
|
||||||
|
await flush()
|
||||||
|
expect(feed.getState().items.map(r => r.id)).toEqual(['fresh'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dispose 后在途响应被丢弃', async () => {
|
||||||
|
const pending = deferred<RdmOperationPage>()
|
||||||
|
const feed = createOperationsFeed(() => pending.promise)
|
||||||
|
feed.first()
|
||||||
|
feed.dispose()
|
||||||
|
pending.resolve(page(['late']))
|
||||||
|
await flush()
|
||||||
|
expect(feed.getState().phase).toBe('loading')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('续页游标失效:保留已读记录并给出游标错误', async () => {
|
||||||
|
const feed = createOperationsFeed(async q => {
|
||||||
|
if (q.cursor) throw new Error('操作记录游标已失效:游标绑定项目不符')
|
||||||
|
return page(['a'], { hasMore: true, nextCursor: 'c1' })
|
||||||
|
})
|
||||||
|
feed.first()
|
||||||
|
await flush()
|
||||||
|
feed.more()
|
||||||
|
await flush()
|
||||||
|
const s = feed.getState()
|
||||||
|
expect(s.items.map(r => r.id)).toEqual(['a'])
|
||||||
|
expect(s.error?.kind).toBe('cursor')
|
||||||
|
expect(s.loadingMore).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('OperationsListView 状态文案', () => {
|
||||||
|
it('加载态', () => {
|
||||||
|
expect(listHtml({ ...READY, phase: 'loading' })).toContain('正在读取审计记录')
|
||||||
|
})
|
||||||
|
it('首页错误:无权限态可读且附原始回执', () => {
|
||||||
|
const html = listHtml({ ...READY, phase: 'error', error: classifyOperationsError(new Error('权限拒绝:x')) })
|
||||||
|
expect(html).toContain('你没有该项目的审计授权')
|
||||||
|
expect(html).toContain('原始回执')
|
||||||
|
expect(html).not.toContain('<details')
|
||||||
|
})
|
||||||
|
it('空列表', () => {
|
||||||
|
expect(listHtml(READY)).toContain('当前筛选下没有审计记录')
|
||||||
|
})
|
||||||
|
it('列表含业务摘要、计数与翻页按钮', () => {
|
||||||
|
const html = listHtml({ ...READY, items: [record('a')], total: 3, hasMore: true, nextCursor: 'c1' })
|
||||||
|
expect(html).toContain('张三')
|
||||||
|
expect(html).toContain('领域变更')
|
||||||
|
expect(html).toContain('章节 S-1')
|
||||||
|
expect(html).toContain('已显示 1 / 共 3 条 · 截至水位 42')
|
||||||
|
expect(html).toContain('加载更多记录')
|
||||||
|
})
|
||||||
|
it('续页游标失效:显示错误与从第一页重新查询,不再提供续页按钮', () => {
|
||||||
|
const html = listHtml({ ...READY, items: [record('a')], hasMore: true, nextCursor: 'c1', error: classifyOperationsError(new Error('操作记录游标已失效')) })
|
||||||
|
expect(html).toContain('翻页位置已失效')
|
||||||
|
expect(html).toContain('从第一页重新查询')
|
||||||
|
expect(html).not.toContain('加载更多记录')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('OperationRow 展开内容', () => {
|
||||||
|
const row = (r: RdmOperationRecord) => renderToStaticMarkup(<OperationRow pid="p1" record={r} defaultOpen />)
|
||||||
|
|
||||||
|
it('字段差异、来源/确认链、当时影响与证据跳转入口', () => {
|
||||||
|
const html = row(record('a'))
|
||||||
|
expect(html).toContain('字段差异')
|
||||||
|
expect(html).toContain('r1')
|
||||||
|
expect(html).toContain('r2')
|
||||||
|
expect(html).toContain('补充风险控制措施')
|
||||||
|
expect(html).toContain('来源与确认链')
|
||||||
|
expect(html).toContain('评审意见')
|
||||||
|
expect(html).toContain('运行 run-7')
|
||||||
|
expect(html).toContain('查看证据:笔记切片 SL-9')
|
||||||
|
expect(html).toContain('文档 D-1')
|
||||||
|
expect(html).toContain('查看影响分析 IA-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('人物以当时名称为主,当前名称只带「当前」标记', () => {
|
||||||
|
const r = record('a', { actor: { name: { value: '张三', recorded: true, display: '张三' }, duty: NOT_RECORDED, current: { value: '张三丰', marker: '当前' } } })
|
||||||
|
const v = describeOperation(r)
|
||||||
|
expect(v.who).toBe('张三')
|
||||||
|
expect(v.whoNow).toBe('当前:张三丰')
|
||||||
|
expect(row(r)).toContain('当前:张三丰')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('旧数据:缺项显示「当时未记录」并标旧记录', () => {
|
||||||
|
const r = record('a', { basis: { reason: NOT_RECORDED, evidence: refs([]) }, change: { ...record('a').change, before: NOT_RECORDED } })
|
||||||
|
expect(isLegacyOperation(r)).toBe(true)
|
||||||
|
const html = row(r)
|
||||||
|
expect(html).toContain('旧记录')
|
||||||
|
expect(html).toContain('当时未记录')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无正文权:只显示服务端给出的「受限」元数据', () => {
|
||||||
|
const r = record('a', { change: { ...record('a').change, summary: { recorded: true, display: '受限' }, disclosure: 'restricted' } })
|
||||||
|
const html = row(r)
|
||||||
|
expect(html).toContain('正文受限')
|
||||||
|
expect(html).toContain('受限')
|
||||||
|
expect(html).not.toContain('补充风险控制措施')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('长对象名可换行(不截断)', () => {
|
||||||
|
const long = '章节 ' + 'X'.repeat(200)
|
||||||
|
const html = row(record('a', { object: { recorded: true, kind: { key: 'section', label: '章节' }, id: 'X', display: long } }))
|
||||||
|
expect(html).toContain(long)
|
||||||
|
expect(html).toContain('class="rdm-audit-op-object"')
|
||||||
|
expect(html).toContain('rdm-audit-event-summary rdm-audit-wrap')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('证据与影响分析折叠(@ui Collapsible,稳定 id)', () => {
|
||||||
|
const row = () => renderToStaticMarkup(<OperationRow pid="p1" record={record('a')} defaultOpen />)
|
||||||
|
const trigger = (expanded: boolean, text: string) =>
|
||||||
|
new RegExp(`<button type="button" class="mk-col-trigger\\s*" aria-expanded="${expanded}"><span class="rdm-audit-err-toggle rdm-audit-wrap">${text}</span></button>`)
|
||||||
|
|
||||||
|
it('默认收起:触发钮由 Collapsible 渲染,详情未挂载(不发起读取)', () => {
|
||||||
|
const html = row()
|
||||||
|
expect(html).toMatch(trigger(false, '查看证据:笔记切片 SL-9'))
|
||||||
|
expect(html).toMatch(trigger(false, '查看影响分析 IA-1'))
|
||||||
|
expect(html).not.toContain('btn v-ghost rdm-audit-wrap')
|
||||||
|
expect(html).not.toContain('加载中…')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('展开态按稳定 id 记忆:重挂载后恢复展开并挂载详情', () => {
|
||||||
|
setPersistedOpen('rdm-audit-evidence:p1/slice/SL-9', true)
|
||||||
|
setPersistedOpen('rdm-audit-impact:p1/IA-1', true)
|
||||||
|
try {
|
||||||
|
const html = row()
|
||||||
|
expect(html).toMatch(trigger(true, '查看证据:笔记切片 SL-9'))
|
||||||
|
expect(html).toMatch(trigger(true, '查看影响分析 IA-1'))
|
||||||
|
expect(html.match(/加载中…/g)).toHaveLength(2)
|
||||||
|
} finally {
|
||||||
|
setPersistedOpen('rdm-audit-evidence:p1/slice/SL-9', false)
|
||||||
|
setPersistedOpen('rdm-audit-impact:p1/IA-1', false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('审计记录页签能力门禁', () => {
|
||||||
|
const enabled = (ids: readonly string[]) => ids.map(id => ({ id, enabled: true }))
|
||||||
|
|
||||||
|
it('五条 W8b/W5 命令齐备且启用才就绪;缺一、停用或清单不可用均不就绪', () => {
|
||||||
|
expect(RDM_AUDIT_OPERATIONS_COMMANDS).toHaveLength(5)
|
||||||
|
const all = enabled(RDM_AUDIT_OPERATIONS_COMMANDS)
|
||||||
|
expect(rdmAuditOperationsReady(all)).toBe(true)
|
||||||
|
for (const missing of RDM_AUDIT_OPERATIONS_COMMANDS) {
|
||||||
|
expect(rdmAuditOperationsReady(all.filter(c => c.id !== missing))).toBe(false)
|
||||||
|
expect(rdmAuditOperationsReady(all.map(c => (c.id === missing ? { ...c, enabled: false } : c)))).toBe(false)
|
||||||
|
}
|
||||||
|
expect(rdmAuditOperationsReady(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只有 rdm.inspect.* 七命令的构建上不就绪', () => {
|
||||||
|
expect(rdmAuditOperationsReady(enabled(RDM_AUDIT_COMMANDS))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('能力缺失时不暴露「审计记录」页签,其余页签不受影响', () => {
|
||||||
|
expect(visibleAuditTabs(false).map(t => t.label)).toEqual(['概览', '资料与文档', '关联追溯', '操作记录'])
|
||||||
|
expect(visibleAuditTabs(true).map(t => t.label)).toEqual(['概览', '资料与文档', '关联追溯', '操作记录', '审计记录'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TraceCoverageView 部分覆盖', () => {
|
||||||
|
it('候选不计正式覆盖;缺项与待复核提示部分覆盖', () => {
|
||||||
|
const html = renderToStaticMarkup(<TraceCoverageView pid="p1" data={{
|
||||||
|
coverage: { confirmed: 3, candidate: 2, gaps: [{ rule: 4, node: 'UN-1@1', reason: '缺执行结果' }] },
|
||||||
|
pending: [{ kind: 'verifies', from: 'SR:1', to: 'VR:2', fromOldRevision: '1', fromNewRevision: '2', toOldRevision: '1', toNewRevision: '', state: 'pending-recheck', source: 'tracenet', edgeId: 'e1' }],
|
||||||
|
}} />)
|
||||||
|
expect(html).toContain('正式覆盖')
|
||||||
|
expect(html).toContain('候选(不计正式覆盖)')
|
||||||
|
expect(html).toContain('部分覆盖')
|
||||||
|
expect(html).toContain('缺项链(1)')
|
||||||
|
expect(html).toContain('待复核受控边(1)')
|
||||||
|
})
|
||||||
|
it('全部确认时不提示部分覆盖', () => {
|
||||||
|
const html = renderToStaticMarkup(<TraceCoverageView pid="p1" data={{ coverage: { confirmed: 5, candidate: 0, gaps: [] }, pending: [] }} />)
|
||||||
|
expect(html).not.toContain('部分覆盖')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('diffImpact(当时与当前影响)', () => {
|
||||||
|
const analysis = (involved: string[]): RdmImpactAnalysis => ({
|
||||||
|
id: 'x', start: { kind: 'UR', id: '1', revision: '1' }, direct: [], layers: [],
|
||||||
|
involved: involved.map(id => ({ kind: 'SR', id })), truncated: false, maxNodes: 100, maxDepth: 5, at: '2026-10-20T00:00:00Z',
|
||||||
|
})
|
||||||
|
it('按 kind:id 比较新增与不再涉及', () => {
|
||||||
|
const d = diffImpact(analysis(['a', 'b']), analysis(['b', 'c']))
|
||||||
|
expect(d.added.map(r => r.id)).toEqual(['c'])
|
||||||
|
expect(d.removed.map(r => r.id)).toEqual(['a'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('对话侧:候选差异、确认路径与依据条', () => {
|
||||||
|
const card = (over: Partial<ActivityCard>): ActivityCard => ({
|
||||||
|
id: 'c1', projectId: 'p1', conversationId: 'v1', kind: 'progress', title: '追溯候选', status: 'action_required',
|
||||||
|
createdAt: '2026-10-20T00:00:00Z', updatedAt: '2026-10-20T00:00:00Z', ...over,
|
||||||
|
})
|
||||||
|
const finding = (over: Record<string, unknown>) => ({ id: 'f1', text: '需求 UR-1 由 SR-2 覆盖', kind: 'analysis', status: 'active', revision: 1, sources: [], ...over })
|
||||||
|
const task = (findings: unknown[]) => ({ findings } as unknown as ActivityCard['task'])
|
||||||
|
|
||||||
|
it('候选差异只取服务端发现字段:更正前、候选与影响', () => {
|
||||||
|
const changes = candidateChanges(card({ task: task([
|
||||||
|
finding({ corrects: '需求 UR-1 无覆盖', impact: '验证 VR-3 需复核' }),
|
||||||
|
finding({ id: 'f2', status: 'superseded', corrects: '旧' }),
|
||||||
|
finding({ id: 'f3' }),
|
||||||
|
]) }))
|
||||||
|
expect(changes).toEqual([{ key: 'f1:1', before: '需求 UR-1 无覆盖', after: '需求 UR-1 由 SR-2 覆盖', impact: '验证 VR-3 需复核' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非待处理卡不呈现候选差异', () => {
|
||||||
|
expect(candidateChanges(card({ status: 'done', task: task([finding({ impact: 'x' })]) }))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('确认/拒绝只为工作卡补齐(非工作卡动作行由卡片自身渲染),确认在前', () => {
|
||||||
|
const actions = [{ id: 'reject', label: '拒绝' }, { id: 'confirm', label: '确认' }, { id: 'recheck', label: '重新检查' }]
|
||||||
|
expect(candidateActions(card({ actions }))).toEqual([])
|
||||||
|
const work = { resultVersion: 2, readVersion: 2 }
|
||||||
|
expect(candidateActions(card({ actions, work })).map(a => a.id)).toEqual(['confirm', 'reject'])
|
||||||
|
expect(candidateActions(card({ actions, work, status: 'resolved' }))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('待确认计数只算本会话待处理卡,供右栏收起后的对话确认路径', () => {
|
||||||
|
const cards = [
|
||||||
|
card({ id: 'a' }),
|
||||||
|
card({ id: 'b', status: 'active', work: { resultVersion: 1, readVersion: 1, decision: { mode: 'single', state: 'open' } } }),
|
||||||
|
card({ id: 'c', conversationId: 'other' }),
|
||||||
|
card({ id: 'd', status: 'done' }),
|
||||||
|
]
|
||||||
|
expect(pendingConfirmations(cards, 'v1', 'p1')).toBe(2)
|
||||||
|
expect(pendingConfirmations(cards, 'v1', 'p2')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('右栏收起后的确认路径经 openInbox 定位首张待处理卡,与计数同一谓词(含决议开放的工作卡)', () => {
|
||||||
|
const openInbox = vi.spyOn(store, 'openInbox').mockImplementation(() => {})
|
||||||
|
try {
|
||||||
|
const decisionOpen = { resultVersion: 1, readVersion: 1, decision: { mode: 'single' as const, state: 'open' as const } }
|
||||||
|
const cards = [
|
||||||
|
card({ id: 'dismissed', status: 'dismissed' }),
|
||||||
|
card({ id: 'elsewhere', conversationId: 'other' }),
|
||||||
|
card({ id: 'decision', status: 'active', work: decisionOpen }),
|
||||||
|
card({ id: 'confirm' }),
|
||||||
|
]
|
||||||
|
expect(pendingConfirmations(cards, 'v1', 'p1')).toBe(2)
|
||||||
|
openPendingConfirmation(cards, 'v1', 'p1')
|
||||||
|
expect(openInbox).toHaveBeenLastCalledWith('decision')
|
||||||
|
openPendingConfirmation(cards.filter(c => c.id !== 'decision'), 'v1', 'p1')
|
||||||
|
expect(openInbox).toHaveBeenLastCalledWith('confirm')
|
||||||
|
} finally {
|
||||||
|
openInbox.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('依据条列出来源与 token 摘要,不含正文', () => {
|
||||||
|
const html = renderToStaticMarkup(<BasisStrip obs={{
|
||||||
|
items: ['项目知识', '对话历史'], estChars: 1800, truncations: ['历史已裁剪'],
|
||||||
|
sections: [{ name: '项目知识', estTokens: 1200 }, { name: '对话历史', estTokens: 600 }],
|
||||||
|
}} />)
|
||||||
|
expect(html).toContain('本次依据')
|
||||||
|
expect(html).toContain('项目知识 · 约 1200 token')
|
||||||
|
expect(html).toContain('共约 1800 token')
|
||||||
|
expect(html).toContain('有 1 处内容被裁剪')
|
||||||
|
expect(html).not.toContain('历史已裁剪')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无分节时退回条目名;无来源时不渲染', () => {
|
||||||
|
expect(renderToStaticMarkup(<BasisStrip obs={{ items: ['系统提示'], estChars: 10 }} />)).toContain('系统提示')
|
||||||
|
expect(renderToStaticMarkup(<BasisStrip obs={{ items: [], estChars: 0 }} />)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
|||||||
|
// U7 法规结构化引用的共享呈现:编号标记、引用卡片、引用清单(审计工作区与驾驶舱阅读器共用)。
|
||||||
|
// 正文与原文片段一律作为文本节点渲染,不经 HTML。非 verified 状态用未核验样式,invalid 用无效样式;
|
||||||
|
// 折叠只用 @ui Collapsible;颜色/字号/圆角只消费 @ui/tokens.css(见 reg-citations.css)。
|
||||||
|
import { Fragment, useState, type ReactNode } from 'react'
|
||||||
|
import { Chip, Collapsible } from '@ui'
|
||||||
|
import {
|
||||||
|
CITATION_CHIP_TONE, citationListOrder, citationMarkText, citationStateLabel, citationStyle,
|
||||||
|
disclosureLabel, excerptView, parseCitedBlocks, safeWebHref, staleLabel,
|
||||||
|
type CitationView,
|
||||||
|
} from '../regCitations'
|
||||||
|
import '../reg-citations.css'
|
||||||
|
|
||||||
|
/** 状态徽标:核验状态 + 过期「待复核」。 */
|
||||||
|
export function CitationStateChips({ c }: { c: { state?: string; stale?: boolean; staleReason?: string } }) {
|
||||||
|
const stale = staleLabel(c)
|
||||||
|
return (
|
||||||
|
<span className="regcite-chips">
|
||||||
|
<Chip tone={CITATION_CHIP_TONE[citationStyle({ state: c.state })]}>{citationStateLabel(c.state)}</Chip>
|
||||||
|
{stale && <Chip tone="warn">{stale}</Chip>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function citationHeading(c: CitationView): string {
|
||||||
|
const parts = [c.docNo, c.clauseNo].filter(Boolean)
|
||||||
|
return parts.length > 0 ? parts.join(' ') : (c.title || c.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(at: string | undefined): string {
|
||||||
|
if (!at) return '—'
|
||||||
|
const d = new Date(at)
|
||||||
|
return Number.isNaN(d.getTime()) ? at : d.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Excerpt({ id, label, text, redaction }: { id: string; label: string; text?: string; redaction?: string }) {
|
||||||
|
if (!text) return <div className="regcite-redaction">{label}未展示:{redaction}</div>
|
||||||
|
return (
|
||||||
|
<Collapsible id={id} trigger={<span className="regcite-toggle">{label}</span>}>
|
||||||
|
<blockquote className="regcite-excerpt">{text}</blockquote>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 引用卡片:元数据 + 可展开原文片段(受披露约束);缺失或 invalid 的 ID 只给无效说明。 */
|
||||||
|
export function CitationCard({ id, citation, scope }: { id: string; citation?: CitationView; scope: string }) {
|
||||||
|
const style = citationStyle(citation)
|
||||||
|
if (!citation || style === 'invalid') {
|
||||||
|
return (
|
||||||
|
<div className="regcite-card regcite-card-invalid">
|
||||||
|
<div className="regcite-card-head">
|
||||||
|
<Chip tone="danger">无效引用</Chip>
|
||||||
|
<span className="regcite-mono">{id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="regcite-note">
|
||||||
|
{citation ? '引用 ID 无法解析为证据批' : '本节引用清单中没有该引用'}——不得作为法律依据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const web = citation.web
|
||||||
|
const href = safeWebHref(web?.finalUrl || web?.url)
|
||||||
|
const fields: [string, ReactNode][] = []
|
||||||
|
const add = (label: string, v: ReactNode) => { if (v !== undefined && v !== null && v !== '') fields.push([label, v]) }
|
||||||
|
add('文号', citation.docNo)
|
||||||
|
add('标题', citation.title)
|
||||||
|
add('版本', citation.editionLabel)
|
||||||
|
add('效力状态', citation.legalStatus)
|
||||||
|
add('条款', citation.clauseNo)
|
||||||
|
add('章节路径', citation.headingPath?.length ? citation.headingPath.join(' › ') : undefined)
|
||||||
|
add('语料修订', citation.corpusRevision && <span className="regcite-mono">{citation.corpusRevision}</span>)
|
||||||
|
add('原文披露', citation.disclosure && disclosureLabel(citation.disclosure))
|
||||||
|
if (web) {
|
||||||
|
add('网络来源', href
|
||||||
|
? <a className="regcite-link" href={href} target="_blank" rel="noopener noreferrer">{web.finalUrl || web.url}</a>
|
||||||
|
: web.finalUrl || web.url)
|
||||||
|
add('官方来源', web.official ? '是' : '否')
|
||||||
|
add('抓取时间', formatTime(web.fetchedAt))
|
||||||
|
}
|
||||||
|
add('证据批', citation.batchId && <span className="regcite-mono">{citation.batchId}</span>)
|
||||||
|
add('引用 ID', <span className="regcite-mono">{citation.id}</span>)
|
||||||
|
const ex = excerptView(citation)
|
||||||
|
return (
|
||||||
|
<div className={`regcite-card regcite-card-${style}`}>
|
||||||
|
<div className="regcite-card-head">
|
||||||
|
<span className="regcite-card-title">{citationHeading(citation)}</span>
|
||||||
|
<CitationStateChips c={citation} />
|
||||||
|
</div>
|
||||||
|
{style !== 'valid' && <div className="regcite-note">未核验引用——不得作为有效法律依据</div>}
|
||||||
|
{citation.claim && <div className="regcite-claim">主张:{citation.claim}</div>}
|
||||||
|
<dl className="regcite-fields">
|
||||||
|
{fields.map(([label, v]) => (
|
||||||
|
<Fragment key={label}><dt>{label}</dt><dd>{v}</dd></Fragment>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<Excerpt id={`regcite-excerpt-${scope}-${citation.id}`} label="原文片段"
|
||||||
|
text={'excerpt' in ex ? ex.excerpt : undefined}
|
||||||
|
redaction={'redaction' in ex ? ex.redaction : undefined} />
|
||||||
|
{web?.excerpt && (
|
||||||
|
<Excerpt id={`regcite-web-${scope}-${citation.id}`} label="网页摘录" text={web.excerpt} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 引用清单一行:摘要触发 + 折叠卡片。 */
|
||||||
|
export function CitationListItem({ id, n, citation, scope }: { id: string; n?: number; citation?: CitationView; scope: string }) {
|
||||||
|
const style = citationStyle(citation)
|
||||||
|
return (
|
||||||
|
<div className={`regcite-item regcite-item-${style}`}>
|
||||||
|
<Collapsible id={`regcite-item-${scope}-${id}`} trigger={
|
||||||
|
<span className="regcite-item-head">
|
||||||
|
{n !== undefined && <span className="regcite-item-num">[{n}]</span>}
|
||||||
|
<span className="regcite-item-title">{citation && style !== 'invalid' ? citationHeading(citation) : id}</span>
|
||||||
|
{citation && style !== 'invalid' && citation.title && citation.title !== citationHeading(citation) && (
|
||||||
|
<span className="regcite-item-sub">{citation.title}</span>
|
||||||
|
)}
|
||||||
|
{citation && style !== 'invalid'
|
||||||
|
? <CitationStateChips c={citation} />
|
||||||
|
: <Chip tone="danger">无效引用</Chip>}
|
||||||
|
</span>
|
||||||
|
}>
|
||||||
|
<CitationCard id={id} citation={citation} scope={scope} />
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 带引用的正文:[cit-…] 渲染为编号标记,点击在段落下展开引用卡片;正文下附引用清单。 */
|
||||||
|
export function CitedBody({ texts, citations, scope, paraClassName = 'cockpit-reader-para' }: {
|
||||||
|
texts: string[]
|
||||||
|
citations?: CitationView[] | null
|
||||||
|
scope: string
|
||||||
|
paraClassName?: string
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState<string | null>(null)
|
||||||
|
const { blocks, order } = parseCitedBlocks(texts)
|
||||||
|
const index = new Map((citations || []).map(c => [c.id, c]))
|
||||||
|
const list = citationListOrder(order, citations)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{blocks.map((segs, bi) => {
|
||||||
|
const ids = [...new Set(segs.flatMap(s => (s.kind === 'cite' ? [s.id] : [])))]
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return <p key={bi} className={paraClassName}>{segs.map(s => (s.kind === 'text' ? s.text : '')).join('')}</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={bi} className="regcite-block">
|
||||||
|
<p className={paraClassName}>
|
||||||
|
{segs.map((s, si) => {
|
||||||
|
if (s.kind === 'text') return <Fragment key={si}>{s.text}</Fragment>
|
||||||
|
const c = index.get(s.id)
|
||||||
|
const key = `${bi}:${s.id}`
|
||||||
|
const isOpen = open === key
|
||||||
|
return (
|
||||||
|
<button key={si} type="button"
|
||||||
|
className={`regcite-mark regcite-${citationStyle(c)}${isOpen ? ' regcite-mark-open' : ''}`}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
title={`引用 ${s.n}:${c ? citationStateLabel(c.state) : '无效引用'}`}
|
||||||
|
onClick={() => setOpen(isOpen ? null : key)}>
|
||||||
|
[{citationMarkText(s.n, c)}]
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{ids.map(id => (
|
||||||
|
<Collapsible key={id} open={open === `${bi}:${id}`}>
|
||||||
|
<CitationCard id={id} citation={index.get(id)} scope={`${scope}-inline`} />
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{list.length > 0 && (
|
||||||
|
<div className="regcite-list">
|
||||||
|
<div className="regcite-list-head">引用({list.length})</div>
|
||||||
|
{list.map((id, i) => (
|
||||||
|
<CitationListItem key={id} id={id} n={i + 1} citation={index.get(id)} scope={scope} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { CapabilityChangePreview, CapabilityGroupEditor, rolePermissionSaveError } from './RolePermissionsPanel'
|
||||||
|
import {
|
||||||
|
buildCapabilityGroups, capabilityGroupChanges, createRolePermissionDraft, linkedChainBreaks, setCapabilityGroup,
|
||||||
|
type RolePermissionValue,
|
||||||
|
} from '../rolePermissions'
|
||||||
|
|
||||||
|
const catalog = [
|
||||||
|
{ actionId: 'project.create', label: '创建项目', group: '项目入口' },
|
||||||
|
{ actionId: 'project.settings', label: '项目设置', group: '项目管理' },
|
||||||
|
{ actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' },
|
||||||
|
{ actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' },
|
||||||
|
{ actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' },
|
||||||
|
{ actionId: 'document.author', label: '编制文档', group: '文档动作' },
|
||||||
|
{ actionId: 'document.review', label: '审核文档', group: '文档动作' },
|
||||||
|
{ actionId: 'document.approve', label: '批准文档', group: '文档动作' },
|
||||||
|
]
|
||||||
|
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
|
||||||
|
const off: RolePermissionValue = { allowed: false, capAllowed: false }
|
||||||
|
const values = Object.fromEntries(catalog.map(item => [item.actionId, off]))
|
||||||
|
const groups = buildCapabilityGroups(catalog)
|
||||||
|
|
||||||
|
describe('CapabilityGroup role editor', () => {
|
||||||
|
it('replaces the per-capability checkbox grid with one default/cap pair per business group', () => {
|
||||||
|
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled={false} onToggle={vi.fn()} />)
|
||||||
|
expect(html.match(/type="checkbox"/g)).toHaveLength(groups.length * 2)
|
||||||
|
expect(html).toContain('创建与管理本人项目 默认授予')
|
||||||
|
expect(html).toContain('文档编制与审签 角色上限')
|
||||||
|
expect(html).not.toContain('编制文档 默认允许')
|
||||||
|
expect(html).not.toContain('管理项目成员 默认授予')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows linked own-project management as controlled items with their association source', () => {
|
||||||
|
const create = groups.find(group => group.id === 'project-create')!
|
||||||
|
const linked = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true).values
|
||||||
|
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={linked} labels={labels} disabled onToggle={vi.fn()} />)
|
||||||
|
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
|
||||||
|
expect(html).toContain('分配文档三权:已联动')
|
||||||
|
const before = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled onToggle={vi.fn()} />)
|
||||||
|
expect(before).toContain('未联动(开启创建项目后自动补齐)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a partially granted group instead of hiding the difference', () => {
|
||||||
|
const partial = { ...values, 'document.author': { allowed: true, capAllowed: true } }
|
||||||
|
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={partial} labels={labels} disabled={false} onToggle={vi.fn()} />)
|
||||||
|
expect(html).toContain('默认授予(部分)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previews the grouped difference with the affected source and affected user count', () => {
|
||||||
|
const create = groups.find(group => group.id === 'project-create')!
|
||||||
|
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
|
||||||
|
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
|
||||||
|
labels={labels} roleName="项目经理" bindingCount={7} />)
|
||||||
|
expect(html).toContain('影响来源:职位角色「项目经理」 · 影响 7 名绑定用户')
|
||||||
|
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
|
||||||
|
expect(html).toContain('联动补齐 项目设置、管理项目成员、管理项目权限、分配文档三权(仅本人创建的项目)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the draft and names a linked conflict the server rejected as a whole', () => {
|
||||||
|
expect(rolePermissionSaveError(new Error('422: linked capability conflict project.members.manage')))
|
||||||
|
.toMatch(/关联能力冲突.*整笔拒绝.*本地草稿已保留/)
|
||||||
|
expect(rolePermissionSaveError(new Error('500: boom'))).toContain('本地草稿已保留')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps every action label visible inside the linked-source list once project creation opens', () => {
|
||||||
|
const create = groups.find(group => group.id === 'project-create')!
|
||||||
|
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
|
||||||
|
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={draft.values} labels={labels} disabled onToggle={vi.fn()} />)
|
||||||
|
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
|
||||||
|
expect(html).toContain('项目设置:已联动')
|
||||||
|
expect(html).toContain('管理项目权限:已联动')
|
||||||
|
expect(html).toContain('分配文档三权:已联动')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits every preview row by business group when multiple groups change together', () => {
|
||||||
|
const create = groups.find(group => group.id === 'project-create')!
|
||||||
|
const duties = groups.find(group => group.id === 'document-duties')!
|
||||||
|
let draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
|
||||||
|
draft = setCapabilityGroup(draft, duties, 'allowed', true)
|
||||||
|
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
|
||||||
|
labels={labels} roleName="项目经理" bindingCount={7} />)
|
||||||
|
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
|
||||||
|
expect(html).toContain('文档编制与审签:默认 拒绝 → 允许')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a 403 the same as a 400 for linked-capability rejection phrasing', () => {
|
||||||
|
expect(rolePermissionSaveError(new Error('403: linked capability conflict'))).toMatch(/关联能力冲突/)
|
||||||
|
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).toContain('本地草稿已保留')
|
||||||
|
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).not.toMatch(/关联能力冲突/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
|
||||||
|
import {
|
||||||
|
buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft, ensureLinkedCapabilities,
|
||||||
|
linkedChainBreaks, rebaseRoleDraft, setCapabilityGroup, validateRolePermissionDraft, visiblePatch,
|
||||||
|
type CapabilityGroup, type CapabilityGroupChange, type GroupLevel, type RolePermissionDraft, type RolePermissionValue,
|
||||||
|
} from '../rolePermissions'
|
||||||
|
import { Collapsible, Icon } from '@ui'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
|
||||||
|
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
|
||||||
|
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
|
||||||
|
|
||||||
|
export function filterRoles(
|
||||||
|
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
|
||||||
|
): RoleRecord[] {
|
||||||
|
const normalized = query.trim().toLocaleLowerCase()
|
||||||
|
return roles.filter(role => {
|
||||||
|
if (status === 'enabled' && !role.enabled) return false
|
||||||
|
if (status === 'disabled' && role.enabled) return false
|
||||||
|
if (!normalized) return true
|
||||||
|
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
|
||||||
|
return mode === 'exact' ? fields.some(value => value === normalized) : fields.some(value => value.includes(normalized))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupPermissionPage(catalog: PermissionDescriptor[], group: string, page: number, size: number): PermissionDescriptor[] {
|
||||||
|
const grouped = catalog.filter(item => !group || item.group === group)
|
||||||
|
return grouped.slice(page * size, page * size + size)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean {
|
||||||
|
return activeSequence === responseSequence
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSystemAction(actions: string[] | undefined, action: string): boolean {
|
||||||
|
return actions?.includes(action) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<string, RolePermissionValue> {
|
||||||
|
const existing = new Map(role.permissions.map(item => [item.actionId, item]))
|
||||||
|
return Object.fromEntries(catalog.filter(item => !item.protected).map(item => {
|
||||||
|
const value = existing.get(item.actionId)
|
||||||
|
return [item.actionId, { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_TEXT: Record<GroupLevel, string> = { on: '允许', off: '拒绝', mixed: '部分' }
|
||||||
|
|
||||||
|
function linkedStatus(values: Record<string, RolePermissionValue>, actionId: string, createOn: boolean): string {
|
||||||
|
if (values[actionId]?.capAllowed) return '已联动(来源:创建项目,仅本人创建的项目)'
|
||||||
|
return createOn ? '待联动补齐(保存时随创建项目一并补齐)' : '未联动(开启创建项目后自动补齐)'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务能力组:组开关替代逐动作勾选;联动项为组内受控项,只展示来源。 */
|
||||||
|
export function CapabilityGroupEditor({ groups, values, labels, disabled, onToggle }: {
|
||||||
|
groups: CapabilityGroup[]
|
||||||
|
values: Record<string, RolePermissionValue>
|
||||||
|
labels: Record<string, string>
|
||||||
|
disabled: boolean
|
||||||
|
onToggle: (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => void
|
||||||
|
}) {
|
||||||
|
return <div className="rp-groups">{groups.map(group => {
|
||||||
|
const state = capabilityGroupState(values, group)
|
||||||
|
const createOn = state.allowed !== 'off' || state.capAllowed !== 'off'
|
||||||
|
return <section key={group.id} className="settings-group" aria-label={`能力组 ${group.label}`}>
|
||||||
|
<div className="ad-row">
|
||||||
|
<b title={`内部动作 ${group.actions.join('、')}`}>{group.label}</b>
|
||||||
|
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 默认授予`} checked={state.allowed === 'on'} disabled={disabled}
|
||||||
|
onChange={event => onToggle(group, 'allowed', event.target.checked)} />默认授予{state.allowed === 'mixed' ? '(部分)' : ''}</label>
|
||||||
|
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 角色上限`} checked={state.capAllowed === 'on'} disabled={disabled}
|
||||||
|
onChange={event => onToggle(group, 'capAllowed', event.target.checked)} />角色上限{state.capAllowed === 'mixed' ? '(部分)' : ''}</label>
|
||||||
|
</div>
|
||||||
|
{group.description && <p className="ad-hint">{group.description}</p>}
|
||||||
|
{group.linked.length > 0 && <ul className="rp-diff" aria-label={`${group.label} 联动项`}>{group.linked.map(id =>
|
||||||
|
<li key={id}>{labels[id] ?? id}:{linkedStatus(values, id, createOn)}</li>)}</ul>}
|
||||||
|
</section>
|
||||||
|
})}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更预览:按业务组列出前后差异、联动补齐项、影响来源与受影响人数。 */
|
||||||
|
export function CapabilityChangePreview({ changes, breaks, labels, roleName, bindingCount }: {
|
||||||
|
changes: CapabilityGroupChange[]
|
||||||
|
breaks: string[]
|
||||||
|
labels: Record<string, string>
|
||||||
|
roleName: string
|
||||||
|
bindingCount: number
|
||||||
|
}) {
|
||||||
|
if (changes.length === 0 && breaks.length === 0) return <p className="ad-hint">没有权限变化</p>
|
||||||
|
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
|
||||||
|
return <div>
|
||||||
|
<p className="ad-hint" role="note">影响来源:职位角色「{roleName}」 · 影响 {bindingCount} 名绑定用户</p>
|
||||||
|
{breaks.length > 0 && <p className="ad-hint" role="note">关联待补齐:{names(breaks)}——保存时随「创建项目」一并补齐上限</p>}
|
||||||
|
<ul className="rp-diff">{changes.map(change => <li key={change.group.id}>
|
||||||
|
{change.group.label}:默认 {LEVEL_TEXT[change.before.allowed]} → {LEVEL_TEXT[change.after.allowed]},上限 {LEVEL_TEXT[change.before.capAllowed]} → {LEVEL_TEXT[change.after.capAllowed]}
|
||||||
|
{change.linkedAdded.length > 0 && <>;联动补齐 {names(change.linkedAdded)}(仅本人创建的项目)</>}
|
||||||
|
</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rolePermissionSaveError(error: unknown): string {
|
||||||
|
const raw = String(error)
|
||||||
|
if (/\b(400|403|422)\b/.test(raw) && /(link|conflict|关联|冲突|cap)/i.test(raw)) {
|
||||||
|
return `关联能力冲突,服务端已整笔拒绝,未保存任何修改;本地草稿已保留,请调整冲突项后重试(${raw.replace(/^Error:\s*/, '')})`
|
||||||
|
}
|
||||||
|
return `${raw.replace(/^Error:\s*/, '')};本地草稿已保留`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RolePermissionsPanel() {
|
||||||
|
const me = useStore(state => state.me)
|
||||||
|
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
|
||||||
|
const canManageRoles = allowed('system.roles.manage')
|
||||||
|
const canViewPermissions = allowed('system.permissions.view')
|
||||||
|
const canManagePermissions = canViewPermissions && allowed('system.permissions.manage')
|
||||||
|
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
|
||||||
|
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
||||||
|
const [selectedId, setSelectedId] = useState('')
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
|
||||||
|
const [confirmDisable, setConfirmDisable] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||||
|
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||||
|
const [actionFilter, setActionFilter] = useState('')
|
||||||
|
const [roleCursor, setRoleCursor] = useState('')
|
||||||
|
const [roleTrail, setRoleTrail] = useState<string[]>([])
|
||||||
|
const [nextRoleCursor, setNextRoleCursor] = useState('')
|
||||||
|
const [view, setView] = useState<'single' | 'matrix'>('single')
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newDescription, setNewDescription] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [roleLoading, setRoleLoading] = useState(false)
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const requestSequence = useRef(0)
|
||||||
|
const selectedIdRef = useRef('')
|
||||||
|
const savePermsRef = useRef<HTMLButtonElement>(null)
|
||||||
|
selectedIdRef.current = selectedId
|
||||||
|
|
||||||
|
const selected = roles?.find(role => role.roleId === selectedId) ?? null
|
||||||
|
const draft = selected ? drafts[selected.roleId] ?? null : null
|
||||||
|
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
|
||||||
|
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
|
||||||
|
const filtered = roles ?? []
|
||||||
|
const capabilityGroups = useMemo(() => buildCapabilityGroups(catalog.filter(item => !item.protected)), [catalog])
|
||||||
|
const labels = useMemo(() => Object.fromEntries(catalog.map(item => [item.actionId, item.label])), [catalog])
|
||||||
|
const changes = draft ? capabilityGroupChanges(draft, capabilityGroups) : []
|
||||||
|
const breaks = draft ? linkedChainBreaks(draft.values) : []
|
||||||
|
|
||||||
|
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
|
||||||
|
filters = { query, mode, status, action: actionFilter }) => {
|
||||||
|
const sequence = ++requestSequence.current
|
||||||
|
setRoleLoading(true)
|
||||||
|
setErr('')
|
||||||
|
const [roleResult, catalogResult] = await Promise.allSettled([
|
||||||
|
api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status,
|
||||||
|
action: filters.action || undefined, cursor: pageCursor, limit: 50 }),
|
||||||
|
canViewPermissions && (refreshCatalog || catalog.length === 0) ? api.permissionCatalog() : Promise.resolve(catalog),
|
||||||
|
])
|
||||||
|
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
|
||||||
|
setRoleLoading(false)
|
||||||
|
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
|
||||||
|
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
|
||||||
|
if (roleResult.status === 'rejected') {
|
||||||
|
setErr(String(roleResult.reason))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rolePage = roleResult.value
|
||||||
|
setRoles(rolePage.roles ?? [])
|
||||||
|
setNextRoleCursor(rolePage.nextCursor ?? '')
|
||||||
|
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
|
||||||
|
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
|
||||||
|
else setSelectedId('')
|
||||||
|
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
|
||||||
|
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
|
||||||
|
const nextSequence = sequence ?? ++requestSequence.current
|
||||||
|
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
|
||||||
|
setSelectedId(role.roleId)
|
||||||
|
setName(role.name)
|
||||||
|
setDescription(role.description)
|
||||||
|
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
|
||||||
|
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
|
||||||
|
setDrafts(prev => prev[role.roleId] ? prev
|
||||||
|
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
|
||||||
|
setNotice('')
|
||||||
|
setErr('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
|
||||||
|
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
|
||||||
|
roleDraftLeaveGuard.current = () => window.confirm(message)
|
||||||
|
return () => { roleDraftLeaveGuard.current = null }
|
||||||
|
}, [dirty, selected?.roleId, selected?.name])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return
|
||||||
|
const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
|
||||||
|
window.addEventListener('beforeunload', handler)
|
||||||
|
return () => window.removeEventListener('beforeunload', handler)
|
||||||
|
}, [dirty])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNextRoleCursor(''); setRoleLoading(true)
|
||||||
|
const timer = window.setTimeout(() => { void load(roleCursor) }, 180)
|
||||||
|
return () => { window.clearTimeout(timer); requestSequence.current++ }
|
||||||
|
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
if (!newName.trim()) return
|
||||||
|
requestSequence.current++; setRoleLoading(false)
|
||||||
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
try {
|
||||||
|
const role = await api.createRole(newName.trim(), newDescription.trim())
|
||||||
|
setNewName(''); setNewDescription('')
|
||||||
|
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
|
||||||
|
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
|
||||||
|
selectRole(role, catalog, ++requestSequence.current)
|
||||||
|
setNotice(`已创建角色 ${role.name}`)
|
||||||
|
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
|
||||||
|
setNotice(`已创建角色 ${role.name}`)
|
||||||
|
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMetadata = async () => {
|
||||||
|
if (!selected) return
|
||||||
|
const roleId = selected.roleId
|
||||||
|
const operationSequence = ++requestSequence.current
|
||||||
|
setRoleLoading(false)
|
||||||
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
try {
|
||||||
|
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
|
||||||
|
await load(); setNotice('角色资料已保存')
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error).includes('409')) {
|
||||||
|
try {
|
||||||
|
const fresh = await api.getRole(roleId)
|
||||||
|
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||||||
|
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
|
||||||
|
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||||||
|
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
|
||||||
|
} catch (refreshError) { setErr(String(refreshError)) }
|
||||||
|
} else setErr(String(error))
|
||||||
|
}
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const setEnabled = async () => {
|
||||||
|
if (!selected) return
|
||||||
|
const roleId = selected.roleId
|
||||||
|
const operationSequence = ++requestSequence.current
|
||||||
|
setRoleLoading(false)
|
||||||
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
try {
|
||||||
|
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
|
||||||
|
await load(); setNotice(selected.enabled ? '角色已停用,现有绑定不再授予权限' : '角色已启用')
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error).includes('409')) {
|
||||||
|
try {
|
||||||
|
const fresh = await api.getRole(roleId)
|
||||||
|
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||||||
|
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
|
||||||
|
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||||||
|
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
|
||||||
|
} catch (refreshError) { setErr(String(refreshError)) }
|
||||||
|
} else setErr(String(error))
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rebasePermissionConflict = async (roleId: string, currentDraft: RolePermissionDraft, operationSequence: number) => {
|
||||||
|
const fresh = await api.getRole(roleId)
|
||||||
|
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||||||
|
setRoles(current => current?.some(role => role.roleId === roleId)
|
||||||
|
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||||||
|
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
|
||||||
|
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
|
||||||
|
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePermissions = async () => {
|
||||||
|
if (!selected || !draft) return
|
||||||
|
const roleId = selected.roleId
|
||||||
|
const operationSequence = ++requestSequence.current
|
||||||
|
setRoleLoading(false)
|
||||||
|
// 关联项不能以断链状态保存:提交前与服务端同规则补齐创建能力的联动上限。
|
||||||
|
const prepared = ensureLinkedCapabilities(draft)
|
||||||
|
const invalid = validateRolePermissionDraft(prepared)
|
||||||
|
if (invalid) { setErr(`${labels[invalid.actionId] ?? invalid.actionId} 的默认权限不能超过角色上限`); return }
|
||||||
|
const full = Object.entries(prepared.values).map(([actionId, value]) => ({ actionId, ...value }))
|
||||||
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
try {
|
||||||
|
const receipt = await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
|
||||||
|
setDrafts(current => {
|
||||||
|
if (!(roleId in current)) return current
|
||||||
|
const next = { ...current }; delete next[roleId]; return next
|
||||||
|
})
|
||||||
|
await load(roleCursor, true, selected.roleId)
|
||||||
|
const expanded = receipt?.linkedExpansion ?? []
|
||||||
|
setNotice(expanded.length ? `角色权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '角色权限已保存')
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error).includes('409')) {
|
||||||
|
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
|
||||||
|
} else setErr(rolePermissionSaveError(error))
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setBusy(false)
|
||||||
|
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (savePermsRef.current && !savePermsRef.current.disabled && document.activeElement === document.body) savePermsRef.current.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editGroup = (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => {
|
||||||
|
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: setCapabilityGroup(prev[selectedId], group, field, checked) } : prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 矩阵按业务组汇总;只有选中角色可编辑组开关,其余角色只读摘要。
|
||||||
|
const matrix = () => <div className="rp-matrix-wrap">
|
||||||
|
<table className="ad-table rp-matrix">
|
||||||
|
<thead><tr><th>角色</th>{capabilityGroups.map(group => <th key={group.id} title={group.description}>{group.label}</th>)}</tr></thead>
|
||||||
|
<tbody>{filtered.map(role => {
|
||||||
|
const values = role.roleId === selectedId && draft ? draft.values : roleValues(role, catalog)
|
||||||
|
const editable = role.roleId === selectedId && !!draft && canManagePermissions
|
||||||
|
return <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
|
||||||
|
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
|
||||||
|
{capabilityGroups.map(group => {
|
||||||
|
const state = capabilityGroupState(values, group)
|
||||||
|
return <td key={group.id}>{editable ? <>
|
||||||
|
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 默认授予`} checked={state.allowed === 'on'}
|
||||||
|
disabled={busy || roleLoading} onChange={event => editGroup(group, 'allowed', event.target.checked)} />默认</label>
|
||||||
|
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 角色上限`} checked={state.capAllowed === 'on'}
|
||||||
|
disabled={busy || roleLoading} onChange={event => editGroup(group, 'capAllowed', event.target.checked)} />上限</label>
|
||||||
|
</> : <span className="ad-dim">默认{LEVEL_TEXT[state.allowed]} · 上限{LEVEL_TEXT[state.capAllowed]}</span>}</td>
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
})}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
return <div className="rp-panel">
|
||||||
|
{canManageRoles && <div className="ad-create">
|
||||||
|
<div className="ad-row">
|
||||||
|
<input className="lg-input" aria-label="新角色名称" placeholder="角色名称" value={newName} onChange={event => setNewName(event.target.value)} />
|
||||||
|
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={newDescription} onChange={event => setNewDescription(event.target.value)} />
|
||||||
|
<button className="lg-submit ad-create-btn" disabled={busy || roleLoading || !newName.trim()} onClick={create}>创建角色</button>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
<Collapsible id="role-permission-filters" trigger="搜索与过滤">
|
||||||
|
<div className="ad-row rp-filters">
|
||||||
|
<input className="lg-input" aria-label="搜索角色" placeholder="角色名称、描述或 ID" value={query} disabled={busy} onChange={event => { setQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
|
||||||
|
<select className="lg-input" aria-label="角色匹配方式" value={mode} disabled={busy} onChange={event => { setMode(event.target.value as typeof mode); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||||
|
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
|
||||||
|
</select>
|
||||||
|
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||||
|
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
|
||||||
|
</select>
|
||||||
|
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
|
||||||
|
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||||
|
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
|
||||||
|
</select>}
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
<div className="ad-row rp-view-toggle">
|
||||||
|
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
|
||||||
|
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
|
||||||
|
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||||
|
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
||||||
|
</div>
|
||||||
|
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
|
||||||
|
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||||
|
{view === 'matrix' ? matrix() : <div className="rp-single">
|
||||||
|
<aside className="rp-role-list">
|
||||||
|
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
|
||||||
|
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
|
||||||
|
</button>)}
|
||||||
|
</aside>
|
||||||
|
<section className="rp-editor">
|
||||||
|
{selected && draft ? <>
|
||||||
|
<div className="ad-row">
|
||||||
|
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
|
||||||
|
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
|
||||||
|
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
|
||||||
|
{canManageRoles && (selected.enabled
|
||||||
|
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
|
||||||
|
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
|
||||||
|
</div>
|
||||||
|
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
|
||||||
|
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
|
||||||
|
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
|
||||||
|
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
|
||||||
|
</div>}
|
||||||
|
{canViewPermissions ? <>
|
||||||
|
<CapabilityGroupEditor groups={capabilityGroups} values={draft.values} labels={labels}
|
||||||
|
disabled={busy || roleLoading || !canManagePermissions} onToggle={editGroup} />
|
||||||
|
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
|
||||||
|
<CapabilityChangePreview changes={changes} breaks={breaks} labels={labels} roleName={selected.name} bindingCount={selected.bindingCount} />
|
||||||
|
</Collapsible>
|
||||||
|
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || (changes.length === 0 && breaks.length === 0) || allEditableIds.length === 0} onClick={savePermissions}>保存权限</button>}
|
||||||
|
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
|
||||||
|
</> : <p className="ad-hint">请选择角色</p>}
|
||||||
|
</section>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@ import type { ThemeChoice } from '../theme'
|
|||||||
import type { SettingDesc } from '../contracts'
|
import type { SettingDesc } from '../contracts'
|
||||||
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
|
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
|
||||||
import { BorrowPanels } from './BorrowPanels'
|
import { BorrowPanels } from './BorrowPanels'
|
||||||
|
import { MyAccountSettings } from './MyAccountSettings'
|
||||||
|
|
||||||
type Section = 'settings'
|
type Section = 'settings' | 'account'
|
||||||
|
|
||||||
// 分组显示名(项目设置面板共用)。
|
// 分组显示名(项目设置面板共用)。
|
||||||
export const groupNames: Record<string, string> = {
|
export const groupNames: Record<string, string> = {
|
||||||
@@ -19,7 +20,7 @@ export const groupNames: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsModal() {
|
export function SettingsModal() {
|
||||||
const [section] = useState<Section>('settings')
|
const [section, setSection] = useState<Section>('settings')
|
||||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||||
const [, setUser] = useState<Record<string, any>>({})
|
const [, setUser] = useState<Record<string, any>>({})
|
||||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||||
@@ -54,10 +55,11 @@ export function SettingsModal() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsShell
|
<SettingsShell
|
||||||
title="设置" active={section} onSelect={() => undefined}
|
title="设置" active={section} onSelect={key => setSection(key as Section)}
|
||||||
onClose={() => store.openSettings(false)}
|
onClose={() => store.openSettings(false)}
|
||||||
sections={[
|
sections={[
|
||||||
{ key: 'settings', label: '常规', icon: 'settings' },
|
{ key: 'settings', label: '常规', icon: 'settings' },
|
||||||
|
{ key: 'account', label: '账号', icon: 'user' },
|
||||||
]}
|
]}
|
||||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
|
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
|
||||||
actions={section === 'settings' && schema.length > 0 ? (
|
actions={section === 'settings' && schema.length > 0 ? (
|
||||||
@@ -108,10 +110,21 @@ export function SettingsModal() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{section === 'settings' && <BorrowPanels />}
|
{section === 'settings' && <BorrowPanels />}
|
||||||
|
{section === 'account' && <MyAccountSettings />}
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 输入框语义(2026-10-12 增量 3):仅秘密字段用密码框;普通文本用 text——
|
||||||
|
// 无条件 type="password" 会把整排设置项当凭据表单(浏览器「password 不在 form 中/
|
||||||
|
// 缺少 username」警告来源之一),也把普通文本无谓遮蔽。
|
||||||
|
export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } {
|
||||||
|
const isSecret = desc.secret || desc.type === 'secret'
|
||||||
|
return isSecret
|
||||||
|
? { type: 'password', autoComplete: 'new-password' }
|
||||||
|
: { type: 'text', autoComplete: 'off' }
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
|
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
|
||||||
if (desc.type === 'boolean') {
|
if (desc.type === 'boolean') {
|
||||||
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
|
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
|
||||||
@@ -122,7 +135,8 @@ export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; val
|
|||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
const isSecret = desc.secret || desc.type === 'secret'
|
const isSecret = desc.secret || desc.type === 'secret'
|
||||||
return <input type="password" autoComplete="new-password" style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
|
const attrs = settingInputAttrs(desc)
|
||||||
|
return <input type={attrs.type} autoComplete={attrs.autoComplete} style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
|
||||||
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
|
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
|
||||||
onChange={e => onChange(e.target.value)} />
|
onChange={e => onChange(e.target.value)} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { TaskSidebar } from './TaskSidebar'
|
||||||
|
|
||||||
|
const mocked = vi.hoisted(() => ({ state: {} as Record<string, unknown> }))
|
||||||
|
|
||||||
|
vi.mock('../store', () => ({
|
||||||
|
useStore: (select: (value: Record<string, unknown>) => unknown) => select(mocked.state),
|
||||||
|
store: { openInbox: vi.fn(), openFiles: vi.fn(), focusPending: vi.fn(), setUI: vi.fn(), toggleLeft: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const member = { owner: 'bob', name: 'Bob', auth: true, systemActions: [],
|
||||||
|
linkedCapabilities: { projectCreate: false, linkedActions: [], authorityEpoch: 1 } }
|
||||||
|
const creator = { ...member, linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } }
|
||||||
|
|
||||||
|
function render(patch: Record<string, unknown>) {
|
||||||
|
mocked.state = {
|
||||||
|
projects: [], projectsLoaded: true, projectSync: { status: 'ok', failures: 0 }, tasks: [], me: member,
|
||||||
|
adminSections: [], artifacts: [], cards: [], projectCards: [], serverWorkspaces: [],
|
||||||
|
rdmAuditAvailable: false, rdmCockpitAvailable: false, currentConvId: null, currentProjectId: null,
|
||||||
|
ui: { theme: 'light', showArchived: false },
|
||||||
|
...patch,
|
||||||
|
}
|
||||||
|
return renderToStaticMarkup(<TaskSidebar />)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => { mocked.state = {} })
|
||||||
|
|
||||||
|
describe('EmptyListNoCreate sidebar', () => {
|
||||||
|
it('shows a waiting-for-invite state without inviting a same-name project for a member without creation', () => {
|
||||||
|
const html = render({})
|
||||||
|
expect(html).toContain('尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。')
|
||||||
|
expect(html).not.toContain('新建项目')
|
||||||
|
expect(html).toMatch(/<button class="tsb-newtask-main" disabled=""/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a loading state instead of an empty state before the list arrives', () => {
|
||||||
|
const html = render({ projectsLoaded: false })
|
||||||
|
expect(html).toContain('正在加载项目列表…')
|
||||||
|
expect(html).not.toContain('等待项目负责人邀请')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('points a creator to explicit project creation', () => {
|
||||||
|
expect(render({ me: creator })).toContain('还没有项目:从「新建项目」开始。')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AutoDiscover sidebar status', () => {
|
||||||
|
it('surfaces a degraded or offline project sync instead of silently showing stale projects', () => {
|
||||||
|
expect(render({ projectSync: { status: 'degraded', failures: 2 } })).toContain('项目列表同步异常,正在自动重试')
|
||||||
|
expect(render({ projectSync: { status: 'offline', failures: 0 } })).toContain('网络已断开,恢复后自动同步项目列表')
|
||||||
|
expect(render({})).not.toContain('同步异常')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps conversation creation available once a discovered project is current', () => {
|
||||||
|
const html = render({ projects: [{ id: 'host-b', name: '项目P', owner: 'carol' }], currentProjectId: 'host-b' })
|
||||||
|
expect(html).toMatch(/<button class="tsb-newtask-main" title="在当前项目新建对话/)
|
||||||
|
expect(html).toContain('从「新建对话」开始')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a project-less sidebar when the current discovery has no project', () => {
|
||||||
|
const html = render({ projects: [], currentProjectId: null })
|
||||||
|
expect(html).toContain('尚无项目')
|
||||||
|
expect(html).not.toContain('同步异常')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render a sync notice when the listing succeeded', () => {
|
||||||
|
expect(render({ projectSync: { status: 'ok', failures: 0 } })).not.toContain('同步异常')
|
||||||
|
expect(render({ projectSync: { status: 'idle', failures: 0 } })).not.toContain('同步异常')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,11 +3,12 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
import { auth } from '../auth'
|
import { auth } from '../auth'
|
||||||
import { api } from '../api'
|
import { api, type ConversationView } from '../api'
|
||||||
import { Icon } from '@ui'
|
import { Icon } from '@ui'
|
||||||
import type { Conversation } from '../contracts'
|
|
||||||
import { relativeTime, filterTasks } from '../tasklist'
|
import { relativeTime, filterTasks } from '../tasklist'
|
||||||
import { projectStage } from '../projectmeta'
|
import {
|
||||||
|
canCreateProject, emptyProjectsNotice, projectCapabilitySummary, projectStage, projectStateLabel, projectWorkError,
|
||||||
|
} from '../projectmeta'
|
||||||
import { buildWorkspaceEntries } from '../workspaces'
|
import { buildWorkspaceEntries } from '../workspaces'
|
||||||
|
|
||||||
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
|
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
|
||||||
@@ -21,7 +22,7 @@ function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClo
|
|||||||
if (x + el.offsetWidth > window.innerWidth - 8) x = window.innerWidth - el.offsetWidth - 8
|
if (x + el.offsetWidth > window.innerWidth - 8) x = window.innerWidth - el.offsetWidth - 8
|
||||||
if (y + el.offsetHeight > window.innerHeight - 8) y = r.top - el.offsetHeight - 6
|
if (y + el.offsetHeight > window.innerHeight - 8) y = r.top - el.offsetHeight - 6
|
||||||
el.style.left = `${Math.max(8, x)}px`
|
el.style.left = `${Math.max(8, x)}px`
|
||||||
el.style.top = `${y}px`
|
el.style.top = `${Math.max(8, Math.min(y, window.innerHeight - el.offsetHeight - 8))}px`
|
||||||
const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() }
|
const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() }
|
||||||
window.addEventListener('mousedown', h)
|
window.addEventListener('mousedown', h)
|
||||||
return () => window.removeEventListener('mousedown', h)
|
return () => window.removeEventListener('mousedown', h)
|
||||||
@@ -36,7 +37,16 @@ function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; da
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskSidebar() {
|
interface TaskSidebarProps {
|
||||||
|
onCollapse?: () => void
|
||||||
|
onConversationOpen?: () => void
|
||||||
|
onFilesOpen?: () => void
|
||||||
|
onWorkOpen?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||||
|
onFilesOpen = store.openFiles, onWorkOpen = store.focusPending,
|
||||||
|
}: TaskSidebarProps = {}) {
|
||||||
const projects = useStore(s => s.projects)
|
const projects = useStore(s => s.projects)
|
||||||
const tasks = useStore(s => s.tasks)
|
const tasks = useStore(s => s.tasks)
|
||||||
const me = useStore(s => s.me)
|
const me = useStore(s => s.me)
|
||||||
@@ -44,9 +54,15 @@ export function TaskSidebar() {
|
|||||||
const artifacts = useStore(s => s.artifacts)
|
const artifacts = useStore(s => s.artifacts)
|
||||||
const currentConvId = useStore(s => s.currentConvId)
|
const currentConvId = useStore(s => s.currentConvId)
|
||||||
const currentProjectId = useStore(s => s.currentProjectId)
|
const currentProjectId = useStore(s => s.currentProjectId)
|
||||||
|
const scratchMode = useStore(s => !!s.scratchMode)
|
||||||
const showArchived = useStore(s => s.ui.showArchived)
|
const showArchived = useStore(s => s.ui.showArchived)
|
||||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||||
|
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
|
||||||
|
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
|
||||||
|
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
|
||||||
const theme = useStore(s => s.ui.theme)
|
const theme = useStore(s => s.ui.theme)
|
||||||
|
const projectsLoaded = useStore(s => !!s.projectsLoaded)
|
||||||
|
const syncStatus = useStore(s => s.projectSync?.status ?? 'idle')
|
||||||
const pendingBadge = useStore(s =>
|
const pendingBadge = useStore(s =>
|
||||||
s.cards.filter(c => c.status === 'action_required').length +
|
s.cards.filter(c => c.status === 'action_required').length +
|
||||||
s.projectCards.filter(c => c.status === 'action_required').length)
|
s.projectCards.filter(c => c.status === 'action_required').length)
|
||||||
@@ -56,36 +72,39 @@ export function TaskSidebar() {
|
|||||||
const [creatingProject, setCreatingProject] = useState(false)
|
const [creatingProject, setCreatingProject] = useState(false)
|
||||||
const [newProjectName, setNewProjectName] = useState('')
|
const [newProjectName, setNewProjectName] = useState('')
|
||||||
|
|
||||||
// ⌘N/Ctrl+N 新建任务(P6:创建入口唯一 + 快捷键)。
|
const createConversation = async (projectId?: string) => {
|
||||||
useEffect(() => {
|
try {
|
||||||
const h = (e: KeyboardEvent) => {
|
if (projectId && scratchMode) await store.openProject(projectId)
|
||||||
const k = e.key.toLowerCase()
|
await store.createTask(projectId)
|
||||||
if ((e.metaKey || e.ctrlKey) && k === 'n') { e.preventDefault(); store.createTask() }
|
onConversationOpen?.()
|
||||||
if ((e.metaKey || e.ctrlKey) && k === 'b') { e.preventDefault(); store.toggleLeft() }
|
} catch { /* store owns the actionable failure toast */ }
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', h, true)
|
|
||||||
return () => window.removeEventListener('keydown', h, true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const tools = buildWorkspaceEntries(serverWorkspaces)
|
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable, rdmCockpit: rdmCockpitAvailable })
|
||||||
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
|
const scratchTasks = tasks.filter(t => t.scratch)
|
||||||
|
const visible = filterTasks(scratchMode ? scratchTasks : tasks.filter(t => t.projectId === currentProjectId && !t.scratch), q)
|
||||||
const pinned = visible.filter(t => t.pinned)
|
const pinned = visible.filter(t => t.pinned)
|
||||||
const rest = visible.filter(t => !t.pinned)
|
const rest = visible.filter(t => !t.pinned)
|
||||||
const currentProject = projects.find(p => p.id === currentProjectId)
|
const currentProject = projects.find(p => p.id === currentProjectId)
|
||||||
const stage = projectStage(currentProject?.settings)
|
const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings)
|
||||||
|
const workError = currentProject ? projectWorkError(currentProject) : null
|
||||||
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
|
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
|
||||||
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
|
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
|
||||||
|
const canCreate = canCreateProject(me)
|
||||||
|
const noProjects = projects.length === 0
|
||||||
|
const syncNotice = syncStatus === 'degraded' ? '项目列表同步异常,正在自动重试'
|
||||||
|
: syncStatus === 'offline' ? '网络已断开,恢复后自动同步项目列表' : null
|
||||||
|
|
||||||
const rowActions = (t: Conversation) => (
|
const rowActions = (t: ConversationView) => (
|
||||||
<span className="tsb-row-acts">
|
<span className="tsb-row-acts">
|
||||||
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
|
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
|
||||||
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
|
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
|
||||||
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
|
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
|
||||||
</button>
|
</button>
|
||||||
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
|
{!t.scratch && <button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
|
||||||
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
|
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
|
||||||
<Icon name="shield" size={13} />
|
<Icon name="shield" size={13} />
|
||||||
</button>
|
</button>}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,16 +114,21 @@ export function TaskSidebar() {
|
|||||||
?? tasks.find(t => t.projectId === pid && !t.archived)
|
?? tasks.find(t => t.projectId === pid && !t.archived)
|
||||||
if (next) store.openTask(next.id)
|
if (next) store.openTask(next.id)
|
||||||
else store.openProject(pid)
|
else store.openProject(pid)
|
||||||
|
onConversationOpen?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskRow = (t: Conversation) => (
|
const taskRow = (t: ConversationView) => (
|
||||||
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
|
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
|
||||||
onClick={() => store.openTask(t.id)}
|
|
||||||
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
|
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
|
||||||
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
|
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
|
||||||
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
<Icon name="message" size={14} />
|
||||||
|
<span className="tsb-task-t" title={t.title}>{t.title || '未命名对话'}</span>
|
||||||
|
{t.crossPrivate && !t.scratch && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
||||||
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
||||||
|
</button>
|
||||||
{rowActions(t)}
|
{rowActions(t)}
|
||||||
|
<button className="tsb-row-menu" aria-label={`「${t.title || '未命名对话'}」更多操作`} title="更多对话操作"
|
||||||
|
onClick={e => setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget })}><Icon name="more" size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,32 +138,33 @@ export function TaskSidebar() {
|
|||||||
<div className="tsb-brand">
|
<div className="tsb-brand">
|
||||||
<button className="tsb-projrow" title="切换项目"
|
<button className="tsb-projrow" title="切换项目"
|
||||||
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
|
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
|
||||||
<Icon name="folder" size={15} />
|
<Icon name={scratchMode ? 'message' : 'folder'} size={15} />
|
||||||
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
|
<span className="tsb-projrow-name">{scratchMode ? '无项目对话' : currentProject?.name ?? '选择项目'}</span>
|
||||||
{stage && <span className="tsb-projrow-stage">{stage}</span>}
|
{stage && !scratchMode && <span className="tsb-projrow-stage">{stage}</span>}
|
||||||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||||
</button>
|
</button>
|
||||||
<span style={{ flex: 1 }} />
|
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={onCollapse}><Icon name="panel-left" /></button>
|
||||||
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 单一主创建入口(P6):新建任务 + 次级菜单(新建项目/指定项目新建) */}
|
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
|
||||||
<div className="tsb-actions">
|
<div className="tsb-actions">
|
||||||
<div className="tsb-newtask">
|
<div className="tsb-newtask">
|
||||||
<button className="tsb-newtask-main" title="在当前项目新建任务(⌘N)" onClick={() => store.createTask()}>
|
<button className="tsb-newtask-main" disabled={noProjects && !scratchMode}
|
||||||
<Icon name="plus" size={15} />新建任务{currentProject ? <span className="tsb-newtask-proj">{currentProject.name}</span> : null}
|
title={scratchMode ? '新建无项目对话(⌘N)' : noProjects ? emptyProjectsNotice(projectsLoaded, canCreate) : '在当前项目新建对话(⌘N)'}
|
||||||
|
onClick={() => createConversation()}>
|
||||||
|
<Icon name="plus" size={15} />新建对话
|
||||||
</button>
|
</button>
|
||||||
<button className="tsb-newtask-caret" title="更多创建选项"
|
<button className="tsb-newtask-caret" title="更多创建选项"
|
||||||
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: el }) }}><Icon name="chevron-down" size={14} /></button>
|
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: el }) }}><Icon name="chevron-down" size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="tsb-search">
|
<div className="tsb-search">
|
||||||
<Icon name="search" size={14} />
|
<Icon name="search" size={14} />
|
||||||
<input placeholder="搜索任务…" value={q} onChange={e => setQ(e.target.value)} />
|
<input aria-label={scratchMode ? '搜索无项目对话' : '搜索本项目会话'} placeholder="搜索对话…" value={q} onChange={e => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tsb-orgrow">
|
<div className="tsb-orgrow">
|
||||||
<span className="tsb-seclabel" style={{ padding: 0 }}>本项目会话</span>
|
<span className="tsb-seclabel" style={{ padding: 0 }}>{scratchMode ? '无项目对话' : '本项目会话'}</span>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
|
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
|
||||||
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
|
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
|
||||||
@@ -147,14 +172,17 @@ export function TaskSidebar() {
|
|||||||
|
|
||||||
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
|
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
|
||||||
<div className="tsb-scroll">
|
<div className="tsb-scroll">
|
||||||
{creatingProject && (
|
{syncNotice && <div className="tsb-tool-note" role="status">{syncNotice}</div>}
|
||||||
|
{canCreate && creatingProject && (
|
||||||
<div className="tsb-newproject">
|
<div className="tsb-newproject">
|
||||||
<input className="tsb-search" autoFocus value={newProjectName}
|
<input className="tsb-search" autoFocus value={newProjectName}
|
||||||
onChange={e => setNewProjectName(e.target.value)}
|
onChange={e => setNewProjectName(e.target.value)}
|
||||||
onKeyDown={async e => {
|
onKeyDown={async e => {
|
||||||
if (e.key === 'Enter' && newProjectName.trim()) {
|
if (e.key === 'Enter' && newProjectName.trim()) {
|
||||||
|
try {
|
||||||
await store.createProject(newProjectName.trim())
|
await store.createProject(newProjectName.trim())
|
||||||
setCreatingProject(false); setNewProjectName('')
|
setCreatingProject(false); setNewProjectName('')
|
||||||
|
} catch { /* keep the input available for a corrected name */ }
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') setCreatingProject(false)
|
if (e.key === 'Escape') setCreatingProject(false)
|
||||||
}}
|
}}
|
||||||
@@ -170,14 +198,16 @@ export function TaskSidebar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{rest.length === 0 && pinned.length === 0 && (
|
{rest.length === 0 && pinned.length === 0 && (
|
||||||
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
|
<div className="tsb-empty">{scratchMode ? '开始一个通用对话;无项目对话彼此独立,不会使用项目文档与知识。'
|
||||||
|
: noProjects ? emptyProjectsNotice(projectsLoaded, canCreate)
|
||||||
|
: workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
|
||||||
)}
|
)}
|
||||||
{rest.map(t => taskRow(t))}
|
{rest.map(t => taskRow(t))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部(方案:文件、工作区、账户沉底)。 */}
|
{/* 底部(方案:文件、工作区、账户沉底)。 */}
|
||||||
<div className="tsb-bottom">
|
<div className="tsb-bottom">
|
||||||
<button className="tsb-tool" title="文件搜索、预览、编辑与下载(第三栏「文件」入口)" onClick={() => store.openFiles()}>
|
<button className="tsb-tool" title="文件搜索、预览、编辑与下载" onClick={onFilesOpen}>
|
||||||
<span className="tsb-tool-icon"><Icon name="folder" size={14} /></span>
|
<span className="tsb-tool-icon"><Icon name="folder" size={14} /></span>
|
||||||
<span className="tsb-tool-name">文件</span>
|
<span className="tsb-tool-name">文件</span>
|
||||||
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
|
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
|
||||||
@@ -189,8 +219,7 @@ export function TaskSidebar() {
|
|||||||
<kbd>{entry.shortcut}</kbd>
|
<kbd>{entry.shortcut}</kbd>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{serverWorkspaces.length === 0 && <div className="tsb-tool-note">领域工具由系统级插件提供</div>}
|
{currentProject && !scratchMode && (
|
||||||
{currentProject && (
|
|
||||||
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
|
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
|
||||||
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
|
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
|
||||||
<span className="tsb-tool-name">项目设置</span>
|
<span className="tsb-tool-name">项目设置</span>
|
||||||
@@ -209,32 +238,44 @@ export function TaskSidebar() {
|
|||||||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||||
</button>
|
</button>
|
||||||
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" size={15} /></button>
|
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" size={15} /></button>
|
||||||
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.focusPending()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
|
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={onWorkOpen}><Icon name="inbox" size={15} />{pendingBadge}</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 弹出菜单们 */}
|
{/* 弹出菜单们 */}
|
||||||
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||||
|
<MenuItem icon="message" onClick={() => { void store.enterScratchMode(); onConversationOpen?.(); setMenu(null) }}>
|
||||||
|
无项目对话
|
||||||
|
<kbd>{scratchMode ? '当前 · ' : ''}{scratchTasks.filter(t => !t.archived).length} 会话</kbd>
|
||||||
|
</MenuItem>
|
||||||
{projects.map(p => {
|
{projects.map(p => {
|
||||||
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||||||
const pstage = projectStage(p.settings)
|
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
|
||||||
|
const mine = !!p.owner && p.owner === me?.owner
|
||||||
return (
|
return (
|
||||||
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
||||||
{p.name}
|
{p.name}
|
||||||
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
|
<kbd>{p.id === currentProjectId && !scratchMode ? '当前 · ' : pstage ? `${pstage} · ` : ''}{mine ? '我创建 · ' : ''}{count} 会话</kbd>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
|
{noProjects && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
|
||||||
|
{canCreate && <>
|
||||||
<div className="tsb-menu-div" />
|
<div className="tsb-menu-div" />
|
||||||
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||||
|
</>}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||||
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
{scratchMode
|
||||||
|
? <MenuItem icon="message" onClick={() => { createConversation(); setMenu(null) }}>新建无项目对话<kbd>⌘N</kbd></MenuItem>
|
||||||
|
: !noProjects && <MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>}
|
||||||
|
{canCreate && <>
|
||||||
<div className="tsb-menu-div" />
|
<div className="tsb-menu-div" />
|
||||||
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||||
|
</>}
|
||||||
|
{noProjects && !canCreate && !scratchMode && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
|
||||||
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
||||||
<MenuItem key={p.id} onClick={() => { store.createTask(p.id); setMenu(null) }}>在「{p.name}」新建任务</MenuItem>
|
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
@@ -242,6 +283,8 @@ export function TaskSidebar() {
|
|||||||
{rowTask && <>
|
{rowTask && <>
|
||||||
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
|
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
|
||||||
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
|
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
|
||||||
|
</>}
|
||||||
|
{rowTask && !rowTask.scratch && <>
|
||||||
<div className="tsb-menu-div" />
|
<div className="tsb-menu-div" />
|
||||||
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
|
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
|
||||||
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
|
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
|
||||||
@@ -256,17 +299,18 @@ export function TaskSidebar() {
|
|||||||
<span>
|
<span>
|
||||||
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
|
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
|
||||||
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
|
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
|
||||||
|
{me && <div className="tsb-acc-mail">{projectCapabilitySummary(me)}</div>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="tsb-menu-div" />
|
<div className="tsb-menu-div" />
|
||||||
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
|
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
|
||||||
<MenuItem icon="inbox" onClick={() => { store.focusPending(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
|
<MenuItem icon="inbox" onClick={() => { onWorkOpen(); setMenu(null) }}>待处理事项{pendingBadge > 0 ? `(${pendingBadge})` : ''}</MenuItem>
|
||||||
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
|
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
|
||||||
<MenuItem icon="moon" onClick={() => { store.setUI({ theme: 'dark' }); setMenu(null) }}>深色主题{theme === 'dark' ? ' · 当前' : ''}</MenuItem>
|
<MenuItem icon="moon" onClick={() => { store.setUI({ theme: 'dark' }); setMenu(null) }}>深色主题{theme === 'dark' ? ' · 当前' : ''}</MenuItem>
|
||||||
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
|
<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: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
|
||||||
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</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 && <>
|
{me?.auth && <>
|
||||||
<div className="tsb-menu-div" />
|
<div className="tsb-menu-div" />
|
||||||
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||||||
|
|||||||
+42
-10
@@ -1,20 +1,52 @@
|
|||||||
// 顶栏:产品标识与待处理铃铛。项目身份在左栏顶部切换器集中显示一次(方案),
|
// Project identity lives in context; the top bar keeps work/files reachable when panes close.
|
||||||
// 阶段徽章随切换器;本栏不再重复。
|
|
||||||
import { BrandMark, Icon, pendingCount } from '@ui'
|
import { BrandMark, Icon, pendingCount } from '@ui'
|
||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
|
import type { WorkbenchPane } from '../workbenchLayout'
|
||||||
|
|
||||||
export function TopBar() {
|
interface TopBarProps {
|
||||||
|
compact?: boolean
|
||||||
|
activePane?: WorkbenchPane
|
||||||
|
showReturn?: boolean
|
||||||
|
onContext?: () => void
|
||||||
|
onConversation?: () => void
|
||||||
|
onWork?: () => void
|
||||||
|
onFiles?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ compact = false, activePane = 'conversation', showReturn = false,
|
||||||
|
onContext = store.toggleLeft, onConversation,
|
||||||
|
onWork = () => { store.openInbox(); store.setUI({ inboxFilter: 'all', previewExpanded: false }) }, onFiles = store.openFiles,
|
||||||
|
}: TopBarProps = {}) {
|
||||||
const pending = useStore(s => pendingCount(s.cards) + pendingCount(s.projectCards))
|
const pending = useStore(s => pendingCount(s.cards) + pendingCount(s.projectCards))
|
||||||
|
const ui = useStore(s => s.ui)
|
||||||
|
const returnVisible = showReturn || compact && activePane !== 'conversation'
|
||||||
return (
|
return (
|
||||||
<header className="topbar">
|
<header className={`topbar companion-topbar${compact ? ' compact' : ''}`}>
|
||||||
<BrandMark size={22} />
|
<div className="topbar-identity"><BrandMark size={22} /><span className="topbar-name">工作助手</span></div>
|
||||||
<span className="topbar-name">Agent-System</span>
|
|
||||||
<span className="topbar-spacer" />
|
<span className="topbar-spacer" />
|
||||||
<button className="topbar-bell" title={pending > 0 ? `待处理 ${pending},打开工作入口` : '工作入口'}
|
{returnVisible && <button className="shell-return" aria-label="返回对话" onClick={onConversation}>
|
||||||
onClick={() => store.focusPending()}>
|
<Icon name="chevron-right" className="shell-back-icon" size={14} /><span>返回对话</span>
|
||||||
<Icon name="inbox" size={16} />
|
</button>}
|
||||||
{pending > 0 && <span className="rail-badge" data-testid="topbar-pending">{pending}</span>}
|
<nav className="shell-navigation" aria-label="工作台导航">
|
||||||
|
{compact && <>
|
||||||
|
<button className="shell-nav-button" aria-label="打开上下文" aria-pressed={activePane === 'context'} onClick={onContext}>
|
||||||
|
<Icon name="panel-left" size={15} /><span>上下文</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button className="shell-nav-button" aria-label="打开对话" aria-pressed={activePane === 'conversation'} onClick={onConversation}>
|
||||||
|
<Icon name="message" size={15} /><span>对话</span>
|
||||||
|
</button>
|
||||||
|
</>}
|
||||||
|
{(compact || ui.rightCollapsed) && <><button className="shell-nav-button" aria-label="打开工作"
|
||||||
|
aria-pressed={compact ? activePane === 'work' : !ui.rightCollapsed && ui.rightTab === 'work'}
|
||||||
|
title={pending > 0 ? `${pending} 项待处理` : '工作'} onClick={onWork}>
|
||||||
|
<Icon name="inbox" size={15} /><span>工作</span>
|
||||||
|
{pending > 0 && <span className="shell-pending" data-testid="topbar-pending">{pending > 99 ? '99+' : pending}</span>}
|
||||||
|
</button>
|
||||||
|
<button className="shell-nav-button" aria-label="打开文件"
|
||||||
|
aria-pressed={compact ? activePane === 'files' : !ui.rightCollapsed && ui.rightTab === 'files'} onClick={onFiles}>
|
||||||
|
<Icon name="folder" size={15} /><span>文件</span>
|
||||||
|
</button></>}
|
||||||
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { SafeMarkdown } from '@ui'
|
||||||
|
import type { StreamEvent } from '../contracts'
|
||||||
|
import { loadRunEvents } from '../runEvents'
|
||||||
|
import { store, useStore } from '../store'
|
||||||
|
import { runEventsToTimeline } from '../live'
|
||||||
|
import { mergeProcessTimeline } from '../messageparts'
|
||||||
|
import { ProcessTimeline } from './ProcessTimeline'
|
||||||
|
import { MessageTimestamp } from './MessageTimestamp'
|
||||||
|
|
||||||
|
/** The work pane and inline fallback share one selection. Only this detail host
|
||||||
|
* reads that run's authorized durable events; opening a record never adds chat. */
|
||||||
|
export function WorkExecutionRecord() {
|
||||||
|
const selected = useStore(s => s.workRecord)
|
||||||
|
const conversationId = useStore(s => s.currentConvId)
|
||||||
|
const messages = useStore(s => s.messages)
|
||||||
|
const run = useStore(s => s.runs.find(r => r.id === selected?.runId))
|
||||||
|
const [result, setResult] = useState<{ runId: string; events: StreamEvent[] } | null>(null)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected || selected.conversationId !== conversationId) return
|
||||||
|
const controller = new AbortController()
|
||||||
|
let active = true
|
||||||
|
setResult(null); setFailed(false)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const events = await loadRunEvents(selected.conversationId, selected.runId, controller.signal)
|
||||||
|
if (active) setResult({ runId: selected.runId, events })
|
||||||
|
} catch { if (active) setFailed(true) }
|
||||||
|
})()
|
||||||
|
return () => { active = false; controller.abort() }
|
||||||
|
}, [selected?.runId, selected?.conversationId, conversationId, run?.status, run?.endedAt])
|
||||||
|
const parts = useMemo(() => {
|
||||||
|
if (!selected || result?.runId !== selected.runId) return []
|
||||||
|
const saved = messages.filter(message => message.runId === selected.runId && message.role === 'assistant')
|
||||||
|
.flatMap(message => message.parts).map(part => ({ ...part, process: true }))
|
||||||
|
return mergeProcessTimeline(saved, runEventsToTimeline(result.events, selected.runId, true), [])
|
||||||
|
}, [messages, result, selected?.runId])
|
||||||
|
if (!selected || selected.conversationId !== conversationId) return null
|
||||||
|
return <section className="work-execution-record" aria-label={`${selected.title}的执行记录`}>
|
||||||
|
<div className="work-record-heading"><strong>{selected.title} · 执行记录</strong>
|
||||||
|
<button type="button" className="btn ghost" onClick={() => store.closeWorkRecord()}>返回工作</button></div>
|
||||||
|
<MessageTimestamp value={run?.createdAt} />
|
||||||
|
{failed ? <p role="status">完整记录暂不可用,请稍后重新打开</p>
|
||||||
|
: !result ? <p role="status">正在读取已保存的记录…</p>
|
||||||
|
: parts.length === 0 ? <p>这项工作没有已保存的过程输出</p>
|
||||||
|
: <ProcessTimeline parts={parts} live={false} idKey={`work-record:${selected.runId}`}
|
||||||
|
renderPart={part => <SafeMarkdown className="part-text" markdown={part.text ?? ''} breaks />} />}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -2,16 +2,19 @@
|
|||||||
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
|
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
|
||||||
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
|
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
|
||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
import type { Run } from '../contracts'
|
import type { Run, WorkCardAction } from '../contracts'
|
||||||
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
|
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
|
||||||
import { Icon, SurfaceView } from '@ui'
|
import { Icon, SurfaceView } from '@ui'
|
||||||
import { previewTabsKey } from '../previewtabs'
|
import { previewTabsKey } from '../previewtabs'
|
||||||
|
import { surfaceKey } from '../surfaceKey'
|
||||||
import { ArtifactsPanel } from './ArtifactsPanel'
|
import { ArtifactsPanel } from './ArtifactsPanel'
|
||||||
import { PreviewPane } from './PreviewPane'
|
import { PreviewPane } from './PreviewPane'
|
||||||
|
import { WorkExecutionRecord } from './WorkExecutionRecord'
|
||||||
|
|
||||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||||
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
|
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
|
||||||
export const docBridge: DocBridge = {
|
export const docBridge: DocBridge = {
|
||||||
|
onOpenTaskSource: (cardId, source) => store.openTaskSource(cardId, source),
|
||||||
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
|
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
|
||||||
onOpenDoc: id => {
|
onOpenDoc: id => {
|
||||||
const art = store.getState().artifacts.find(a => a.id === id)
|
const art = store.getState().artifacts.find(a => a.id === id)
|
||||||
@@ -19,12 +22,33 @@ export const docBridge: DocBridge = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const inboxCallbacks = {
|
// 无项目对话(scratch)没有可留档的项目:只用不含 onArchive 的回调。
|
||||||
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
|
const scratchInboxCallbacks = {
|
||||||
|
onAction: (c: ActivityCard, id: string, payload?: WorkCardAction) => store.cardAction(c, id, payload),
|
||||||
|
onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c),
|
||||||
|
onReference: (c: ActivityCard) => store.referenceWork(c),
|
||||||
|
actionState: (c: ActivityCard) => store.getState().cardActionStates[c.id],
|
||||||
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
||||||
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
||||||
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
|
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
|
||||||
onArchive: (c: ActivityCard) => store.cardToProject(c),
|
}
|
||||||
|
const inboxCallbacks = { ...scratchInboxCallbacks, onArchive: (c: ActivityCard) => store.cardToProject(c) }
|
||||||
|
|
||||||
|
type Scope = string | null | undefined
|
||||||
|
|
||||||
|
/** 本会话仍待用户处理的卡片:计数与定位共用这一谓词。 */
|
||||||
|
const isPendingConfirmation = (card: ActivityCard, conversationId: Scope, projectId: Scope) =>
|
||||||
|
card.status !== 'dismissed' && card.conversationId === conversationId && card.projectId === projectId
|
||||||
|
&& (card.status === 'action_required' || card.work?.decision?.state === 'open')
|
||||||
|
|
||||||
|
/** 本会话仍待用户处理的卡片数:右栏收起后由对话输入区提示承接确认路径。 */
|
||||||
|
export function pendingConfirmations(cards: ActivityCard[], conversationId: Scope, projectId: Scope): number {
|
||||||
|
return cards.filter(card => isPendingConfirmation(card, conversationId, projectId)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右栏收起或不在「工作」页签时的确认路径:经 openInbox 展开右栏、切到工作页签并定位首张待处理卡。 */
|
||||||
|
export function openPendingConfirmation(cards: ActivityCard[], conversationId: Scope, projectId: Scope) {
|
||||||
|
store.openInbox(cards.find(card => isPendingConfirmation(card, conversationId, projectId))?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
|
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
|
||||||
@@ -51,15 +75,22 @@ function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxF
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Workbench() {
|
export function Workbench() {
|
||||||
|
// Subscribe to shared feedback even when no card object has changed yet.
|
||||||
|
useStore(s => s.cardActionStates)
|
||||||
const rightTab = useStore(s => s.ui.rightTab)
|
const rightTab = useStore(s => s.ui.rightTab)
|
||||||
const cards = useStore(s => s.cards)
|
const cards = useStore(s => s.cards)
|
||||||
const plan = useStore(s => s.plan)
|
const plan = useStore(s => s.plan)
|
||||||
const todos = useStore(s => s.todos)
|
const todos = useStore(s => s.todos)
|
||||||
const inboxScope = useStore(s => s.inboxScope)
|
const scratch = useStore(s => !!s.tasks.find(t => t.id === s.currentConvId)?.scratch)
|
||||||
|
const storedScope = useStore(s => s.inboxScope)
|
||||||
|
const inboxScope = scratch ? 'conv' : storedScope
|
||||||
const projectCards = useStore(s => s.projectCards)
|
const projectCards = useStore(s => s.projectCards)
|
||||||
const filter = useStore(s => s.ui.inboxFilter)
|
const filter = useStore(s => s.ui.inboxFilter)
|
||||||
const visible = inboxScope === 'project' ? projectCards : cards
|
const visible = inboxScope === 'project' ? projectCards : cards
|
||||||
const pending = pendingCount(visible)
|
const pending = pendingCount(visible)
|
||||||
|
const unread = visible.filter(card => card.work && card.work.resultVersion > card.work.readVersion).length
|
||||||
|
const pendingHere = useStore(s => pendingConfirmations(s.cards, s.currentConvId, s.currentProjectId))
|
||||||
|
const collapseHint = pendingHere > 0 ? `收起右栏(${pendingHere} 项待确认可从对话输入区的提示回到工作栏处理)` : '收起右栏'
|
||||||
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
|
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
|
||||||
const switchTab = (tab: 'work' | 'files') =>
|
const switchTab = (tab: 'work' | 'files') =>
|
||||||
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
|
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
|
||||||
@@ -67,18 +98,18 @@ export function Workbench() {
|
|||||||
<>
|
<>
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
|
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
|
||||||
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
|
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}{unread > 0 ? ` · ${unread} 项新结果` : ''}</button>
|
||||||
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
|
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
|
||||||
title="文件搜索、预览、编辑与下载">文件</button>
|
title="文件搜索、预览、编辑与下载">文件</button>
|
||||||
<span className="spacer" style={{ flex: 1 }} />
|
<span className="spacer" style={{ flex: 1 }} />
|
||||||
{rightTab === 'work' && (
|
{rightTab === 'work' && (
|
||||||
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
|
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
|
||||||
)}
|
)}
|
||||||
<button className="tab" title="收起右栏" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
|
<button className="tab" title={collapseHint} aria-label={collapseHint} onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
{rightTab === 'files'
|
{rightTab === 'files'
|
||||||
? <FilesTab />
|
? <FilesTab />
|
||||||
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} />}
|
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} scratch={scratch} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -91,31 +122,41 @@ function FilesTab() {
|
|||||||
return <div className="tab-body"><ArtifactsPanel /></div>
|
return <div className="tab-body"><ArtifactsPanel /></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
/** U1:同会话存在后台 progress 卡(进行中/待确认)时,progress 为权威对象,隐藏 CurrentTaskCard 避免双标题。 */
|
||||||
|
export function hasActiveProgressCard(cards: ActivityCard[]): boolean {
|
||||||
|
return cards.some(c => c.kind === 'progress' && (c.status === 'active' || c.status === 'action_required'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards, scratch }: {
|
||||||
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
|
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
|
||||||
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
|
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
|
||||||
filter: InboxFilter
|
filter: InboxFilter
|
||||||
inboxScope: 'conv' | 'project'
|
inboxScope: 'conv' | 'project'
|
||||||
projectCards: ActivityCard[]
|
projectCards: ActivityCard[]
|
||||||
cards: ActivityCard[]
|
cards: ActivityCard[]
|
||||||
|
scratch: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const record = useStore(s => s.workRecord)
|
||||||
|
const showCurrentTask = !hasActiveProgressCard(cards)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="tab-body" data-scroll-sync="inbox">
|
<div className="tab-body" data-work-scroll>
|
||||||
<CurrentTaskCard plan={plan} todos={todos} />
|
{record ? <WorkExecutionRecord /> : <>
|
||||||
|
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
|
||||||
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
|
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
|
||||||
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
|
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
|
||||||
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
|
{!scratch && <button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
|
||||||
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
|
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
|
||||||
</button>
|
</button>}
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
|
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
|
||||||
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
|
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
|
||||||
</div>
|
</div>
|
||||||
{inboxScope === 'project'
|
{inboxScope === 'project'
|
||||||
? <ProjectInbox cards={projectCards} filter={filter} />
|
? <ProjectInbox cards={projectCards} filter={filter} />
|
||||||
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
|
: <InboxList cards={cards} docBridge={docBridge} callbacks={scratch ? scratchInboxCallbacks : inboxCallbacks} filter={filter} />}
|
||||||
<WorkspaceSurfaces />
|
<WorkspaceSurfaces />
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -180,9 +221,11 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
|
|||||||
function WorkspaceSurfaces() {
|
function WorkspaceSurfaces() {
|
||||||
const surfaces = useStore(s => s.surfaces)
|
const surfaces = useStore(s => s.surfaces)
|
||||||
const pin = useStore(s => s.ui.workspaceSurfacePin)
|
const pin = useStore(s => s.ui.workspaceSurfacePin)
|
||||||
if (surfaces.length === 0) return null
|
const usable = surfaces.filter(x => surfaceKey(x) != null)
|
||||||
const shown = pin ? surfaces.find(x => String(x.identity.surfaceId) === pin) : surfaces[surfaces.length - 1]
|
if (usable.length === 0) return null
|
||||||
|
const shown = pin ? usable.find(x => surfaceKey(x) === pin) : usable[usable.length - 1]
|
||||||
if (!shown) return null
|
if (!shown) return null
|
||||||
|
const shownId = surfaceKey(shown)!
|
||||||
return (
|
return (
|
||||||
<div className="workspace-surfaces" data-workspace-surfaces>
|
<div className="workspace-surfaces" data-workspace-surfaces>
|
||||||
<div className="ws-head">
|
<div className="ws-head">
|
||||||
@@ -190,10 +233,10 @@ function WorkspaceSurfaces() {
|
|||||||
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
|
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
|
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
|
||||||
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : String(shown.identity.surfaceId) })}>
|
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : shownId })}>
|
||||||
{pin ? '📍' : '📌'}
|
{pin ? '📍' : '📌'}
|
||||||
</button>
|
</button>
|
||||||
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(String(shown.identity.surfaceId))}>
|
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(shownId)}>
|
||||||
↗
|
↗
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import { docBridge } from './Workbench'
|
|||||||
import { AgentRow } from './Workbench'
|
import { AgentRow } from './Workbench'
|
||||||
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
|
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
|
||||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||||
|
import { DocumentCockpit } from './DocumentCockpit'
|
||||||
|
|
||||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
|
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
|
||||||
|
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
|
||||||
export function workspaceEntries(): WorkspaceEntry[] {
|
export function workspaceEntries(): WorkspaceEntry[] {
|
||||||
return buildWorkspaceEntries(store.getState().serverWorkspaces)
|
const s = store.getState()
|
||||||
|
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable, rdmCockpit: s.rdmCockpitAvailable })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Workspaces() {
|
export function Workspaces() {
|
||||||
@@ -34,6 +37,11 @@ export function Workspaces() {
|
|||||||
<RdmAuditWorkspace />
|
<RdmAuditWorkspace />
|
||||||
</OverlayHost>
|
</OverlayHost>
|
||||||
}
|
}
|
||||||
|
if (active === 'rdm-cockpit') {
|
||||||
|
return <OverlayHost title="文档驾驶舱" icon="📋" shortcut={entry?.shortcut} sayHint="打开文档驾驶舱" onClose={close}>
|
||||||
|
<DocumentCockpit />
|
||||||
|
</OverlayHost>
|
||||||
|
}
|
||||||
if (active.startsWith('plugin:')) {
|
if (active.startsWith('plugin:')) {
|
||||||
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
|
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
|
||||||
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
|
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import type { Project } from '../contracts'
|
||||||
|
import { MemberDutyCell, RdmMembersSection, memberManagementView } from './adminRdmSections'
|
||||||
|
|
||||||
|
const project: Project = {
|
||||||
|
id: 'host-1', owner: 'alice', name: '项目P', createdAt: '', updatedAt: '',
|
||||||
|
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MemberManagementEntryHidden', () => {
|
||||||
|
it('stays read-only until the self check confirms member management', () => {
|
||||||
|
expect(memberManagementView(null, null)).toEqual({ canManage: false, notice: '正在核对成员管理权限…' })
|
||||||
|
expect(memberManagementView([], null).canManage).toBe(false)
|
||||||
|
expect(memberManagementView([{ action: 'member.manage', allowed: false, }], null).notice).toContain('成员增删由项目创建者负责')
|
||||||
|
expect(memberManagementView([{ action: 'member.manage', allowed: true }], null)).toEqual({ canManage: true, notice: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not offer member management on a project that cannot accept work', () => {
|
||||||
|
expect(memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档').canManage).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no add, role change or removal entry before authorization is known', () => {
|
||||||
|
const html = renderToStaticMarkup(<RdmMembersSection project={project} />)
|
||||||
|
expect(html).toContain('正在核对成员管理权限…')
|
||||||
|
expect(html).not.toContain('添加成员')
|
||||||
|
expect(html).not.toContain('选择人员')
|
||||||
|
expect(html).toContain('实际职责')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CreatorOwnerMemberDuties member table', () => {
|
||||||
|
it('separates the immutable creator from the owner label and plain members', () => {
|
||||||
|
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
|
||||||
|
expect(creator).toContain('创建者')
|
||||||
|
expect(creator).toContain('成员增删')
|
||||||
|
expect(creator).toContain('随「创建项目」联动')
|
||||||
|
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator="alice" />)
|
||||||
|
expect(owner).toContain('项目负责人')
|
||||||
|
expect(owner).toContain('不含成员管理')
|
||||||
|
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
|
||||||
|
expect(member).toContain('基础协作')
|
||||||
|
expect(member).not.toContain('成员增删')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to plain member duty when the project has no creator recorded', () => {
|
||||||
|
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator={undefined} />)
|
||||||
|
expect(owner).toContain('项目负责人')
|
||||||
|
expect(owner).not.toContain('<span class="ad-chip ad-chip-admin">创建者')
|
||||||
|
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator={undefined} />)
|
||||||
|
expect(member).toContain('基础协作')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the creator cell as an admin chip and the plain member as a neutral chip', () => {
|
||||||
|
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
|
||||||
|
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
|
||||||
|
expect(creator).toContain('ad-chip-admin')
|
||||||
|
expect(member).toMatch(/ad-chip(?!-admin)/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MemberManagementEntryHidden notice clarity', () => {
|
||||||
|
it('names the project-wide stage error as the reason to gate management entries', () => {
|
||||||
|
const result = memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档,请联系项目负责人恢复后再新建对话')
|
||||||
|
expect(result).toEqual({ canManage: false, notice: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes pending self-check from a confirmed no-permission state', () => {
|
||||||
|
const pending = memberManagementView(null, null)
|
||||||
|
const denied = memberManagementView([{ action: 'project.members.manage', allowed: false }], null)
|
||||||
|
expect(pending.notice).toMatch(/正在核对/)
|
||||||
|
expect(denied.notice).toMatch(/成员增删由项目创建者负责/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
// RDM 项目设置组件:成员 / 文档职责。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||||
|
// 门控集中在 src/rdmManagement.ts;分区可见性由 ProjectSettingsModal 按 store 的
|
||||||
|
// rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||||
|
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||||||
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
|
import { api } from '../api'
|
||||||
|
import type { Project } from '../contracts'
|
||||||
|
import { canManageProjectMembers, memberDuty, projectWorkError } from '../projectmeta'
|
||||||
|
import { useStore } from '../store'
|
||||||
|
import { Btn, Collapsible, Icon, Picker } from '@ui'
|
||||||
|
import {
|
||||||
|
memberAddPayload, memberRemovePayload, memberSetRolePayload,
|
||||||
|
permCheckPayload, describeSource,
|
||||||
|
type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type RdmMemberPage, type RdmProfRole,
|
||||||
|
type SearchFieldsChoice,
|
||||||
|
MATCH_MODE_LABELS, SEARCH_FIELDS, accountCandidatePager, accountSearchKey, appendMemberPage, memberListParams,
|
||||||
|
personLabel, roleOptionsPayload,
|
||||||
|
} from '../rdmManagement'
|
||||||
|
|
||||||
|
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||||
|
|
||||||
|
interface MemberRow {
|
||||||
|
owner: string; name?: string; role: string; addedBy: string; addedAt: string
|
||||||
|
profRole?: { roleId: string; name: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成员管理入口门控:本人自省未到达或无成员管理资格时只读(fail-closed)。 */
|
||||||
|
export function memberManagementView(actions: { action: string; allowed: boolean }[] | null, writeError: string | null) {
|
||||||
|
if (actions === null) return { canManage: false, notice: '正在核对成员管理权限…' }
|
||||||
|
if (!canManageProjectMembers(actions)) {
|
||||||
|
return { canManage: false, notice: '你可以查看成员与实际职责;成员增删由项目创建者负责(需具备项目成员管理资格)。' }
|
||||||
|
}
|
||||||
|
return { canManage: !writeError, notice: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实际职责:创建者(不可变)与界面身份「负责人」分开显示,并标明能力关联来源。 */
|
||||||
|
export function MemberDutyCell({ member, creator }: { member: { owner: string; role: string }; creator: string | undefined }) {
|
||||||
|
const duty = memberDuty(member, creator)
|
||||||
|
return <>
|
||||||
|
<span className={duty.kind === 'member' ? 'ad-chip' : 'ad-chip ad-chip-admin'}>{duty.label}</span>
|
||||||
|
<div className="ad-dim" title={`来源:${duty.source}`}>{duty.duties}</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RdmMembersSection({ project }: { project: Project }) {
|
||||||
|
const projectId = project.id
|
||||||
|
const writeError = projectWorkError(project)
|
||||||
|
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||||
|
const me = useStore(s => s.me)
|
||||||
|
const [manageActions, setManageActions] = useState<{ action: string; allowed: boolean }[] | null>(null)
|
||||||
|
const manage = memberManagementView(manageActions, writeError)
|
||||||
|
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||||
|
const [memberNext, setMemberNext] = useState('')
|
||||||
|
const [memberTotal, setMemberTotal] = useState<number | null>(null)
|
||||||
|
const [directoryOk, setDirectoryOk] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [memberQuery, setMemberQuery] = useState('')
|
||||||
|
const [memberRoleId, setMemberRoleId] = useState('')
|
||||||
|
// null=未加载或 rdm.role.options 失败:两处专业角色筛选均不渲染(不给误导选项)。
|
||||||
|
const [roleOptions, setRoleOptions] = useState<RdmProfRole[] | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [picked, setPicked] = useState<string[]>([])
|
||||||
|
const [pickedItems, setPickedItems] = useState<PersonPickItem[]>([])
|
||||||
|
const [candRoleId, setCandRoleId] = useState('')
|
||||||
|
const [candMatch, setCandMatch] = useState<MemberMatchMode>('fuzzy')
|
||||||
|
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
|
||||||
|
const [role, setRole] = useState<'member' | 'owner'>('member')
|
||||||
|
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
|
||||||
|
const rowsRef = useRef<MemberRow[] | null>(null)
|
||||||
|
rowsRef.current = rows
|
||||||
|
const knownPicks = useRef(new Map<string, PersonPickItem>())
|
||||||
|
// 成员表请求代次:切项目/切筛选推进代次,迟到响应丢弃。
|
||||||
|
const memberGen = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPicked([]); setPickedItems([]); knownPicks.current.clear()
|
||||||
|
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
|
||||||
|
setRoleOptions(null)
|
||||||
|
}, [project])
|
||||||
|
|
||||||
|
// rdm.role.options 受 member.manage 授权;未确认前先不发请求,避免普通成员被 403。
|
||||||
|
// 跟着 [project, manage.canManage] 重订:项目切换或权限放开后重发,迟到响应丢弃。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!manage.canManage) return
|
||||||
|
let live = true
|
||||||
|
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
|
||||||
|
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
|
||||||
|
.catch(() => { if (live) setRoleOptions(null) })
|
||||||
|
return () => { live = false }
|
||||||
|
}, [project, manage.canManage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setManageActions(null)
|
||||||
|
if (!me?.owner) return
|
||||||
|
let live = true
|
||||||
|
cmd<{ actions?: { action: string; allowed: boolean }[] }>('rdm.perm.check', permCheckPayload(projectId, me.owner))
|
||||||
|
.then(out => { if (live) setManageActions(out.actions ?? []) })
|
||||||
|
.catch(() => { if (live) setManageActions([]) })
|
||||||
|
return () => { live = false }
|
||||||
|
}, [project, me?.owner])
|
||||||
|
|
||||||
|
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
|
||||||
|
[candMatch, candFields, candRoleId])
|
||||||
|
|
||||||
|
/** 人员选择器取数适配:rdm.account.list 服务端按 query/mode/searchFields/roleId 先过滤后分页。 */
|
||||||
|
const fetchPage = useMemo(() => {
|
||||||
|
const page = accountCandidatePager(params => cmd<RdmAccountPage>('rdm.account.list', params), projectId,
|
||||||
|
{ ...candOptions, joinedOwners: () => (rowsRef.current ?? []).map(m => m.owner) })
|
||||||
|
return async (query: string, cursor: string, limit: number) => {
|
||||||
|
const out = await page(query, cursor, limit)
|
||||||
|
for (const it of out.items) knownPicks.current.set(it.id, it)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}, [project, candOptions])
|
||||||
|
|
||||||
|
/** cursor 空=重载首页(clear 时先清空表格);非空=追加下一页。 */
|
||||||
|
const loadMembers = useCallback(async (cursor: string, clear: boolean) => {
|
||||||
|
const gen = cursor ? memberGen.current : ++memberGen.current
|
||||||
|
if (cursor) setLoadingMore(true)
|
||||||
|
else {
|
||||||
|
setLoadingMore(false)
|
||||||
|
if (clear) { setErr(''); setRows(null); setMemberNext(''); setMemberTotal(null) }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = await cmd<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)
|
||||||
|
try { await fn(); setNotice(label); await refresh() }
|
||||||
|
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>}
|
||||||
|
{manage.notice && <p className="ad-hint" role="note">{manage.notice}</p>}
|
||||||
|
{manage.canManage && <div className="ad-create">
|
||||||
|
<div className="ad-row">
|
||||||
|
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
|
||||||
|
<select className="lg-input" aria-label="匹配方式" value={candMatch}
|
||||||
|
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
|
||||||
|
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
|
||||||
|
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
|
||||||
|
</select>
|
||||||
|
<select className="lg-input" aria-label="检索字段" value={candFields}
|
||||||
|
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
|
||||||
|
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
|
||||||
|
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="ad-row">
|
||||||
|
<Picker
|
||||||
|
aria-label="选择人员"
|
||||||
|
selected={picked}
|
||||||
|
onSelect={onPick}
|
||||||
|
selectedItems={pickedItems}
|
||||||
|
fetchPage={fetchPage}
|
||||||
|
reloadKey={accountSearchKey(projectId, candOptions)}
|
||||||
|
disabled={pickerDisabled}
|
||||||
|
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
|
||||||
|
emptyText="输入后显示匹配账号"
|
||||||
|
/>
|
||||||
|
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||||
|
<option value="member">项目成员</option>
|
||||||
|
<option value="owner">项目负责人(界面身份,不含成员管理)</option>
|
||||||
|
</select>
|
||||||
|
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
|
||||||
|
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
|
||||||
|
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
|
||||||
|
setPicked([]); setPickedItems([])
|
||||||
|
})}>添加成员</button>
|
||||||
|
</div>
|
||||||
|
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。被加入的成员无需重新登录,项目会自动出现在其项目列表。</p>
|
||||||
|
</div>}
|
||||||
|
<div className="ad-row">
|
||||||
|
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
|
||||||
|
onChange={e => setMemberQuery(e.target.value)} />
|
||||||
|
{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><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{(rows ?? []).map(m => (
|
||||||
|
<tr key={m.owner}>
|
||||||
|
<td className="ad-mono">{personLabel(m)}</td>
|
||||||
|
<td className="ad-dim">{m.profRole?.name || '—'}</td>
|
||||||
|
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
|
||||||
|
<td><MemberDutyCell member={m} creator={project.owner} /></td>
|
||||||
|
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||||
|
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||||
|
<td>{manage.canManage && <>
|
||||||
|
<button className="ad-mini" disabled={busy || !!writeError}
|
||||||
|
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
|
||||||
|
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
|
||||||
|
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
|
||||||
|
{m.role === 'owner' ? '降为成员' : '升为负责人'}
|
||||||
|
</button>{' '}
|
||||||
|
<button className="ad-mini" disabled={busy || !!writeError}
|
||||||
|
onClick={() => run(`已移除 ${m.owner}`,
|
||||||
|
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
|
||||||
|
</>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows !== null && rows.length === 0 && <tr><td colSpan={7} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||||
|
{rows === null && <tr><td colSpan={7} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{memberNext && rows !== null && (
|
||||||
|
<div className="ad-row">
|
||||||
|
<button className="ad-mini" disabled={loadingMore}
|
||||||
|
onClick={() => loadMembers(memberNext, false)}>{loadingMore ? '加载中…' : '加载更多成员'}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||||
|
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RdmDutiesSection } from './duties/RdmDutiesSection'
|
||||||
|
|
||||||
|
// ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖;
|
||||||
|
// 与实际判定同源——UI 不重算,全部读服务端解释) ----
|
||||||
|
|
||||||
|
interface MatrixAction {
|
||||||
|
action: string; label?: string; scope?: string
|
||||||
|
allowed: boolean; source: string; reason?: string
|
||||||
|
}
|
||||||
|
interface MatrixOut {
|
||||||
|
projectId: string; member: string; role: string
|
||||||
|
profRole: { roleId: string; name: string } | null
|
||||||
|
actions: MatrixAction[]
|
||||||
|
}
|
||||||
|
interface MatrixRowOut {
|
||||||
|
member: string; role: string
|
||||||
|
profRole: { roleId: string; name: string } | null
|
||||||
|
actions: MatrixAction[]
|
||||||
|
}
|
||||||
|
interface MatrixBatchOut { projectId: string; rows: MatrixRowOut[] }
|
||||||
|
|
||||||
|
function actionTitle(a: MatrixAction) { return a.label || a.action }
|
||||||
|
|
||||||
|
/** 动作行:中文标签为主,内部标识进 title 技术详情(方案 §5)。 */
|
||||||
|
function ActionDetailTable({ actions, onOverride, canManage }: {
|
||||||
|
actions: MatrixAction[]
|
||||||
|
onOverride?: (action: string, mode: 'allow' | 'deny' | 'default') => void
|
||||||
|
canManage?: boolean
|
||||||
|
}) {
|
||||||
|
const project = actions.filter(a => a.scope !== 'system')
|
||||||
|
const system = actions.filter(a => a.scope === 'system')
|
||||||
|
const overrideOf = (a: MatrixAction): 'allow' | 'deny' | 'default' =>
|
||||||
|
a.source.includes('override') || a.source === 'project' ? (a.allowed ? 'allow' : 'deny') : 'default'
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<table className="ad-table">
|
||||||
|
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th>{canManage ? <th>项目覆盖</th> : null}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{project.map(a => (
|
||||||
|
<tr key={a.action}>
|
||||||
|
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
|
||||||
|
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
|
||||||
|
<td className="ad-dim">{describeSource(a.source)}</td>
|
||||||
|
<td className="ad-dim">{a.reason || '—'}</td>
|
||||||
|
{canManage ? (
|
||||||
|
<td>
|
||||||
|
<select className="lg-input" value={overrideOf(a)} style={{ minWidth: 120 }}
|
||||||
|
onChange={e => onOverride?.(a.action, e.target.value as 'allow' | 'deny' | 'default')}>
|
||||||
|
<option value="default">沿用角色默认</option>
|
||||||
|
<option value="allow">项目内允许</option>
|
||||||
|
<option value="deny">项目内禁止</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{system.length > 0 && (
|
||||||
|
<Collapsible trigger={`系统范围动作(${system.length} 项,不在项目内配置)`}>
|
||||||
|
<table className="ad-table">
|
||||||
|
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{system.map(a => (
|
||||||
|
<tr key={a.action}>
|
||||||
|
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
|
||||||
|
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
|
||||||
|
<td className="ad-dim">{describeSource(a.source)}</td>
|
||||||
|
<td className="ad-dim">{a.reason || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RdmPermMatrixSection({ project }: { project: Project }) {
|
||||||
|
const projectId = project.id
|
||||||
|
const writeError = projectWorkError(project)
|
||||||
|
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||||
|
const me = useStore(s => s.me)
|
||||||
|
const [view, setView] = useState<'member' | 'matrix'>('member')
|
||||||
|
const [member, setMember] = useState('')
|
||||||
|
const [members, setMembers] = useState<MemberRow[] | null>(null)
|
||||||
|
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
|
||||||
|
const [batch, setBatch] = useState<MatrixBatchOut | null>(null)
|
||||||
|
const [expand, setExpand] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const loadMembers = () =>
|
||||||
|
cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||||
|
.then(out => {
|
||||||
|
setMembers(out.members ?? [])
|
||||||
|
if (me?.owner) setMember(prev => prev || me.owner)
|
||||||
|
}).catch(e => setErr(String(e)))
|
||||||
|
useEffect(() => { loadMembers() }, [project, me?.owner]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const query = async (m: string) => {
|
||||||
|
if (!m) return
|
||||||
|
setErr(''); setBusy(true); setMatrix(null)
|
||||||
|
try {
|
||||||
|
setMatrix(await cmd<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
|
||||||
|
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const queryBatch = async () => {
|
||||||
|
setErr(''); setBusy(true); setBatch(null)
|
||||||
|
try {
|
||||||
|
setBatch(await cmd<MatrixBatchOut>('rdm.perm.matrix', { projectId }))
|
||||||
|
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
useEffect(() => { if (view === 'matrix') queryBatch() }, [view]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
/** 三态覆盖写回:default=实际撤销(perm.unset);allow/deny=perm.set。 */
|
||||||
|
const override = async (m: string, action: string, mode: 'allow' | 'deny' | 'default') => {
|
||||||
|
if (writeError) { setErr(writeError); return }
|
||||||
|
setErr(''); setNotice(''); setBusy(true)
|
||||||
|
try {
|
||||||
|
if (mode === 'default') {
|
||||||
|
await cmd('rdm.perm.unset', { projectId, member: m, action })
|
||||||
|
setNotice(`已恢复默认(撤销 ${action} 的项目覆盖)`)
|
||||||
|
} else {
|
||||||
|
await cmd('rdm.perm.set', { projectId, member: m, action, allow: mode === 'allow' })
|
||||||
|
setNotice(mode === 'allow' ? `已在项目内允许 ${action}` : `已在项目内禁止 ${action}`)
|
||||||
|
}
|
||||||
|
await query(m)
|
||||||
|
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const canManage = !writeError && !!matrix && matrix.role !== undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||||
|
<div className="ad-create">
|
||||||
|
<div className="ad-row">
|
||||||
|
<select className="lg-input" value={view} onChange={e => setView(e.target.value as 'member' | 'matrix')}>
|
||||||
|
<option value="member">按成员查看(默认自省)</option>
|
||||||
|
<option value="matrix">权限矩阵(成员 × 动作,须管理权)</option>
|
||||||
|
</select>
|
||||||
|
{view === 'member' && (
|
||||||
|
<>
|
||||||
|
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
|
||||||
|
<option value="">选择成员…</option>
|
||||||
|
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
|
||||||
|
</select>
|
||||||
|
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看</Btn>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="ad-hint">普通成员可查看自己的有效权限;查看他人与矩阵须项目管理权。解释与实际判定同源(服务端,非前端推算);系统范围动作折叠为技术详情。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'member' && matrix && (
|
||||||
|
<>
|
||||||
|
<div className="ad-row">
|
||||||
|
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目身份 <b>{matrix.role === 'owner' ? '项目负责人' : matrix.role === 'member' ? '项目成员' : matrix.role}</b>
|
||||||
|
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b></>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ActionDetailTable actions={matrix.actions ?? []}
|
||||||
|
canManage={canManage}
|
||||||
|
onOverride={(action, mode) => override(matrix.member, action, mode)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{view === 'member' && !matrix && !err && members !== null && <p className="ad-hint">选择成员后展示各动作的有效权限、来源与第一拒绝原因。</p>}
|
||||||
|
|
||||||
|
{view === 'matrix' && batch && (
|
||||||
|
<table className="ad-table" style={{ overflowX: 'auto', display: 'block' }}>
|
||||||
|
<thead><tr>
|
||||||
|
<th>成员</th>
|
||||||
|
{(batch.rows[0]?.actions ?? []).filter(a => a.scope !== 'system').map(a => (
|
||||||
|
<th key={a.action} title={`内部标识 ${a.action}`}>{actionTitle(a)}</th>
|
||||||
|
))}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{batch.rows.map(row => (
|
||||||
|
<tr key={row.member}>
|
||||||
|
<td>
|
||||||
|
<button className="ad-mini" onClick={() => setExpand(expand === row.member ? '' : row.member)}>
|
||||||
|
{row.member}
|
||||||
|
</button>
|
||||||
|
<span className="ad-dim"> · {row.role === 'owner' ? '负责人' : '成员'}</span>
|
||||||
|
</td>
|
||||||
|
{row.actions.filter(a => a.scope !== 'system').map(a => (
|
||||||
|
<td key={a.action} title={`${a.label ?? a.action}:${a.allowed ? '允许' : '拒绝'}(${describeSource(a.source)}${a.reason ? ';' + a.reason : ''})`}>
|
||||||
|
{a.allowed ? <span className="ad-chip ad-chip-admin">✓</span> : <span className="ad-chip ad-chip-warn">✗</span>}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{view === 'matrix' && batch && expand && (
|
||||||
|
<div className="settings-group" style={{ marginTop: 12 }}>
|
||||||
|
<h4>{expand} · 单成员详情与三态覆盖</h4>
|
||||||
|
{(() => {
|
||||||
|
const row = batch.rows.find(r => r.member === expand)
|
||||||
|
if (!row) return null
|
||||||
|
return <ActionDetailTable actions={row.actions} canManage={!writeError}
|
||||||
|
onOverride={(action, mode) => override(expand, action, mode)} />
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||||
|
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,11 +6,16 @@ import { api } from '../api'
|
|||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
import type { AgentProfile } from '../contracts'
|
import type { AgentProfile } from '../contracts'
|
||||||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||||||
|
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||||||
|
import type { SettingDesc } from '../contracts'
|
||||||
import { docBridge } from './Workbench'
|
import { docBridge } from './Workbench'
|
||||||
import { randomPassword } from '../passwords'
|
import { accountActionPolicy, canAppointAdmins } from '../accountAuthority'
|
||||||
|
import { CreateAdminOption, IdentityChip } from './AccountUsersPanel'
|
||||||
|
import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCredential } from './oneTimePasswordUi'
|
||||||
|
|
||||||
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
|
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码的唯一处置是
|
||||||
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
|
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
|
||||||
|
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||||
|
|
||||||
export function UsersPanel() {
|
export function UsersPanel() {
|
||||||
const me = useStore(s => s.me)
|
const me = useStore(s => s.me)
|
||||||
@@ -19,11 +24,10 @@ export function UsersPanel() {
|
|||||||
const [notice, setNotice] = useState('')
|
const [notice, setNotice] = useState('')
|
||||||
const [owner, setOwner] = useState('')
|
const [owner, setOwner] = useState('')
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [pw, setPw] = useState('')
|
const [initialPassword, setInitialPassword] = useState('')
|
||||||
const [asAdmin, setAsAdmin] = useState(false)
|
const [createAsAdmin, setCreateAsAdmin] = useState(false)
|
||||||
|
const [generatedCredential, setGeneratedCredential] = useState<GeneratedCredential | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
|
||||||
const [resetPw, setResetPw] = useState('')
|
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||||||
@@ -32,25 +36,22 @@ export function UsersPanel() {
|
|||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
setErr(''); setNotice('')
|
setErr(''); setNotice('')
|
||||||
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
|
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
|
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined, canAppointAdmins(me) && createAsAdmin)
|
||||||
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
|
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
|
||||||
setOwner(''); setName(''); setPw('')
|
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
|
||||||
|
setOwner(''); setName(''); setInitialPassword(''); setCreateAsAdmin(false)
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const reset = async () => {
|
const issueOneTimePassword = async (u: AdminUserRow) => {
|
||||||
if (!resetFor) return
|
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
|
||||||
setErr('')
|
|
||||||
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
|
|
||||||
setBusy(true)
|
|
||||||
try {
|
try {
|
||||||
await api.resetAdminUserPassword(resetFor, resetPw)
|
const out = await api.adminIssueOneTimePassword(u.owner)
|
||||||
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
|
setGeneratedCredential(oneTimePasswordCredential(out))
|
||||||
setResetFor(null); setResetPw('')
|
setNotice(`已为 ${u.owner} 生成一次性登录密码。用户使用后必须立即设置新密码。`)
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
@@ -62,46 +63,37 @@ export function UsersPanel() {
|
|||||||
<div className="ad-row">
|
<div className="ad-row">
|
||||||
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
|
<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" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
|
||||||
|
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
|
||||||
|
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ad-row">
|
<CreateAdminOption me={me} checked={createAsAdmin} disabled={busy} onChange={setCreateAsAdmin} />
|
||||||
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
|
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
|
||||||
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
|
|
||||||
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
|
|
||||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
|
|
||||||
</div>
|
|
||||||
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||||
|
{generatedCredential && <GeneratedCredentialPanel credential={generatedCredential} onClose={() => setGeneratedCredential(null)} />}
|
||||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||||
{resetFor ? (
|
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点击该用户行的「生成一次性登录密码」:用户登录后必须立即设置新密码。</p>}
|
||||||
<div className="ad-reset">
|
|
||||||
<span>重置 <b>{resetFor}</b> 的密码:</span>
|
|
||||||
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
|
|
||||||
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
|
|
||||||
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
|
|
||||||
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table className="ad-table">
|
<table className="ad-table">
|
||||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(users ?? []).map(u => (
|
{(users ?? []).map(u => {
|
||||||
<tr key={u.owner}>
|
const policy = accountActionPolicy(me, u)
|
||||||
|
return <tr key={u.owner}>
|
||||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||||
<td>{u.name || '—'}</td>
|
<td>{u.name || '—'}</td>
|
||||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
<td>{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 className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||||
<td>{me?.isAdmin && (
|
<td>{me?.isAdmin && (policy.resetPassword
|
||||||
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
|
? <button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
|
||||||
|
: <span className="ad-dim">{policy.protectedReason || '受保护账号不可生成一次性登录密码'}</span>
|
||||||
)}</td>
|
)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
})}
|
||||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -240,3 +232,74 @@ export function PluginWorkspacesPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 模型与服务配置(P2 系统受管项的唯一写入面)----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SystemSettingsPanel:消费 GET/PUT /api/settings/system。
|
||||||
|
* - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施);
|
||||||
|
* 非受管项属于用户/项目偏好,不在本面板出现。
|
||||||
|
* - 保存后重新 GET 以服务端结果回填(保存成功 ≠ 本地草稿即真相);掩码占位符
|
||||||
|
* 原样传回,由服务端视为未修改。
|
||||||
|
* - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。
|
||||||
|
*/
|
||||||
|
export function SystemSettingsPanel() {
|
||||||
|
const [schema, setSchema] = useState<SettingDesc[] | null>(null)
|
||||||
|
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [forbidden, setForbidden] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setErr(''); setForbidden(false)
|
||||||
|
try {
|
||||||
|
const out = await api.getSystemSettings()
|
||||||
|
setSchema(out.schema.filter(d => d.scope === 'system'))
|
||||||
|
setDraft(flatten(out.system))
|
||||||
|
setLoaded(true)
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e).includes('403')) { setForbidden(true); setLoaded(true) }
|
||||||
|
else setErr(String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
const groups: Record<string, SettingDesc[]> = {}
|
||||||
|
for (const d of schema ?? []) (groups[d.group] ??= []).push(d)
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr(''); setNotice(''); setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.putSystemSettings(unflatten(draft))
|
||||||
|
setNotice('已保存;按各项标注的生效时机生效(需重启的项在重启后生效)')
|
||||||
|
await load()
|
||||||
|
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loaded) return <EmptyState icon="settings">加载系统设置…</EmptyState>
|
||||||
|
if (forbidden) return <EmptyState icon="shield">系统设置仅管理员可配置</EmptyState>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="ad-hint" style={{ marginBottom: 14 }}>
|
||||||
|
模型、密钥与运行基础设施为系统受管配置:仅此面板(管理员)可写,用户与项目设置中的同名项不再生效。
|
||||||
|
</p>
|
||||||
|
{Object.entries(groups).map(([group, descs]) => (
|
||||||
|
<SettingsGroup key={group} title={group === 'models' ? '模型' : group === 'tools' ? '工具服务' : group === 'telemetry' ? '遥测' : group === 'runtime' ? '运行基础设施' : group}>
|
||||||
|
{descs.map(d => (
|
||||||
|
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
|
||||||
|
<SettingInput desc={d} value={draft[d.key]} onChange={v => setDraft(prev => ({ ...prev, [d.key]: v }))} />
|
||||||
|
</SettingRow>
|
||||||
|
))}
|
||||||
|
</SettingsGroup>
|
||||||
|
))}
|
||||||
|
<div className="ad-row" style={{ marginTop: 12 }}>
|
||||||
|
<Btn variant="primary" icon="check" disabled={busy} onClick={save}>保存系统配置</Btn>
|
||||||
|
</div>
|
||||||
|
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||||
|
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { api } from '../../api'
|
||||||
|
import type { Project } from '../../contracts'
|
||||||
|
import { projectWorkError } from '../../projectmeta'
|
||||||
|
import { Btn, Collapsible, EmptyState, Icon } from '@ui'
|
||||||
|
import {
|
||||||
|
SEARCH_FIELDS, accountCandidatePager, accountSearchKey, dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload,
|
||||||
|
type DutyAccountIds, type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type SearchFieldsChoice,
|
||||||
|
} from '../../rdmManagement'
|
||||||
|
import {
|
||||||
|
absorbServerDocs, dirtyDocs, editCell, savePayload,
|
||||||
|
type CellDuty, type DutyDocDraft, type DutyMember,
|
||||||
|
} from '../../duties/draft'
|
||||||
|
import { DutyByDocumentView, DutyByMemberView, DutyCandidateSearch, DutyChangePreview, DutyMatrixView } from '../../duties/views'
|
||||||
|
|
||||||
|
type ViewName = 'member' | 'document' | 'matrix'
|
||||||
|
|
||||||
|
interface MatrixDoc {
|
||||||
|
docId: string
|
||||||
|
title: string
|
||||||
|
code?: string
|
||||||
|
typeKey: string
|
||||||
|
stage?: string
|
||||||
|
dutyVersion: number
|
||||||
|
activeRound?: number
|
||||||
|
legacySectionOverrides?: number
|
||||||
|
}
|
||||||
|
interface MatrixOut {
|
||||||
|
canAssign: boolean
|
||||||
|
docs: MatrixDoc[]
|
||||||
|
members: DutyMember[]
|
||||||
|
cells: { docId: string; owner: string; duty: string }[]
|
||||||
|
nextDocCursor?: string
|
||||||
|
nextMemberCursor?: string
|
||||||
|
}
|
||||||
|
interface PreviewOut {
|
||||||
|
ok: boolean
|
||||||
|
docs: {
|
||||||
|
docId: string
|
||||||
|
title?: string
|
||||||
|
replacedAuthor?: string
|
||||||
|
replacedApprover?: string
|
||||||
|
needsConfirm?: boolean
|
||||||
|
reasons?: string[]
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
interface LegacyOut {
|
||||||
|
findings: { docId: string; title: string; sectionTitle: string; kind: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RdmDutiesSection({ project }: { project: Project }) {
|
||||||
|
const projectId = project.id
|
||||||
|
const writeError = projectWorkError(project)
|
||||||
|
const [view, setView] = useState<ViewName>('member')
|
||||||
|
const [docs, setDocs] = useState<DutyDocDraft[]>([])
|
||||||
|
const [members, setMembers] = useState<DutyMember[]>([])
|
||||||
|
const [member, setMember] = useState('')
|
||||||
|
const [docId, setDocId] = useState('')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [stage, setStage] = useState('')
|
||||||
|
const [typeKey, setTypeKey] = useState('')
|
||||||
|
const [assignment, setAssignment] = useState('')
|
||||||
|
const [docCursor, setDocCursor] = useState('')
|
||||||
|
const [nextDoc, setNextDoc] = useState('')
|
||||||
|
const [canAssign, setCanAssign] = useState(false)
|
||||||
|
const [narrow, setNarrow] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [preview, setPreview] = useState<PreviewOut['docs']>([])
|
||||||
|
const [confirm, setConfirm] = useState(false)
|
||||||
|
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
|
||||||
|
const [legacyOpen, setLegacyOpen] = useState(false)
|
||||||
|
const [candMode, setCandMode] = useState<MemberMatchMode>('fuzzy')
|
||||||
|
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
|
||||||
|
const [accountIds, setAccountIds] = useState<DutyAccountIds>({})
|
||||||
|
const seq = useRef(0)
|
||||||
|
const op = useRef(0)
|
||||||
|
const docsRef = useRef(docs)
|
||||||
|
docsRef.current = docs
|
||||||
|
|
||||||
|
const load = async (cursor: string, request = ++seq.current, replaceDirty = false) => {
|
||||||
|
const seenMembers: DutyMember[] = []
|
||||||
|
const seenCells: MatrixOut['cells'] = []
|
||||||
|
let pageDocs: MatrixDoc[] = []
|
||||||
|
let memberCursor = ''
|
||||||
|
let nextDocument = ''
|
||||||
|
let assignable = false
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const page = await api.rdmProjectCommand<MatrixOut>(project, 'rdm.duty.matrix', dutyMatrixPayload(projectId, {
|
||||||
|
view: 'matrix', query, stage, typeKey, assignment, docCursor: cursor, memberCursor, docLimit: 10, memberLimit: 20,
|
||||||
|
}))
|
||||||
|
if (request !== seq.current) return
|
||||||
|
if (pageDocs.length === 0) pageDocs = page.docs ?? []
|
||||||
|
seenMembers.push(...(page.members ?? []))
|
||||||
|
seenCells.push(...(page.cells ?? []))
|
||||||
|
assignable = page.canAssign
|
||||||
|
nextDocument = page.nextDocCursor ?? ''
|
||||||
|
memberCursor = page.nextMemberCursor ?? ''
|
||||||
|
} while (memberCursor)
|
||||||
|
if (request !== seq.current) return
|
||||||
|
setMembers(seenMembers)
|
||||||
|
setCanAssign(assignable)
|
||||||
|
setNextDoc(nextDocument)
|
||||||
|
setDocs(current => absorbServerDocs(current, pageDocs, seenCells, replaceDirty))
|
||||||
|
} catch (error) {
|
||||||
|
if (request !== seq.current) return
|
||||||
|
setErr(String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia('(max-width: 720px)')
|
||||||
|
const apply = () => setNarrow(media.matches)
|
||||||
|
apply()
|
||||||
|
media.addEventListener('change', apply)
|
||||||
|
return () => media.removeEventListener('change', apply)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load(docCursor)
|
||||||
|
return () => { seq.current += 1 }
|
||||||
|
}, [project, query, stage, typeKey, assignment, docCursor])
|
||||||
|
|
||||||
|
useEffect(() => { setAccountIds({}) }, [project])
|
||||||
|
|
||||||
|
const candOptions = useMemo(() => ({ docId, mode: candMode, searchFields: SEARCH_FIELDS[candFields].fields }),
|
||||||
|
[docId, candMode, candFields])
|
||||||
|
const fetchCandidates = useMemo(() => accountCandidatePager(
|
||||||
|
params => api.rdmProjectCommand<RdmAccountPage>(project, 'rdm.account.list', params), projectId, candOptions,
|
||||||
|
), [project, candOptions])
|
||||||
|
|
||||||
|
const onCell = (id: string, owner: string, duty: CellDuty) => {
|
||||||
|
setPreview([])
|
||||||
|
setConfirm(false)
|
||||||
|
setDocs(current => editCell(current, id, owner, duty))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCandidate = (id: string, candidate: PersonPickItem, duty: Exclude<CellDuty, 'none'>) => {
|
||||||
|
setAccountIds(current => ({ ...current, [candidate.owner]: candidate.id }))
|
||||||
|
onCell(id, candidate.owner, duty)
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleDocs = docs.filter(doc => {
|
||||||
|
const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase()
|
||||||
|
if (query && !text.includes(query.trim().toLowerCase())) return false
|
||||||
|
if (stage && doc.stage !== stage) return false
|
||||||
|
if (typeKey && doc.typeKey !== typeKey) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const payload = savePayload(docsRef.current, confirm, accountIds)
|
||||||
|
if (payload.issues.length > 0) {
|
||||||
|
setErr(payload.issues.join(';'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const request = ++seq.current
|
||||||
|
const ticket = ++op.current
|
||||||
|
setBusy(true); setErr(''); setNotice('')
|
||||||
|
let sent = false
|
||||||
|
try {
|
||||||
|
const looked = await api.rdmProjectCommand<PreviewOut>(project, 'rdm.duty.preview', dutyPreviewPayload(projectId, payload.docs))
|
||||||
|
if (request !== seq.current) return
|
||||||
|
setPreview(looked.docs ?? [])
|
||||||
|
if (!looked.ok) {
|
||||||
|
setErr('预览未通过,未写入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((looked.docs ?? []).some(item => item.needsConfirm) && !confirm) {
|
||||||
|
setErr('在途审签将失效。确认后再保存。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sent = true
|
||||||
|
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm, accountIds).docs))
|
||||||
|
if (request !== seq.current) return
|
||||||
|
setNotice('已保存文档职责')
|
||||||
|
setPreview([])
|
||||||
|
setConfirm(false)
|
||||||
|
setDocs(current => current.map(doc => ({ ...doc, dirty: false })))
|
||||||
|
await load(docCursor, request)
|
||||||
|
} catch (error) {
|
||||||
|
if (request !== seq.current) return
|
||||||
|
if (!sent) {
|
||||||
|
setErr(String(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setErr(`保存结果未确认:${error}`)
|
||||||
|
setConfirm(false)
|
||||||
|
await load(docCursor, request, true)
|
||||||
|
} finally {
|
||||||
|
if (ticket === op.current) setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanLegacy = async (request = ++seq.current) => {
|
||||||
|
try {
|
||||||
|
const out = await api.rdmProjectCommand<LegacyOut>(project, 'rdm.duty.legacy.scan', dutyLegacyPayload(projectId))
|
||||||
|
if (request !== seq.current) return
|
||||||
|
setLegacy(out.findings ?? [])
|
||||||
|
} catch (error) {
|
||||||
|
if (request !== seq.current) return
|
||||||
|
setErr(String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyIdentical = async () => {
|
||||||
|
const request = ++seq.current
|
||||||
|
const ticket = ++op.current
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.rdmProjectCommand(project, 'rdm.duty.legacy.apply', dutyLegacyPayload(projectId))
|
||||||
|
if (request !== seq.current) return
|
||||||
|
setNotice('已归并无歧义的章节职责')
|
||||||
|
await scanLegacy(request)
|
||||||
|
await load(docCursor, request)
|
||||||
|
} catch (error) {
|
||||||
|
if (request !== seq.current) return
|
||||||
|
setErr(String(error))
|
||||||
|
} finally {
|
||||||
|
if (ticket === op.current) setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabled = busy || !!writeError || !canAssign
|
||||||
|
const ambiguous = legacy.filter(item => item.kind !== 'identical')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="ad-hint">本页只分配已有文档的职责,不创建或生成文档。</p>
|
||||||
|
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||||
|
<div className="ad-row" role="tablist" aria-label="职责视图">
|
||||||
|
<button className="ad-mini" role="tab" aria-selected={view === 'member'} onClick={() => setView('member')}>按人员</button>
|
||||||
|
<button className="ad-mini" role="tab" aria-selected={view === 'document'} onClick={() => setView('document')}>按文档</button>
|
||||||
|
<button className="ad-mini" role="tab" aria-selected={view === 'matrix'} onClick={() => setView('matrix')}>矩阵</button>
|
||||||
|
</div>
|
||||||
|
<div className="ad-row">
|
||||||
|
<input aria-label="筛选文档名称或编号" value={query} placeholder="名称或编号" disabled={busy} onChange={event => { setDocCursor(''); setQuery(event.target.value) }} />
|
||||||
|
<input aria-label="筛选类型" value={typeKey} placeholder="类型" disabled={busy} onChange={event => { setDocCursor(''); setTypeKey(event.target.value) }} />
|
||||||
|
<input aria-label="筛选阶段" value={stage} placeholder="阶段" disabled={busy} onChange={event => { setDocCursor(''); setStage(event.target.value) }} />
|
||||||
|
<select aria-label="筛选当前职责" value={assignment} disabled={busy} onChange={event => { setDocCursor(''); setAssignment(event.target.value) }}>
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="assigned">已分配</option>
|
||||||
|
<option value="unassigned">未分配</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />}
|
||||||
|
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
|
||||||
|
{view === 'document' && (() => {
|
||||||
|
const doc = docs.find(item => item.docId === docId)
|
||||||
|
return doc ? (
|
||||||
|
<DutyCandidateSearch doc={doc} mode={candMode} fields={candFields} fetchPage={fetchCandidates}
|
||||||
|
reloadKey={accountSearchKey(projectId, candOptions)} disabled={disabled}
|
||||||
|
onMode={setCandMode} onFields={setCandFields} onAssign={(candidate, duty) => onCandidate(doc.docId, candidate, duty)} />
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
|
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
|
||||||
|
<div className="ad-row">
|
||||||
|
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
|
||||||
|
{nextDoc && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor(nextDoc)}>下一页文档</button>}
|
||||||
|
<Btn variant="primary" icon="check" disabled={disabled || dirtyDocs(docs).length === 0} onClick={() => { void save() }}>保存职责</Btn>
|
||||||
|
</div>
|
||||||
|
<DutyChangePreview impacts={preview} confirm={confirm} onConfirm={setConfirm} />
|
||||||
|
<Collapsible id="duty-legacy" open={legacyOpen} onToggle={setLegacyOpen} trigger="旧章节职责">
|
||||||
|
<button className="ad-mini" disabled={busy} onClick={() => { void scanLegacy() }}>扫描</button>
|
||||||
|
<button className="ad-mini" disabled={disabled} onClick={() => { void applyIdentical() }}>归并无歧义覆盖</button>
|
||||||
|
{legacy.length === 0 ? <p className="ad-hint">尚未扫描,或没有章节级覆盖。</p> : (
|
||||||
|
<ul>
|
||||||
|
{legacy.map(item => <li key={`${item.docId}-${item.sectionTitle}`}>{item.title} / {item.sectionTitle}:{item.kind === 'identical' ? '与文档职责一致' : '有歧义,需逐文档确认'}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{ambiguous.length > 0 && <p className="ad-hint">有歧义的文档不会自动覆盖。请在上面的视图里逐份确认后再保存。</p>}
|
||||||
|
</Collapsible>
|
||||||
|
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||||
|
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||||
|
{docs.length === 0 && !err && <EmptyState icon="inbox">暂无文档</EmptyState>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
||||||
|
export function humanLoginError(raw: string): string {
|
||||||
|
if (raw.includes('401')) return '用户名或密码不正确'
|
||||||
|
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||||
|
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||||
|
return raw.replace(/^\d{3}:\s*/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
|
||||||
|
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
||||||
|
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||||
|
export function humanChangePasswordError(raw: string): string {
|
||||||
|
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
||||||
|
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
||||||
|
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
||||||
|
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
|
||||||
|
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
||||||
|
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||||
|
if (raw.includes('401')) return '认证失败,请重新登录'
|
||||||
|
return raw.replace(/^\d{3}:\s*/, '')
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { AdminIssuedOneTimePassword } from '../api'
|
||||||
|
|
||||||
|
export interface GeneratedCredential {
|
||||||
|
owner: string
|
||||||
|
password: string
|
||||||
|
kind: 'initial' | 'one-time'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oneTimePasswordCredential(out: AdminIssuedOneTimePassword): GeneratedCredential {
|
||||||
|
return { owner: out.owner, password: out.oneTimePassword, kind: 'one-time' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeneratedCredentialPanel({ credential, onClose }: { credential: GeneratedCredential; onClose: () => void }) {
|
||||||
|
return <div className="ad-reset">
|
||||||
|
<p className="ad-hint">{credential.kind === 'initial'
|
||||||
|
? `初始密码仅显示一次,请立即安全交付给 ${credential.owner}。`
|
||||||
|
: `一次性登录密码仅显示一次,请立即安全交付给 ${credential.owner};使用后必须强制改密。`}</p>
|
||||||
|
<input className="lg-input ad-mono" readOnly value={credential.password} aria-label="一次性密码" />
|
||||||
|
<button type="button" className="ad-mini" onClick={onClose}>关闭</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { api, type PasswordChangeMode } from '../api'
|
||||||
|
|
||||||
|
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
||||||
|
return mode === 'current_required'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isForcedPasswordMode(mode: PasswordChangeMode | null | undefined): mode is PasswordChangeMode {
|
||||||
|
return mode === 'grant_setup' || mode === 'current_required' || mode === 'one_time_password'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForcedPasswordModeSection({ mode, current, onCurrentChange }: { mode: PasswordChangeMode | null; current: string; onCurrentChange: (value: string) => void }) {
|
||||||
|
if (mode === 'current_required') {
|
||||||
|
return <>
|
||||||
|
<label className="lg-label" htmlFor="forced-current">当前密码</label>
|
||||||
|
<input id="forced-current" className="lg-input" type="password" value={current}
|
||||||
|
onChange={e => onCurrentChange(e.target.value)} autoComplete="current-password" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
if (mode === 'grant_setup' || mode === 'one_time_password') {
|
||||||
|
return <p className="lg-hint">当前为受限设密会话,此次无需输入当前密码。</p>
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitForcedPassword(mode: PasswordChangeMode, current: string, next: string) {
|
||||||
|
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+44
-2
@@ -150,8 +150,23 @@ export interface ActivityCard {
|
|||||||
id: ID; projectId: ID; conversationId?: ID;
|
id: ID; projectId: ID; conversationId?: ID;
|
||||||
sourceRunId?: ID; anchorMessageId?: ID;
|
sourceRunId?: ID; anchorMessageId?: ID;
|
||||||
kind: CardKind; title: string; status: CardStatus; tone?: string;
|
kind: CardKind; title: string; status: CardStatus; tone?: string;
|
||||||
|
task?: TaskSnapshot;
|
||||||
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
|
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
|
||||||
pinned?: boolean; createdAt: string; updatedAt: string;
|
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkCard {
|
||||||
|
result?: string; resultVersion: number; readVersion: number; completedAt?: string;
|
||||||
|
decision?: WorkDecision;
|
||||||
|
}
|
||||||
|
export interface WorkDecision {
|
||||||
|
runId?: ID;
|
||||||
|
mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'dispatching' | 'submitted';
|
||||||
|
options?: { id: string; label: string }[];
|
||||||
|
selectedIds?: string[]; freeText?: string; respondedAt?: string;
|
||||||
|
}
|
||||||
|
export interface WorkCardAction {
|
||||||
|
resultVersion: number; selectedIds?: string[]; freeText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardPayload { card: ActivityCard }
|
export interface CardPayload { card: ActivityCard }
|
||||||
@@ -270,6 +285,8 @@ export interface SettingDesc {
|
|||||||
default?: unknown; options?: string[];
|
default?: unknown; options?: string[];
|
||||||
effect: 'hot' | 'new-run' | 'restart';
|
effect: 'hot' | 'new-run' | 'restart';
|
||||||
secret?: boolean;
|
secret?: boolean;
|
||||||
|
/** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */
|
||||||
|
scope?: 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 插件 ----
|
// ---- 插件 ----
|
||||||
@@ -335,7 +352,7 @@ export interface PluginPageDecl {
|
|||||||
|
|
||||||
export interface ParsedDocument {
|
export interface ParsedDocument {
|
||||||
meta: {
|
meta: {
|
||||||
filename: string; mime?: string; parser: string; confidence?: number;
|
filename: string; mime?: string; parser: string; confidence?: number; origin?: DocumentOrigin;
|
||||||
crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' };
|
crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' };
|
||||||
parsedAt: string;
|
parsedAt: string;
|
||||||
};
|
};
|
||||||
@@ -398,3 +415,28 @@ export interface SurfaceMeta {
|
|||||||
conversationId?: ID;
|
conversationId?: ID;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persistent work projection; host-owned identities and revisions.
|
||||||
|
export interface DocumentOrigin { artifactId: ID; contentHash: string; parserRevision: string }
|
||||||
|
export interface TaskPhase { id: string; label: string; observedAt?: string }
|
||||||
|
export interface TaskSourceRef { id: string; artifactId: ID; contentHash: string; parserRevision: string; blockIndex: number; label: string; quote?: string; availability?: string }
|
||||||
|
export interface TaskResultRef { artifactId?: ID; surfaceId?: ID; version?: string; contentHash?: string; availability: string; name?: string }
|
||||||
|
export interface TaskFinding { id: string; text: string; impact?: string; kind: string; status: string; revision: number; sources: TaskSourceRef[]; assumptions?: string; corrects?: string }
|
||||||
|
export interface TaskSnapshot {
|
||||||
|
inputs?: TaskSourceRef[]
|
||||||
|
limitations?: string[]
|
||||||
|
schemaVersion: number; taskId: ID; attemptId: ID; cardId: ID; projectId: ID; conversationId: ID;
|
||||||
|
sourceMessageId?: ID; parentRunId?: ID; runId?: ID; ownerInstanceId: string; executionEpoch?: number;
|
||||||
|
revision: number; sourceSeq: number; streamScope: string; inputRevision?: string; title: string; goal?: string;
|
||||||
|
executionState: 'queued'|'running'|'awaiting_user'|'completed'|'failed'|'interrupted'; outcomeCompleteness: 'none'|'partial'|'complete'; deliveryState: 'pending'|'available'|'reported'; phase: TaskPhase;
|
||||||
|
focus?: string; coverage?: string; remaining?: string; nextStep?: string; conclusion?: string;
|
||||||
|
findings?: TaskFinding[]; actions?: UIAction[]; resultRefs?: TaskResultRef[]; lastActivityAt?: string; lastMeaningfulUpdateAt?: string;
|
||||||
|
checkpointKeys?: Record<string,string>; checkpointCount?: number; checkpointSeq?: number;
|
||||||
|
}
|
||||||
|
export interface TaskSourceQuote { sourceId: string; blockIndex: number; quote: string }
|
||||||
|
interface WorkEvidencePremise { role: "total" | "phase" | "dependent" | "direct_relation"; sourceIndex: number; value?: number }
|
||||||
|
interface WorkFindingEvidence { mode: "direct_relation" | "difference" | "unresolved_relation"; premises: WorkEvidencePremise[]; result?: number }
|
||||||
|
export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[]; evidence?: WorkFindingEvidence }
|
||||||
|
export interface WorkCheckpointCandidate { checkpointId: string; phaseId: string; focus?: string; remaining?: string; nextStep?: string; findings: WorkFindingCandidate[] }
|
||||||
|
|
||||||
|
export interface UIAction { id: string; label: string; style?: string; command?: string }
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// 文档驾驶舱 I2 纯逻辑层测试:能力门控、筛选切换、统计呈现(真 0 vs 不可算)、
|
||||||
|
// 快照一致性/更新提示、上下文键、选择保留、树徽标。组件取数编排不在此覆盖。
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
contextKey, nodeSummaryLine, sameSnapshot, selectionSurvives, statDisplay,
|
||||||
|
STAT_FILTERS, toggleFilter, treeBadges, updateNotice,
|
||||||
|
type CockpitMeta, type CockpitStats, type CockpitTreeNode,
|
||||||
|
} from './documentCockpit'
|
||||||
|
import { cockpitReady, COCKPIT_COMMANDS } from './workspaces'
|
||||||
|
|
||||||
|
const meta = (rev: string, seq: number): CockpitMeta => ({
|
||||||
|
schemaVersion: 'document-cockpit.v1', projectId: 'p1', snapshotRevision: rev,
|
||||||
|
asOfSeq: seq, generatedAt: '2026-10-16T00:00:00Z', truncated: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cockpitReady', () => {
|
||||||
|
const all = COCKPIT_COMMANDS.map(id => ({ id, enabled: true }))
|
||||||
|
it('七命令齐备且 enabled 才就绪', () => {
|
||||||
|
expect(cockpitReady(null)).toBe(false)
|
||||||
|
expect(cockpitReady([])).toBe(false)
|
||||||
|
expect(cockpitReady(all.map(c => ({ ...c, enabled: false })))).toBe(false)
|
||||||
|
expect(cockpitReady(all.slice(0, 6))).toBe(false)
|
||||||
|
expect(cockpitReady(all)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('统计呈现:真 0 与不可算不得互相冒充', () => {
|
||||||
|
const stats: CockpitStats = {
|
||||||
|
documentCount: 0,
|
||||||
|
completion: { known: 3, total: 5, state: 'known' },
|
||||||
|
emptyNodeCount: 0,
|
||||||
|
unverifiedSourceCount: 2,
|
||||||
|
conflictCount: null as unknown as number,
|
||||||
|
pendingChangeCount: null,
|
||||||
|
updatedAt: '',
|
||||||
|
statReasons: { conflictCount: 'upstream_error', pendingChangeCount: 'not_projected_v1' },
|
||||||
|
}
|
||||||
|
it('真 0 显示 0', () => {
|
||||||
|
expect(statDisplay(stats, 'empty').value).toBe('0')
|
||||||
|
expect(statDisplay(stats, 'documents').value).toBe('0')
|
||||||
|
})
|
||||||
|
it('null 显示 — 并带原因', () => {
|
||||||
|
const d = statDisplay(stats, 'conflict')
|
||||||
|
expect(d.value).toBe('—')
|
||||||
|
expect(d.hint).toBe('upstream_error')
|
||||||
|
})
|
||||||
|
it('未纳入投影的统计显式标注原因', () => {
|
||||||
|
const d = statDisplay(stats, 'pending')
|
||||||
|
expect(d.value).toBe('—')
|
||||||
|
expect(d.hint).toBe('not_projected_v1')
|
||||||
|
})
|
||||||
|
it('完成度三分态:known/partial/unavailable', () => {
|
||||||
|
expect(statDisplay(stats, 'completion').value).toBe('3/5')
|
||||||
|
expect(statDisplay({ ...stats, completion: { known: 1, total: 5, state: 'partial' } }, 'completion').hint).toBe('部分计算')
|
||||||
|
const u = statDisplay({ ...stats, completion: { known: 0, total: 0, state: 'unavailable' } }, 'completion')
|
||||||
|
expect(u.value).toBe('—')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('筛选切换', () => {
|
||||||
|
it('点击切换、再点清除;统计键与筛选值一一对应', () => {
|
||||||
|
expect(toggleFilter('', 'state:empty')).toBe('state:empty')
|
||||||
|
expect(toggleFilter('state:empty', 'state:empty')).toBe('')
|
||||||
|
expect(toggleFilter('state:empty', 'state:conflict')).toBe('state:conflict')
|
||||||
|
expect(STAT_FILTERS.map(s => s.filter)).toEqual(['state:empty', 'state:unverified', 'state:conflict'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('快照一致性与更新提示', () => {
|
||||||
|
it('同 revision 同 seq 视为同快照', () => {
|
||||||
|
expect(sameSnapshot(meta('wm:5', 5), meta('wm:5', 5))).toBe(true)
|
||||||
|
expect(sameSnapshot(meta('wm:5', 5), meta('wm:6', 6))).toBe(false)
|
||||||
|
expect(sameSnapshot(undefined, meta('wm:5', 5))).toBe(false)
|
||||||
|
})
|
||||||
|
it('展示快照落后时提示刷新,一致时不提示', () => {
|
||||||
|
expect(updateNotice(meta('wm:5', 5), meta('wm:6', 6))).toContain('wm:6')
|
||||||
|
expect(updateNotice(meta('wm:6', 6), meta('wm:6', 6))).toBeNull()
|
||||||
|
expect(updateNotice(null, meta('wm:6', 6))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('旧响应丢弃的上下文键', () => {
|
||||||
|
it('项目与筛选任一变化都改变键', () => {
|
||||||
|
expect(contextKey('p1', '')).not.toBe(contextKey('p2', ''))
|
||||||
|
expect(contextKey('p1', '')).not.toBe(contextKey('p1', 'state:empty'))
|
||||||
|
expect(contextKey('p1', 'state:empty')).toBe(contextKey('p1', 'state:empty'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('刷新不重置选择', () => {
|
||||||
|
it('文档仍在根清单则保留', () => {
|
||||||
|
const kept = selectionSurvives({ kind: 'document', id: 'd1' }, ['d1', 'd2'])
|
||||||
|
expect(kept).toEqual({ kind: 'document', id: 'd1' })
|
||||||
|
})
|
||||||
|
it('文档被筛掉则清空', () => {
|
||||||
|
expect(selectionSurvives({ kind: 'document', id: 'd9' }, ['d1', 'd2'])).toBeNull()
|
||||||
|
})
|
||||||
|
it('章节选择随父文档存活', () => {
|
||||||
|
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d1'], 'd1')).toEqual({ kind: 'section', id: 's1' })
|
||||||
|
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d2'], 'd1')).toBeNull()
|
||||||
|
expect(selectionSurvives(null, ['d1'])).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('树徽标与状态行', () => {
|
||||||
|
const node = (over: Partial<CockpitTreeNode['state']> & { visibility?: string }): CockpitTreeNode => ({
|
||||||
|
ref: { kind: 'section', id: 's1' }, title: '章节', path: [], childCount: 0,
|
||||||
|
visibility: over.visibility || 'full',
|
||||||
|
state: {
|
||||||
|
content: 'complete', verification: 'not_applicable', sourceCount: 0,
|
||||||
|
conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true, ...over,
|
||||||
|
},
|
||||||
|
} as CockpitTreeNode)
|
||||||
|
it('诚实徽标:空/受限/未核实/冲突计数', () => {
|
||||||
|
expect(treeBadges(node({}))).toEqual([])
|
||||||
|
expect(treeBadges(node({ content: 'empty' }))).toContain('空')
|
||||||
|
expect(treeBadges(node({ verification: 'unverified' }))).toContain('未核实')
|
||||||
|
expect(treeBadges(node({ conflictCount: 2 }))).toContain('冲突 2')
|
||||||
|
expect(treeBadges(node({ visibility: 'metadata_only' }))).toContain('仅元数据')
|
||||||
|
})
|
||||||
|
it('结构缺口显式标注(冻结事实:doc→section 两级)', () => {
|
||||||
|
const line = nodeSummaryLine(node({ content: 'empty' }))
|
||||||
|
expect(line).toContain('内容为空')
|
||||||
|
expect(line).toContain('层级止于章节')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- I3:阅读器与右栏纯逻辑 ----
|
||||||
|
import {
|
||||||
|
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
|
||||||
|
prefsKey, mergePrefs, loadPrefs,
|
||||||
|
type CockpitHistorySummary, type CockpitVersionSummary,
|
||||||
|
} from './documentCockpit'
|
||||||
|
|
||||||
|
describe('偏好:按用户+项目隔离,枚举外脏值回落默认', () => {
|
||||||
|
it('prefsKey 区分用户与项目', () => {
|
||||||
|
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('a', 'p2'))
|
||||||
|
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('b', 'p1'))
|
||||||
|
})
|
||||||
|
it('mergePrefs 逐项校验', () => {
|
||||||
|
expect(mergePrefs(null)).toEqual({ lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' })
|
||||||
|
expect(mergePrefs({ lastView: 'reader', density: 'full', rightPanelTab: 'conflicts' })).toEqual({
|
||||||
|
lastView: 'reader', density: 'full', rightPanelTab: 'conflicts',
|
||||||
|
})
|
||||||
|
const junk = mergePrefs({ lastView: 'galaxy', density: 42, rightPanelTab: 'nope' })
|
||||||
|
expect(junk.lastView).toBe('panorama')
|
||||||
|
expect(junk.density).toBe('compact')
|
||||||
|
expect(junk.rightPanelTab).toBe('sources')
|
||||||
|
})
|
||||||
|
it('loadPrefs 在无 localStorage 环境安全回落', () => {
|
||||||
|
expect(loadPrefs('a', 'p1').lastView).toBe('panorama')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('版本摘要与行级差异', () => {
|
||||||
|
const v = (seq: number, content?: string, redacted?: boolean): CockpitVersionSummary => ({
|
||||||
|
seq, version: `v${seq}`, at: '2026-10-16T00:00:00Z', basisCount: 0, content, redacted,
|
||||||
|
})
|
||||||
|
it('当前+前一版;受限整体降级', () => {
|
||||||
|
const s = versionSummary([v(3, '三'), v(2, '二'), v(1, '一')])
|
||||||
|
expect(s.current?.version).toBe('v3')
|
||||||
|
expect(s.previous?.version).toBe('v2')
|
||||||
|
expect(s.redacted).toBe(false)
|
||||||
|
const r = versionSummary([v(3, undefined, true)])
|
||||||
|
expect(r.redacted).toBe(true)
|
||||||
|
expect(versionSummary(undefined).current).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('diffLines 标记新增/删除/保留', () => {
|
||||||
|
const d = diffLines('共同\n新增行', '共同\n删除行')
|
||||||
|
expect(d).toContainEqual({ flag: '=', text: '共同' })
|
||||||
|
expect(d).toContainEqual({ flag: '+', text: '新增行' })
|
||||||
|
expect(d).toContainEqual({ flag: '-', text: '删除行' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('历史严格合并:乱序与重复丢弃(DV-R05)', () => {
|
||||||
|
const h = (seq: number): CockpitHistorySummary => ({ seq, at: '', op: 'x', summary: `s${seq}` })
|
||||||
|
it('只接受 seq 更大的条目', () => {
|
||||||
|
const r = mergeHistoryStrict([h(10), h(9)], [h(11), h(8)])
|
||||||
|
expect(r.items.map(i => i.seq)).toEqual([10, 9, 11])
|
||||||
|
expect(r.dropped).toBe(1)
|
||||||
|
})
|
||||||
|
it('重复 seq 丢弃且计入 dropped', () => {
|
||||||
|
const r = mergeHistoryStrict([h(10)], [h(10), h(11)])
|
||||||
|
expect(r.items.map(i => i.seq)).toEqual([10, 11])
|
||||||
|
expect(r.dropped).toBe(1)
|
||||||
|
})
|
||||||
|
it('空历史接受全部', () => {
|
||||||
|
expect(mergeHistoryStrict([], [h(1), h(2)]).items.map(i => i.seq)).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
it('historyLine 带操作者与本地时间', () => {
|
||||||
|
expect(historyLine(h(1))).toContain('s1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('阅读器装配:受限章节给占位不冒充空内容', () => {
|
||||||
|
const sec = (id: string, content: string): CockpitTreeNode => ({
|
||||||
|
ref: { kind: 'section', id }, title: id, path: [], childCount: 0,
|
||||||
|
state: { content, verification: 'not_applicable', sourceCount: 0, conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true },
|
||||||
|
visibility: 'full',
|
||||||
|
})
|
||||||
|
it('正文/受限/未加载三分态', () => {
|
||||||
|
const out = assembleReader(
|
||||||
|
[sec('a', 'complete'), sec('b', 'complete'), sec('c', 'empty')],
|
||||||
|
{
|
||||||
|
a: { body: [{ kind: 'text', text: '正文A' }] },
|
||||||
|
b: { body: [{ kind: 'restricted', reason: 'section-duty-or-authorship' }] },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(out[0].blocks).toEqual([{ kind: 'text', text: '正文A' }])
|
||||||
|
expect(out[1].blocks[0].kind).toBe('restricted')
|
||||||
|
expect(out[2].blocks[0].reason).toBe('not_loaded')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
// 文档驾驶舱 I2 纯逻辑层(rollout I2;契约:Dev 仓 docs/superpowers/specs
|
||||||
|
// 2026-10-16-document-visualization-read-model-contract.md @ 37b272ba,
|
||||||
|
// 四项取舍冻结见 plans/…-rollout.md §7)。只放可测判定:筛选切换、统计呈现
|
||||||
|
//(真 0 vs 不可算)、快照一致性与更新提示、上下文键与选择保留。组件只做取数编排与渲染。
|
||||||
|
|
||||||
|
// ---- 契约类型(I1 后端 DTO 的前端精简形态) ----
|
||||||
|
|
||||||
|
export interface CockpitNodeRef { kind: string; id: string; version?: string }
|
||||||
|
|
||||||
|
export interface CockpitMeta {
|
||||||
|
schemaVersion: string
|
||||||
|
projectId: string
|
||||||
|
snapshotRevision: string
|
||||||
|
asOfSeq: number
|
||||||
|
generatedAt: string
|
||||||
|
truncated: boolean
|
||||||
|
truncationReasons?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitNodeState {
|
||||||
|
content: string // complete|partial|empty|inaccessible|unavailable
|
||||||
|
verification: string // verified|mixed|unverified|disputed|not_applicable
|
||||||
|
sourceCount: number
|
||||||
|
conflictCount: number
|
||||||
|
impactCount: number
|
||||||
|
missingChildCount: number
|
||||||
|
structuralGap: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitTreeNode {
|
||||||
|
ref: CockpitNodeRef
|
||||||
|
title: string
|
||||||
|
path: string[]
|
||||||
|
parent?: CockpitNodeRef
|
||||||
|
childCount: number
|
||||||
|
state: CockpitNodeState
|
||||||
|
lastChangedAt?: string
|
||||||
|
lastChangedBy?: string
|
||||||
|
visibility: string // full|redacted|metadata_only
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitStats {
|
||||||
|
documentCount: number
|
||||||
|
completion: { known: number; total: number; state: string }
|
||||||
|
emptyNodeCount: number
|
||||||
|
unverifiedSourceCount: number
|
||||||
|
conflictCount: number
|
||||||
|
pendingChangeCount: number | null
|
||||||
|
updatedAt?: string
|
||||||
|
statReasons: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitChangeSummary {
|
||||||
|
node: CockpitNodeRef
|
||||||
|
title: string
|
||||||
|
changedAt: string
|
||||||
|
actor?: string
|
||||||
|
kind: string
|
||||||
|
summary: string
|
||||||
|
version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitOverview {
|
||||||
|
meta: CockpitMeta
|
||||||
|
project: { title: string }
|
||||||
|
stats: CockpitStats
|
||||||
|
documents: CockpitTreeNode[]
|
||||||
|
recentChanges: CockpitChangeSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitTreePage {
|
||||||
|
meta: CockpitMeta
|
||||||
|
items: CockpitTreeNode[]
|
||||||
|
nextCursor?: string
|
||||||
|
hasMore: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 统计带:点击即切换共享筛选(视图规格 §2;未知筛选由后端 400 拒绝) ----
|
||||||
|
|
||||||
|
export const STAT_FILTERS: { key: string; label: string; filter: string }[] = [
|
||||||
|
{ key: 'empty', label: '空缺节点', filter: 'state:empty' },
|
||||||
|
{ key: 'unverified', label: '未核实来源', filter: 'state:unverified' },
|
||||||
|
{ key: 'conflict', label: '冲突', filter: 'state:conflict' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 点击统计卡:同筛选再次点击=清除(只定位,不执行写入)。 */
|
||||||
|
export function toggleFilter(current: string, filter: string): string {
|
||||||
|
return current === filter ? '' : filter
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计呈现:null(不可算)显示 — 加原因;真 0 显示 0。两侧都不得互相冒充(契约 §2/§6)。 */
|
||||||
|
export function statDisplay(stats: CockpitStats, key: string): { value: string; hint?: string } {
|
||||||
|
const reasons = stats.statReasons || {}
|
||||||
|
if (key === 'documents') return { value: String(stats.documentCount) }
|
||||||
|
if (key === 'completion') {
|
||||||
|
const c = stats.completion
|
||||||
|
if (c.state === 'unavailable') return { value: '—', hint: reasons['completion'] || '无法计算' }
|
||||||
|
if (c.state === 'partial') return { value: `${c.known}/${c.total}`, hint: '部分计算' }
|
||||||
|
return { value: `${c.known}/${c.total}` }
|
||||||
|
}
|
||||||
|
const map: Record<string, number | null | undefined> = {
|
||||||
|
empty: stats.emptyNodeCount,
|
||||||
|
unverified: stats.unverifiedSourceCount,
|
||||||
|
conflict: stats.conflictCount,
|
||||||
|
pending: stats.pendingChangeCount,
|
||||||
|
}
|
||||||
|
const v = map[key]
|
||||||
|
// 原因表按后端 DTO 字段名(statReasons 键=字段名),展示键需映射。
|
||||||
|
const reasonKey: Record<string, string> = {
|
||||||
|
empty: 'emptyNodeCount', unverified: 'unverifiedSourceCount',
|
||||||
|
conflict: 'conflictCount', pending: 'pendingChangeCount',
|
||||||
|
}
|
||||||
|
if (v === null || v === undefined) return { value: '—', hint: reasons[reasonKey[key] || key] || '暂不可计算' }
|
||||||
|
return { value: String(v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 快照一致性(DV-R05 前端侧):统计与树徽标同快照才同屏可信 ----
|
||||||
|
|
||||||
|
export function sameSnapshot(a?: CockpitMeta, b?: CockpitMeta): boolean {
|
||||||
|
if (!a || !b) return false
|
||||||
|
return a.snapshotRevision === b.snapshotRevision && a.asOfSeq === b.asOfSeq
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新提示:展示中快照落后于最新已见快照时提示刷新(不抢占滚动,视图规格 §5)。 */
|
||||||
|
export function updateNotice(viewed: CockpitMeta | null, latest: CockpitMeta | null): string | null {
|
||||||
|
if (!viewed || !latest) return null
|
||||||
|
if (viewed.snapshotRevision === latest.snapshotRevision && viewed.asOfSeq === latest.asOfSeq) return null
|
||||||
|
return `内容已更新(快照 ${latest.snapshotRevision})——刷新查看最新状态`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上下文键:项目+筛选标识一轮取数上下文;不匹配的旧响应直接丢弃(契约 §8)。 */
|
||||||
|
export function contextKey(projectId: string, filter: string): string {
|
||||||
|
return `${projectId}\u0001${filter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新不重置选择:文档仍在根清单(或选中章节的父文档仍在)则保留,否则清空。 */
|
||||||
|
export function selectionSurvives(
|
||||||
|
selected: CockpitNodeRef | null,
|
||||||
|
rootDocIds: string[],
|
||||||
|
parentDocId?: string,
|
||||||
|
): CockpitNodeRef | null {
|
||||||
|
if (!selected) return null
|
||||||
|
if (selected.kind === 'document') return rootDocIds.includes(selected.id) ? selected : null
|
||||||
|
if (parentDocId && rootDocIds.includes(parentDocId)) return selected
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 树行徽标(诚实状态:空/部分/受限/未核实/冲突计数;不虚构成功)。 */
|
||||||
|
export function treeBadges(n: CockpitTreeNode): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
if (n.state.content === 'empty') out.push('空')
|
||||||
|
else if (n.state.content === 'partial') out.push('部分')
|
||||||
|
else if (n.state.content === 'inaccessible') out.push('受限')
|
||||||
|
if (n.visibility === 'metadata_only') out.push('仅元数据')
|
||||||
|
if (n.state.verification === 'unverified') out.push('未核实')
|
||||||
|
if (n.state.conflictCount > 0) out.push(`冲突 ${n.state.conflictCount}`)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 节点状态一句话总结(全景 §4.2)。 */
|
||||||
|
export function nodeSummaryLine(n: CockpitTreeNode): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
const content = { complete: '内容完整', partial: '内容部分', empty: '内容为空', inaccessible: '内容受限', unavailable: '暂不可读' }[n.state.content] || n.state.content
|
||||||
|
parts.push(content)
|
||||||
|
if (n.state.structuralGap) parts.push('层级止于章节')
|
||||||
|
if (n.state.sourceCount > 0) parts.push(`来源 ${n.state.sourceCount}`)
|
||||||
|
if (n.state.conflictCount > 0) parts.push(`冲突 ${n.state.conflictCount}`)
|
||||||
|
if (n.state.impactCount > 0) parts.push(`影响 ${n.state.impactCount}`)
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- I3:阅读器与右栏纯逻辑 ----
|
||||||
|
|
||||||
|
export interface CockpitVersionSummary {
|
||||||
|
seq: number
|
||||||
|
version: string
|
||||||
|
actor?: string
|
||||||
|
at: string
|
||||||
|
basisCount: number
|
||||||
|
content?: string
|
||||||
|
redacted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitContentBlock { kind: string; text?: string; reason?: string }
|
||||||
|
|
||||||
|
export interface CockpitSourceSummary {
|
||||||
|
ref: { kind: string; id: string }
|
||||||
|
label: string
|
||||||
|
capturedAt?: string
|
||||||
|
confidence: string
|
||||||
|
verification: string
|
||||||
|
visibility: string
|
||||||
|
redactedReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitHistorySummary {
|
||||||
|
seq: number
|
||||||
|
at: string
|
||||||
|
op: string
|
||||||
|
actor?: string
|
||||||
|
summary: string
|
||||||
|
operationId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitImpactSummary {
|
||||||
|
ref: { kind: string; id: string }
|
||||||
|
edgeType: string
|
||||||
|
edgeState: string
|
||||||
|
direction: string
|
||||||
|
layer: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitAssertion { node: CockpitNodeRef; claim: string; visibility: string; redactedReason?: string }
|
||||||
|
|
||||||
|
export interface CockpitConflictSummary {
|
||||||
|
id: string
|
||||||
|
node: CockpitNodeRef
|
||||||
|
state: string
|
||||||
|
left: CockpitAssertion
|
||||||
|
right: CockpitAssertion
|
||||||
|
detectedAt: string
|
||||||
|
resolutionRef?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CockpitPage<T> { meta?: CockpitMeta; items: T[]; nextCursor?: string; hasMore: boolean; truncated?: boolean }
|
||||||
|
|
||||||
|
export const PANEL_TABS = [
|
||||||
|
{ id: 'sources', label: '内容来源' },
|
||||||
|
{ id: 'history', label: '修改历史' },
|
||||||
|
{ id: 'impacts', label: '影响对象' },
|
||||||
|
{ id: 'conflicts', label: '冲突与风险' },
|
||||||
|
] as const
|
||||||
|
export type PanelTabId = (typeof PANEL_TABS)[number]['id']
|
||||||
|
|
||||||
|
export type ViewMode = 'panorama' | 'reader'
|
||||||
|
export type Density = 'compact' | 'full'
|
||||||
|
|
||||||
|
export interface CockpitPrefs {
|
||||||
|
lastView: ViewMode
|
||||||
|
density: Density
|
||||||
|
rightPanelTab: PanelTabId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 偏好按 用户+项目 隔离(设计 §7.3);仅本地存储,不构成授权。 */
|
||||||
|
export function prefsKey(actor: string, projectId: string): string {
|
||||||
|
return `cockpit-prefs:${actor}:${projectId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析偏好:枚举外的脏值逐项回落默认(panorama+compact+sources)。 */
|
||||||
|
export function mergePrefs(raw: unknown): CockpitPrefs {
|
||||||
|
const base: CockpitPrefs = { lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' }
|
||||||
|
if (!raw || typeof raw !== 'object') return base
|
||||||
|
const r = raw as Record<string, unknown>
|
||||||
|
const out = { ...base }
|
||||||
|
if (r.lastView === 'panorama' || r.lastView === 'reader') out.lastView = r.lastView
|
||||||
|
if (r.density === 'compact' || r.density === 'full') out.density = r.density
|
||||||
|
if (PANEL_TABS.some(t => t.id === r.rightPanelTab)) out.rightPanelTab = r.rightPanelTab as PanelTabId
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPrefs(actor: string, projectId: string): CockpitPrefs {
|
||||||
|
try {
|
||||||
|
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(prefsKey(actor, projectId)) : null
|
||||||
|
return mergePrefs(raw ? JSON.parse(raw) : null)
|
||||||
|
} catch {
|
||||||
|
return mergePrefs(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePrefs(actor: string, projectId: string, prefs: CockpitPrefs): void {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem(prefsKey(actor, projectId), JSON.stringify(prefs))
|
||||||
|
} catch { /* 存储不可用时偏好静默降级为会话内 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 版本摘要:当前版本 + 前一版本(视图规格 §5)。versions 为最新在前;受限版本无 content。 */
|
||||||
|
export function versionSummary(versions: CockpitVersionSummary[] | undefined): {
|
||||||
|
current?: CockpitVersionSummary
|
||||||
|
previous?: CockpitVersionSummary
|
||||||
|
redacted: boolean
|
||||||
|
} {
|
||||||
|
if (!versions || versions.length === 0) return { redacted: false }
|
||||||
|
const current = versions[0]
|
||||||
|
const previous = versions.length > 1 ? versions[1] : undefined
|
||||||
|
return { current, previous, redacted: !!current.redacted }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 历史面板合并:只接受严格更新(seq 更大且未见)的条目;过期与重复都计入 dropped(DV-R05/契约 §8)。 */
|
||||||
|
export function mergeHistoryStrict(existing: CockpitHistorySummary[], incoming: CockpitHistorySummary[]): {
|
||||||
|
items: CockpitHistorySummary[]
|
||||||
|
dropped: number
|
||||||
|
} {
|
||||||
|
const seen = new Set(existing.map(e => e.seq))
|
||||||
|
const maxSeq = existing.length > 0 ? Math.max(...seen) : -1
|
||||||
|
const items = [...existing]
|
||||||
|
let dropped = 0
|
||||||
|
for (const e of incoming) {
|
||||||
|
if (e.seq <= maxSeq || seen.has(e.seq)) {
|
||||||
|
dropped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(e.seq)
|
||||||
|
items.push(e)
|
||||||
|
}
|
||||||
|
return { items, dropped }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阅读器装配:把文档的章节详情按树顺序组装为连续正文;受限章节给占位说明而非空白。 */
|
||||||
|
export function assembleReader(
|
||||||
|
sections: CockpitTreeNode[],
|
||||||
|
details: Record<string, { body?: CockpitContentBlock[]; versions?: CockpitVersionSummary[] } | undefined>,
|
||||||
|
): { ref: CockpitNodeRef; title: string; contentState: string; blocks: CockpitContentBlock[]; versions?: CockpitVersionSummary[] }[] {
|
||||||
|
return sections.map(sec => {
|
||||||
|
const d = details[sec.ref.id]
|
||||||
|
const readable = !!d?.body && d.body.length > 0 && d.body[0].kind === 'text'
|
||||||
|
const restricted = !!d?.body && d.body.length > 0 && d.body[0].kind === 'restricted'
|
||||||
|
const blocks: CockpitContentBlock[] = readable
|
||||||
|
? (d!.body as CockpitContentBlock[])
|
||||||
|
: restricted
|
||||||
|
? [{ kind: 'restricted', reason: d?.body?.[0]?.reason || 'section-duty-or-authorship' }]
|
||||||
|
: [{ kind: 'restricted', reason: 'not_loaded' }]
|
||||||
|
return { ref: sec.ref, title: sec.title, contentState: sec.state.content, blocks, versions: d?.versions }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行级差异(阅读器 Collapsible 的完整对比):按行对齐的朴素 LCS 免实现——
|
||||||
|
* 以行为单位做集合比对标记 +/=/-,足够呈现「完整差异」而不冒充精确补丁。 */
|
||||||
|
export function diffLines(current: string, previous: string): { flag: '+' | '-' | '='; text: string }[] {
|
||||||
|
const cur = current.split('\n')
|
||||||
|
const prev = new Set(previous.split('\n'))
|
||||||
|
const curSet = new Set(cur)
|
||||||
|
const out: { flag: '+' | '-' | '='; text: string }[] = []
|
||||||
|
for (const line of cur) out.push({ flag: prev.has(line) ? '=' : '+', text: line })
|
||||||
|
for (const line of previous.split('\n')) if (!curSet.has(line)) out.push({ flag: '-', text: line })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 历史条目的自然语言时间线文本(右栏 §6)。 */
|
||||||
|
export function historyLine(h: CockpitHistorySummary): string {
|
||||||
|
const who = h.actor ? `${h.actor} ` : ''
|
||||||
|
return `${h.summary}(${who}${new Date(h.at).toLocaleString('zh-CN')})`
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,207 @@
|
|||||||
|
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
|
||||||
|
import { dutyAccountIds, type DutyAccountIds } from '../rdmManagement'
|
||||||
|
|
||||||
|
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
|
||||||
|
|
||||||
|
interface DutyRoles {
|
||||||
|
author: string
|
||||||
|
reviewers: string[]
|
||||||
|
approver: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DutyDocDraft extends DutyRoles {
|
||||||
|
docId: string
|
||||||
|
title: string
|
||||||
|
code: string
|
||||||
|
typeKey: string
|
||||||
|
stage: string
|
||||||
|
dutyVersion: number
|
||||||
|
activeRound: number
|
||||||
|
legacySectionOverrides: number
|
||||||
|
dirty: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DutyMember {
|
||||||
|
owner: string
|
||||||
|
role: string
|
||||||
|
valid: boolean
|
||||||
|
canAuthor: boolean
|
||||||
|
canReview: boolean
|
||||||
|
canApprove: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DutySaveItem {
|
||||||
|
docId: string
|
||||||
|
author: string
|
||||||
|
reviewers: string[]
|
||||||
|
approver: string
|
||||||
|
expectedDutyVersion: number
|
||||||
|
confirmInvalidation: boolean
|
||||||
|
accountIds?: DutyAccountIds
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
|
||||||
|
if (roles.author === owner) return 'author'
|
||||||
|
if (roles.approver === owner) return 'approver'
|
||||||
|
if (roles.reviewers.includes(owner)) return 'reviewer'
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyCell(roles: DutyRoles, owner: string, duty: CellDuty): {
|
||||||
|
roles: DutyRoles
|
||||||
|
replacedAuthor: string
|
||||||
|
replacedApprover: string
|
||||||
|
} {
|
||||||
|
const replacedAuthor = duty === 'author' && roles.author !== '' && roles.author !== owner ? roles.author : ''
|
||||||
|
const replacedApprover = duty === 'approver' && roles.approver !== '' && roles.approver !== owner ? roles.approver : ''
|
||||||
|
let author = roles.author === owner ? '' : roles.author
|
||||||
|
let approver = roles.approver === owner ? '' : roles.approver
|
||||||
|
let reviewers = roles.reviewers.filter(person => person !== owner)
|
||||||
|
switch (duty) {
|
||||||
|
case 'author':
|
||||||
|
author = owner
|
||||||
|
break
|
||||||
|
case 'approver':
|
||||||
|
approver = owner
|
||||||
|
break
|
||||||
|
case 'reviewer':
|
||||||
|
reviewers = [...reviewers, owner]
|
||||||
|
break
|
||||||
|
case 'none':
|
||||||
|
break
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = duty
|
||||||
|
throw new Error(exhaustive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { roles: { author, reviewers, approver }, replacedAuthor, replacedApprover }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replacementNote(roles: DutyRoles, owner: string, duty: CellDuty): string {
|
||||||
|
if (duty === 'author' && roles.author !== '' && roles.author !== owner) return `将替换${roles.author}`
|
||||||
|
if (duty === 'approver' && roles.approver !== '' && roles.approver !== owner) return `将替换${roles.approver}`
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function dutyConflicts(roles: DutyRoles): number {
|
||||||
|
const seen = new Map<string, number>()
|
||||||
|
const mark = (owner: string) => {
|
||||||
|
if (!owner) return
|
||||||
|
seen.set(owner, (seen.get(owner) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
mark(roles.author)
|
||||||
|
mark(roles.approver)
|
||||||
|
for (const reviewer of roles.reviewers) mark(reviewer)
|
||||||
|
let conflicts = 0
|
||||||
|
for (const count of seen.values()) if (count > 1) conflicts += 1
|
||||||
|
return conflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeness(roles: DutyRoles): { authors: number; reviewers: number; approvers: number; conflicts: number } {
|
||||||
|
return {
|
||||||
|
authors: roles.author ? 1 : 0,
|
||||||
|
reviewers: roles.reviewers.length,
|
||||||
|
approvers: roles.approver ? 1 : 0,
|
||||||
|
conflicts: dutyConflicts(roles),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveIssues(roles: DutyRoles): string[] {
|
||||||
|
const issues: string[] = []
|
||||||
|
if (!roles.author) issues.push('需指定编制人')
|
||||||
|
if (roles.reviewers.length === 0) issues.push('审核至少一人')
|
||||||
|
if (!roles.approver) issues.push('需指定批准人')
|
||||||
|
if (dutyConflicts(roles) > 0) issues.push('一人只能担一个职责')
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
function rolesFromCells(docId: string, cells: { docId: string; owner: string; duty: string }[]): DutyRoles {
|
||||||
|
const roles: DutyRoles = { author: '', reviewers: [], approver: '' }
|
||||||
|
for (const cell of cells) {
|
||||||
|
if (cell.docId !== docId) continue
|
||||||
|
if (cell.duty !== 'none' && cell.duty !== 'author' && cell.duty !== 'reviewer' && cell.duty !== 'approver') continue
|
||||||
|
const next = applyCell(roles, cell.owner, cell.duty)
|
||||||
|
roles.author = next.roles.author
|
||||||
|
roles.reviewers = next.roles.reviewers
|
||||||
|
roles.approver = next.roles.approver
|
||||||
|
}
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
export function absorbServerDocs(
|
||||||
|
current: DutyDocDraft[],
|
||||||
|
docs: {
|
||||||
|
docId: string; title: string; code?: string; typeKey: string; stage?: string
|
||||||
|
dutyVersion: number; activeRound?: number; legacySectionOverrides?: number
|
||||||
|
}[],
|
||||||
|
cells: { docId: string; owner: string; duty: string }[],
|
||||||
|
replaceDirty = false,
|
||||||
|
): DutyDocDraft[] {
|
||||||
|
const byId = new Map(current.map(doc => [doc.docId, doc]))
|
||||||
|
const pageIds = new Set(docs.map(doc => doc.docId))
|
||||||
|
const next: DutyDocDraft[] = []
|
||||||
|
for (const doc of docs) {
|
||||||
|
const previous = byId.get(doc.docId)
|
||||||
|
const serverRoles = rolesFromCells(doc.docId, cells)
|
||||||
|
const keepDraft = !replaceDirty && previous?.dirty === true
|
||||||
|
next.push({
|
||||||
|
docId: doc.docId,
|
||||||
|
title: doc.title,
|
||||||
|
code: doc.code ?? doc.docId,
|
||||||
|
typeKey: doc.typeKey,
|
||||||
|
stage: doc.stage ?? '',
|
||||||
|
dutyVersion: keepDraft ? previous.dutyVersion : doc.dutyVersion,
|
||||||
|
activeRound: doc.activeRound ?? 0,
|
||||||
|
legacySectionOverrides: doc.legacySectionOverrides ?? 0,
|
||||||
|
dirty: keepDraft,
|
||||||
|
author: keepDraft ? previous.author : serverRoles.author,
|
||||||
|
reviewers: keepDraft ? previous.reviewers : serverRoles.reviewers,
|
||||||
|
approver: keepDraft ? previous.approver : serverRoles.approver,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const doc of current) {
|
||||||
|
if (!replaceDirty && doc.dirty && !pageIds.has(doc.docId)) next.push(doc)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editCell(docs: DutyDocDraft[], docId: string, owner: string, duty: CellDuty): DutyDocDraft[] {
|
||||||
|
return docs.map(doc => {
|
||||||
|
if (doc.docId !== docId) return doc
|
||||||
|
const next = applyCell(doc, owner, duty)
|
||||||
|
return { ...doc, ...next.roles, dirty: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
|
||||||
|
return docs.filter(doc => doc.dirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** accountIds:候选检索选中的 owner → userId;每份文档只携带其职责涉及的人。 */
|
||||||
|
export function savePayload(
|
||||||
|
docs: DutyDocDraft[], confirmInvalidation: boolean, accountIds?: DutyAccountIds,
|
||||||
|
): { docs: DutySaveItem[]; issues: string[] } {
|
||||||
|
const dirty = dirtyDocs(docs)
|
||||||
|
const issues: string[] = []
|
||||||
|
if (dirty.length === 0) issues.push('没有未保存的职责')
|
||||||
|
if (dirty.length > 20) issues.push('一次保存最多 20 份文档')
|
||||||
|
for (const doc of dirty) {
|
||||||
|
for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
docs: dirty.slice(0, 20).map(doc => {
|
||||||
|
const ids = dutyAccountIds(doc, accountIds)
|
||||||
|
return {
|
||||||
|
docId: doc.docId,
|
||||||
|
author: doc.author,
|
||||||
|
reviewers: doc.reviewers,
|
||||||
|
approver: doc.approver,
|
||||||
|
expectedDutyVersion: doc.dutyVersion,
|
||||||
|
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
|
||||||
|
...(ids ? { accountIds: ids } : {}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
issues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Picker } from '@ui'
|
||||||
|
import type { CellDuty, DutyDocDraft, DutyMember } from './draft'
|
||||||
|
import { cellDuty, completeness, replacementNote, saveIssues } from './draft'
|
||||||
|
import {
|
||||||
|
MATCH_MODE_LABELS, SEARCH_FIELDS,
|
||||||
|
type MemberMatchMode, type PersonPickItem, type SearchFieldsChoice,
|
||||||
|
} from '../rdmManagement'
|
||||||
|
|
||||||
|
const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [
|
||||||
|
{ value: 'none', label: '无职责' },
|
||||||
|
{ value: 'author', label: '编制' },
|
||||||
|
{ value: 'reviewer', label: '审核' },
|
||||||
|
{ value: 'approver', label: '批准' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function DutySelect({
|
||||||
|
doc, owner, disabled, onChange,
|
||||||
|
}: {
|
||||||
|
doc: DutyDocDraft
|
||||||
|
owner: string
|
||||||
|
disabled: boolean
|
||||||
|
onChange: (duty: CellDuty) => void
|
||||||
|
}) {
|
||||||
|
const current = cellDuty(doc, owner)
|
||||||
|
return (
|
||||||
|
<label className="duty-cell">
|
||||||
|
<select
|
||||||
|
aria-label={`${owner} 在 ${doc.title} 的职责`}
|
||||||
|
value={current}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={event => {
|
||||||
|
const value = event.target.value
|
||||||
|
if (value === 'none' || value === 'author' || value === 'reviewer' || value === 'approver') onChange(value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DUTY_OPTIONS.map(option => {
|
||||||
|
const hint = replacementNote(doc, owner, option.value)
|
||||||
|
return <option key={option.value} value={option.value}>{hint ? `${option.label}(${hint})` : option.label}</option>
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DutyByMemberView({
|
||||||
|
member, members, docs, disabled, onMember, onCell,
|
||||||
|
}: {
|
||||||
|
member: string
|
||||||
|
members: DutyMember[]
|
||||||
|
docs: DutyDocDraft[]
|
||||||
|
disabled: boolean
|
||||||
|
onMember: (owner: string) => void
|
||||||
|
onCell: (docId: string, owner: string, duty: CellDuty) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="setting-row">
|
||||||
|
<label htmlFor="duty-member">成员</label>
|
||||||
|
<select id="duty-member" aria-label="选择成员" value={member} onChange={event => onMember(event.target.value)}>
|
||||||
|
<option value="">选择成员</option>
|
||||||
|
{members.map(person => <option key={person.owner} value={person.owner}>{person.owner}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{!member ? <p className="ad-hint">先选择成员,再为每份文档指定无职责、编制、审核或批准。</p> : (
|
||||||
|
<table className="ad-table">
|
||||||
|
<thead><tr><th>文档</th><th>类型 / 阶段</th><th>职责</th><th>完整性</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{docs.map(doc => {
|
||||||
|
const score = completeness(doc)
|
||||||
|
return (
|
||||||
|
<tr key={doc.docId}>
|
||||||
|
<td>{doc.title}<span className="ad-dim ad-mono"> · {doc.code}</span></td>
|
||||||
|
<td className="ad-dim">{doc.typeKey}{doc.stage ? ` / ${doc.stage}` : ''}</td>
|
||||||
|
<td><DutySelect doc={doc} owner={member} disabled={disabled} onChange={duty => onCell(doc.docId, member, duty)} /></td>
|
||||||
|
<td className="ad-dim">编 {score.authors}/1 · 审 {score.reviewers} · 批 {score.approvers}/1</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{docs.length === 0 && <tr><td colSpan={4} className="ad-dim">没有符合筛选的文档</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DutyByDocumentView({
|
||||||
|
docId, docs, members, disabled, onDoc, onCell,
|
||||||
|
}: {
|
||||||
|
docId: string
|
||||||
|
docs: DutyDocDraft[]
|
||||||
|
members: DutyMember[]
|
||||||
|
disabled: boolean
|
||||||
|
onDoc: (id: string) => void
|
||||||
|
onCell: (docId: string, owner: string, duty: CellDuty) => void
|
||||||
|
}) {
|
||||||
|
const doc = docs.find(item => item.docId === docId)
|
||||||
|
const score = doc ? completeness(doc) : null
|
||||||
|
const issues = doc ? saveIssues(doc) : []
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="setting-row">
|
||||||
|
<label htmlFor="duty-doc">文档</label>
|
||||||
|
<select id="duty-doc" aria-label="选择文档" value={docId} onChange={event => onDoc(event.target.value)}>
|
||||||
|
<option value="">选择整份文档</option>
|
||||||
|
{docs.map(item => <option key={item.docId} value={item.docId}>{item.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{score && <p className="ad-hint">编制 {score.authors}/1、审核 {score.reviewers} 人、批准 {score.approvers}/1、冲突 {score.conflicts}{issues.length > 0 ? `。${issues.join(',')}` : ''}</p>}
|
||||||
|
{!doc ? <p className="ad-hint">先选择整份文档。这里不能选择章节,也没有生成入口。</p> : (
|
||||||
|
<table className="ad-table">
|
||||||
|
<thead><tr><th>成员</th><th>项目身份</th><th>职责</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map(person => (
|
||||||
|
<tr key={person.owner}>
|
||||||
|
<td className="ad-mono">{person.owner}{person.valid ? '' : '(无效)'}</td>
|
||||||
|
<td className="ad-dim">{person.role === 'owner' ? '项目负责人' : '项目成员'}</td>
|
||||||
|
<td><DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssignedDuty = Exclude<CellDuty, 'none'>
|
||||||
|
|
||||||
|
/** 文档职责候选检索:rdm.account.list 携带 docId,只有当前有效成员可选;选中后按 userId 指派。 */
|
||||||
|
export function DutyCandidateSearch({
|
||||||
|
doc, mode, fields, fetchPage, reloadKey, disabled, onMode, onFields, onAssign,
|
||||||
|
}: {
|
||||||
|
doc: DutyDocDraft
|
||||||
|
mode: MemberMatchMode
|
||||||
|
fields: SearchFieldsChoice
|
||||||
|
fetchPage: (query: string, cursor: string, limit: number) => Promise<{ items: PersonPickItem[]; nextCursor?: string }>
|
||||||
|
reloadKey: string
|
||||||
|
disabled: boolean
|
||||||
|
onMode: (mode: MemberMatchMode) => void
|
||||||
|
onFields: (fields: SearchFieldsChoice) => void
|
||||||
|
onAssign: (candidate: PersonPickItem, duty: AssignedDuty) => void
|
||||||
|
}) {
|
||||||
|
const [picked, setPicked] = useState<PersonPickItem | null>(null)
|
||||||
|
const [duty, setDuty] = useState<AssignedDuty>('reviewer')
|
||||||
|
const known = useRef(new Map<string, PersonPickItem>())
|
||||||
|
useEffect(() => { known.current.clear(); setPicked(null) }, [reloadKey])
|
||||||
|
const fetchKnown = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||||
|
const page = await fetchPage(query, cursor, limit)
|
||||||
|
for (const item of page.items) known.current.set(item.id, item)
|
||||||
|
return page
|
||||||
|
}, [fetchPage])
|
||||||
|
return (
|
||||||
|
<div className="settings-group">
|
||||||
|
<h4>搜索候选人</h4>
|
||||||
|
<div className="ad-row">
|
||||||
|
<select className="lg-input" aria-label="候选匹配方式" value={mode} onChange={event => onMode(event.target.value as MemberMatchMode)}>
|
||||||
|
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m => <option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
|
||||||
|
</select>
|
||||||
|
<select className="lg-input" aria-label="候选检索字段" value={fields} onChange={event => onFields(event.target.value as SearchFieldsChoice)}>
|
||||||
|
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f => <option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="ad-row">
|
||||||
|
<Picker
|
||||||
|
aria-label="职责候选人"
|
||||||
|
selected={picked ? [picked.id] : []}
|
||||||
|
selectedItems={picked ? [picked] : []}
|
||||||
|
onSelect={ids => setPicked(known.current.get(ids[0] ?? '') ?? null)}
|
||||||
|
fetchPage={fetchKnown}
|
||||||
|
reloadKey={reloadKey}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={mode === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
|
||||||
|
emptyText="输入后显示当前有效成员"
|
||||||
|
/>
|
||||||
|
<select className="lg-input" aria-label="候选人职责" value={duty} onChange={event => setDuty(event.target.value as AssignedDuty)}>
|
||||||
|
{DUTY_OPTIONS.filter(option => option.value !== 'none').map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="ad-mini" disabled={disabled || !picked || picked.disabled}
|
||||||
|
onClick={() => { if (picked) { onAssign(picked, duty); setPicked(null) } }}>
|
||||||
|
{picked ? `指派到 ${doc.title}` : '指派'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{picked && <p className="ad-hint">{picked.title} · {picked.owner}{replacementNote(doc, picked.owner, duty) ? `(${replacementNote(doc, picked.owner, duty)})` : ''}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DutyMatrixView({
|
||||||
|
docs, members, disabled, narrow, onCell,
|
||||||
|
}: {
|
||||||
|
docs: DutyDocDraft[]
|
||||||
|
members: DutyMember[]
|
||||||
|
disabled: boolean
|
||||||
|
narrow: boolean
|
||||||
|
onCell: (docId: string, owner: string, duty: CellDuty) => void
|
||||||
|
}) {
|
||||||
|
if (narrow) {
|
||||||
|
return <p className="ad-hint">当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="duty-matrix-wrap">
|
||||||
|
<table className="ad-table duty-matrix">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="duty-sticky">成员</th>
|
||||||
|
{docs.map(doc => {
|
||||||
|
const score = completeness(doc)
|
||||||
|
const ready = score.authors === 1 && score.reviewers >= 1 && score.approvers === 1 && score.conflicts === 0
|
||||||
|
return <th key={doc.docId}>{doc.title}<span className={ready ? 'ad-chip' : 'ad-chip ad-chip-warn'}>{ready ? '三权齐' : '未齐'}</span></th>
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map(person => (
|
||||||
|
<tr key={person.owner}>
|
||||||
|
<td className="duty-sticky ad-mono">{person.owner}</td>
|
||||||
|
{docs.map(doc => (
|
||||||
|
<td key={doc.docId}>
|
||||||
|
<DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DutyChangePreview({
|
||||||
|
impacts, confirm, onConfirm,
|
||||||
|
}: {
|
||||||
|
impacts: {
|
||||||
|
docId: string
|
||||||
|
title?: string
|
||||||
|
replacedAuthor?: string
|
||||||
|
replacedApprover?: string
|
||||||
|
needsConfirm?: boolean
|
||||||
|
reasons?: string[]
|
||||||
|
}[]
|
||||||
|
confirm: boolean
|
||||||
|
onConfirm: (value: boolean) => void
|
||||||
|
}) {
|
||||||
|
if (impacts.length === 0) return null
|
||||||
|
const needs = impacts.some(item => item.needsConfirm)
|
||||||
|
return (
|
||||||
|
<div className="settings-group">
|
||||||
|
<h4>保存前预览</h4>
|
||||||
|
<ul>
|
||||||
|
{impacts.map(item => (
|
||||||
|
<li key={item.docId}>
|
||||||
|
{item.title || item.docId}
|
||||||
|
{item.replacedAuthor ? `:编制将替换${item.replacedAuthor}` : ''}
|
||||||
|
{item.replacedApprover ? `:批准将替换${item.replacedApprover}` : ''}
|
||||||
|
{item.needsConfirm ? ';在途审签将失效,历史名单保持不变' : ''}
|
||||||
|
{item.reasons && item.reasons.length > 0 ? `;${item.reasons.join(',')}` : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{needs && (
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={confirm} onChange={event => onConfirm(event.target.checked)} />
|
||||||
|
确认使受影响的在途审签失效
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+90
-9
@@ -3,8 +3,9 @@
|
|||||||
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
|
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
|
||||||
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定。
|
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定。
|
||||||
// 参数增量(编写中)一到即算工具,解说不用等工具真正开始执行才离开回复区。
|
// 参数增量(编写中)一到即算工具,解说不用等工具真正开始执行才离开回复区。
|
||||||
import type { Message, Part } from './contracts'
|
import type { Message, Part, StreamEvent } from './contracts'
|
||||||
import type { LiveState } from './store'
|
import type { LiveState } from './store'
|
||||||
|
import { splitTimeline, type ReplayPart } from './messageparts'
|
||||||
|
|
||||||
export type ToolLiveState = 'composing' | 'running' | 'done'
|
export type ToolLiveState = 'composing' | 'running' | 'done'
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export type ToolLiveState = 'composing' | 'running' | 'done'
|
|||||||
export type LiveItem =
|
export type LiveItem =
|
||||||
| { kind: 'thinking'; text: string; startedAt: number; endedAt: number }
|
| { kind: 'thinking'; text: string; startedAt: number; endedAt: number }
|
||||||
| { kind: 'text'; index: number; text: string }
|
| { kind: 'text'; index: number; text: string }
|
||||||
|
| { kind: 'agent'; text: string; agentRun?: string; agentPhase?: 'spawn' | 'progress' | 'result' }
|
||||||
| {
|
| {
|
||||||
kind: 'tool'
|
kind: 'tool'
|
||||||
callId?: string
|
callId?: string
|
||||||
@@ -29,7 +31,7 @@ export type LiveItem =
|
|||||||
| { kind: 'check'; passed: boolean; reason: string }
|
| { kind: 'check'; passed: boolean; reason: string }
|
||||||
| { kind: 'final' }
|
| { kind: 'final' }
|
||||||
|
|
||||||
export type LiveTool = Extract<LiveItem, { kind: 'tool' }>
|
type LiveTool = Extract<LiveItem, { kind: 'tool' }>
|
||||||
|
|
||||||
const OUTPUT_TAIL = 4096
|
const OUTPUT_TAIL = 4096
|
||||||
|
|
||||||
@@ -118,13 +120,13 @@ export function applyToolCall(items: LiveItem[], name: string, callId?: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applyToolOutput 把执行中的输出追加到对应工具,只保留尾部,避免长日志撑住内存。
|
// applyToolOutput 把执行中的输出追加到对应工具,只保留尾部,避免长日志撑住内存。
|
||||||
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string): LiveItem[] {
|
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string, maxChars = OUTPUT_TAIL): LiveItem[] {
|
||||||
for (let i = items.length - 1; i >= 0; i--) {
|
for (let i = items.length - 1; i >= 0; i--) {
|
||||||
const it = items[i]
|
const it = items[i]
|
||||||
if (!isTool(it) || it.state === 'done') continue
|
if (!isTool(it) || it.state === 'done') continue
|
||||||
if (callId && it.callId && it.callId !== callId) continue
|
if (callId && it.callId && it.callId !== callId) continue
|
||||||
let output = it.output + text
|
let output = it.output + text
|
||||||
if (output.length > OUTPUT_TAIL) output = output.slice(output.length - OUTPUT_TAIL)
|
if (output.length > maxChars) output = output.slice(output.length - maxChars)
|
||||||
const out = items.slice()
|
const out = items.slice()
|
||||||
out[i] = { ...it, output }
|
out[i] = { ...it, output }
|
||||||
return out
|
return out
|
||||||
@@ -149,6 +151,27 @@ export function applyCheck(items: LiveItem[], passed: boolean, reason: string):
|
|||||||
return [...items, { kind: 'check', passed, reason }]
|
return [...items, { kind: 'check', passed, reason }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A child streams cumulative previews. Replace that child's latest preview in
|
||||||
|
* place, while preserving real phase changes and the order of other children. */
|
||||||
|
function applyAgentProgress(items: LiveItem[], agentRun: string, note: string): LiveItem[] {
|
||||||
|
const text = note.trim()
|
||||||
|
if (!text) return items
|
||||||
|
for (let i = items.length - 1; i >= 0; i--) {
|
||||||
|
const previous = items[i]
|
||||||
|
if (previous.kind !== 'agent' || previous.agentRun !== agentRun) continue
|
||||||
|
if (previous.agentPhase !== 'progress') break
|
||||||
|
const value = previous.text.slice('协作进展:'.length)
|
||||||
|
if (value === text || value.startsWith(text)) return items
|
||||||
|
if (text.startsWith(value) && !/^调用\s/.test(text)) {
|
||||||
|
const next = items.slice()
|
||||||
|
next[i] = { ...previous, text: `协作进展:${text}` }
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return [...items, { kind: 'agent', text: `协作进展:${text}`, agentRun, agentPhase: 'progress' }]
|
||||||
|
}
|
||||||
|
|
||||||
// applyFinalizing 标记收尾开始:此前的正文都是过程,此后的正文才是给用户的回复。
|
// applyFinalizing 标记收尾开始:此前的正文都是过程,此后的正文才是给用户的回复。
|
||||||
export function applyFinalizing(items: LiveItem[]): LiveItem[] {
|
export function applyFinalizing(items: LiveItem[]): LiveItem[] {
|
||||||
if (items.some(it => it.kind === 'final')) return items
|
if (items.some(it => it.kind === 'final')) return items
|
||||||
@@ -193,7 +216,9 @@ export function liveToMessage(live: LiveState): Message {
|
|||||||
parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } })
|
parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (it.kind === 'thinking') {
|
if (it.kind === 'agent') {
|
||||||
|
parts.push({ type: 'text', text: it.text, process: true, replaySource: 'agent' } as ReplayPart)
|
||||||
|
} else if (it.kind === 'thinking') {
|
||||||
const durationMs = it.endedAt - it.startedAt
|
const durationMs = it.endedAt - it.startedAt
|
||||||
if (it.text) parts.push({
|
if (it.text) parts.push({
|
||||||
type: 'thinking', text: it.text, process: true,
|
type: 'thinking', text: it.text, process: true,
|
||||||
@@ -221,7 +246,63 @@ export function liveToMessage(live: LiveState): Message {
|
|||||||
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
|
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
|
const createdAt = live.startedAt > 0 && Number.isFinite(live.startedAt) ? new Date(live.startedAt).toISOString() : ''
|
||||||
|
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild one run's process from its durable ordered events, without the live preview's output-tail limit. */
|
||||||
|
export function runEventsToTimeline(events: StreamEvent[], parentRunId = events[0]?.runId, completeRecord = false): ReplayPart[] {
|
||||||
|
let items: LiveItem[] = []
|
||||||
|
let finalizing = false
|
||||||
|
for (const event of [...events].sort((a, b) => a.seq - b.seq)) {
|
||||||
|
const p = event.payload as Record<string, unknown> | undefined
|
||||||
|
const now = Date.parse(event.ts) || 0
|
||||||
|
if (event.runId !== parentRunId && event.type !== 'agent.progress' && event.type !== 'agent.result') continue
|
||||||
|
switch (event.type) {
|
||||||
|
case 'agent.spawn':
|
||||||
|
if (typeof p?.name === 'string') items.push({ kind: 'agent', text: `协作代理「${p.name}」已开始`,
|
||||||
|
agentRun: String(p.agentRun ?? event.runId ?? ''), agentPhase: 'spawn' })
|
||||||
|
break
|
||||||
|
case 'agent.progress':
|
||||||
|
if (typeof p?.note === 'string') items = applyAgentProgress(items, String(p.agentRun ?? event.runId ?? ''), p.note)
|
||||||
|
break
|
||||||
|
case 'agent.result':
|
||||||
|
items.push({ kind: 'agent', text: `协作结束:${typeof p?.summary === 'string' && p.summary.trim() ? p.summary : String(p?.status ?? '已结束')}`,
|
||||||
|
agentRun: String(p?.agentRun ?? event.runId ?? ''), agentPhase: 'result' })
|
||||||
|
break
|
||||||
|
case 'thinking.delta':
|
||||||
|
if (typeof p?.text === 'string') items = applyThinkingDelta(items, p.text, now)
|
||||||
|
break
|
||||||
|
case 'message.delta':
|
||||||
|
if ((!finalizing || completeRecord) && typeof p?.text === 'string') items = applyTextDelta(items, typeof p.index === 'number' ? p.index : 0, p.text)
|
||||||
|
break
|
||||||
|
case 'tool.call.delta':
|
||||||
|
if (typeof p?.delta === 'string') items = applyToolCallDelta(items, typeof p.index === 'number' ? p.index : 0,
|
||||||
|
p.delta, typeof p.callId === 'string' ? p.callId : undefined, typeof p.name === 'string' ? p.name : undefined, now)
|
||||||
|
break
|
||||||
|
case 'tool.call':
|
||||||
|
items = applyToolCall(items, typeof p?.name === 'string' ? p.name : '工具',
|
||||||
|
typeof p?.callId === 'string' ? p.callId : undefined,
|
||||||
|
typeof p?.arguments === 'string' ? p.arguments : undefined, now)
|
||||||
|
break
|
||||||
|
case 'tool.output':
|
||||||
|
if (typeof p?.text === 'string') items = applyToolOutput(items,
|
||||||
|
typeof p.callId === 'string' ? p.callId : undefined, p.text, Number.POSITIVE_INFINITY)
|
||||||
|
break
|
||||||
|
case 'tool.result':
|
||||||
|
items = applyToolResult(items, typeof p?.callId === 'string' ? p.callId : undefined,
|
||||||
|
typeof p?.status === 'string' ? p.status : 'ok', typeof p?.summary === 'string' ? p.summary : undefined, now)
|
||||||
|
break
|
||||||
|
case 'run.phase':
|
||||||
|
if (p?.phase === 'finalizing') { finalizing = true; items = applyFinalizing(items) }
|
||||||
|
break
|
||||||
|
case 'run.check':
|
||||||
|
items = applyCheck(items, !!p?.passed, typeof p?.reason === 'string' ? p.reason : '')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const live: LiveState = { runId: events[0]?.runId ?? '', phase: 'done', items, agentNotes: {}, startedAt: 0 }
|
||||||
|
return completeRecord ? liveToMessage(live).parts : splitTimeline(liveToMessage(live).parts).timeline
|
||||||
}
|
}
|
||||||
|
|
||||||
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
|
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
|
||||||
@@ -246,9 +327,9 @@ export function livePhaseLabel(live: LiveState, now = Date.now()): string {
|
|||||||
return '回复中'
|
return '回复中'
|
||||||
}
|
}
|
||||||
|
|
||||||
// showProcessInline:运行中时间线始终展开。run 结束后随「自动折叠过程」偏好收成一行。
|
// 默认阅读层只显示阶段;运行中也把技术记录留给用户主动展开。
|
||||||
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
|
export function showProcessInline(_live: LiveState | null, autoCollapse: boolean): boolean {
|
||||||
return live != null || !autoCollapse
|
return !autoCollapse
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasSettledReply:助手回复落库后,这个 run 的实时气泡才该收起。
|
// hasSettledReply:助手回复落库后,这个 run 的实时气泡才该收起。
|
||||||
|
|||||||
+74
-13
@@ -1,5 +1,4 @@
|
|||||||
// 消息切分:过程时间线 = 从开头到最后一个 process 部件(中间夹着的内容部件留在时间线里);
|
// 消息切分:每轮过程按落库顺序留在可回看的时间线,终轮回复直接可读。
|
||||||
// 最终段 = 它之后的正文与交付卡片。渲染顺序仍是落库顺序,只在这一刀上折叠。
|
|
||||||
import type { Part } from './contracts'
|
import type { Part } from './contracts'
|
||||||
|
|
||||||
export interface TimelineSplit {
|
export interface TimelineSplit {
|
||||||
@@ -7,22 +6,84 @@ export interface TimelineSplit {
|
|||||||
final: Part[]
|
final: Part[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。
|
/** Client-only provenance. It is never written to the Core message contract. */
|
||||||
* promoteLastText:回复区没有正文时,把时间线里最后一段解说提上来。失败或被截断的 run 用它保住可见回复。 */
|
export interface ReplayPart extends Part { replaySource?: 'agent' }
|
||||||
|
|
||||||
|
/** 最后一个 process 部件之前的记录属于该轮过程;错误与文件入口始终可见。
|
||||||
|
* 终态没有回复时,只把最后一段过程解说提升为可见回复。 */
|
||||||
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
|
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
|
||||||
let last = -1
|
let last = -1
|
||||||
for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i
|
for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i
|
||||||
if (last < 0) return { timeline: [], final: parts }
|
if (last < 0) return { timeline: [], final: parts }
|
||||||
const timeline = parts.slice(0, last + 1)
|
const timeline: Part[] = []
|
||||||
const final = parts.slice(last + 1)
|
const final: Part[] = []
|
||||||
if (!promoteLastText || final.some(p => p.type === 'text' && (p.text ?? '').trim())) return { timeline, final }
|
for (let i = 0; i < parts.length; i++) {
|
||||||
let textAt = -1
|
const part = parts[i]
|
||||||
for (let i = timeline.length - 1; i >= 0; i--) {
|
if (i > last || part.type === 'error' || part.type === 'artifact_ref') final.push(part)
|
||||||
if (timeline[i].type === 'text' && (timeline[i].text ?? '').trim()) { textAt = i; break }
|
else timeline.push(part)
|
||||||
}
|
}
|
||||||
if (textAt < 0) return { timeline, final }
|
if (promoteLastText && !final.some(part => part.type === 'text' || part.type === 'error')) {
|
||||||
const lifted = { ...timeline[textAt], process: undefined }
|
let idx = -1
|
||||||
return { timeline: timeline.filter((_, i) => i !== textAt), final: [lifted, ...final] }
|
for (let i = timeline.length - 1; i >= 0; i--) {
|
||||||
|
if (timeline[i].type === 'text') { idx = i; break }
|
||||||
|
}
|
||||||
|
if (idx >= 0) final.unshift(...timeline.splice(idx, 1))
|
||||||
|
}
|
||||||
|
return { timeline, final }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = (text?: string) => (text ?? '').replace(/\s+/g, ' ').trim()
|
||||||
|
|
||||||
|
function sameProcessPart(saved: Part, replayed: Part): boolean {
|
||||||
|
if (saved.type !== replayed.type) return false
|
||||||
|
switch (saved.type) {
|
||||||
|
case 'tool_call': return !!saved.toolCall?.id && saved.toolCall.id === replayed.toolCall?.id
|
||||||
|
case 'tool_result': return !!saved.toolResult?.callId && saved.toolResult.callId === replayed.toolResult?.callId
|
||||||
|
case 'check': return saved.check?.passed === replayed.check?.passed && saved.check?.reason === replayed.check?.reason
|
||||||
|
case 'thinking':
|
||||||
|
case 'text': return normalized(saved.text) === normalized(replayed.text)
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the settled message as the content authority; add only event-only records
|
||||||
|
* and full tool output. Event deltas can be missing, while terminal parts can
|
||||||
|
* contain retry notes or parsed inline thinking that the event stream cannot reconstruct. */
|
||||||
|
export function mergeProcessTimeline(saved: Part[], replayed: ReplayPart[], final: Part[]): Part[] {
|
||||||
|
if (replayed.length === 0) return saved
|
||||||
|
const finalTexts = final.filter(part => part.type === 'text').map(part => normalized(part.text)).filter(Boolean)
|
||||||
|
const merged: Part[] = []
|
||||||
|
let cursor = 0
|
||||||
|
let pending: Part[] = []
|
||||||
|
for (const part of replayed) {
|
||||||
|
const text = normalized(part.text)
|
||||||
|
const agentNote = part.replaySource === 'agent'
|
||||||
|
if (part.type === 'text' && !agentNote && ((text && finalTexts.some(value => value.includes(text) || text.includes(value)))
|
||||||
|
|| /<\/?think\b/i.test(part.text ?? ''))) continue
|
||||||
|
let match = -1
|
||||||
|
for (let i = cursor; i < saved.length; i++) {
|
||||||
|
if (sameProcessPart(saved[i], part)) { match = i; break }
|
||||||
|
}
|
||||||
|
if (match < 0) {
|
||||||
|
// A missing delta batch can leave only a prefix in events. The complete
|
||||||
|
// terminal part is authoritative; never render that prefix beside it.
|
||||||
|
if (!agentNote && (part.type === 'text' || part.type === 'thinking') && text && saved.some(item => {
|
||||||
|
if (item.type !== part.type) return false
|
||||||
|
const value = normalized(item.text)
|
||||||
|
return value && (value.includes(text) || text.includes(value))
|
||||||
|
})) continue
|
||||||
|
pending.push(part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged.push(...saved.slice(cursor, match), ...pending)
|
||||||
|
pending = []
|
||||||
|
const output = (part as Part & { toolOutput?: string }).toolOutput
|
||||||
|
merged.push(output && saved[match].type === 'tool_call'
|
||||||
|
? { ...saved[match], toolOutput: output } as Part : saved[match])
|
||||||
|
cursor = match + 1
|
||||||
|
}
|
||||||
|
merged.push(...saved.slice(cursor), ...pending)
|
||||||
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
|
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
|
||||||
|
|||||||
+6
-1
@@ -1,17 +1,22 @@
|
|||||||
// 输入排队(纯数据):回合进行中先入本地队列,终态再逐条发送。
|
// 输入排队(纯数据):回合进行中先入本地队列,终态再逐条发送。
|
||||||
|
export interface WorkReference { cardId: string; resultVersion: number; title: string }
|
||||||
|
|
||||||
export interface QueuedMessage {
|
export interface QueuedMessage {
|
||||||
id: string
|
id: string
|
||||||
text: string
|
text: string
|
||||||
attachmentIds: string[]
|
attachmentIds: string[]
|
||||||
attachmentNames: string[]
|
attachmentNames: string[]
|
||||||
|
taskRef?: WorkReference
|
||||||
|
deliveryError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[]): QueuedMessage {
|
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference): QueuedMessage {
|
||||||
return {
|
return {
|
||||||
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
text,
|
text,
|
||||||
attachmentIds,
|
attachmentIds,
|
||||||
attachmentNames,
|
attachmentNames,
|
||||||
|
...(taskRef ? { taskRef } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
// 浏览器端随机初始密码(管理员未显式给定密码时):
|
|
||||||
// crypto.getRandomValues 拒绝易混淆字符(0/O/1/l/I),保证可抄写。
|
|
||||||
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'
|
|
||||||
|
|
||||||
export function randomPassword(length = 12): string {
|
|
||||||
const buf = new Uint32Array(length)
|
|
||||||
crypto.getRandomValues(buf)
|
|
||||||
let out = ''
|
|
||||||
for (let i = 0; i < length; i++) out += ALPHABET[buf[i] % ALPHABET.length]
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
+2
-1
@@ -3,13 +3,14 @@
|
|||||||
export interface PreviewTab {
|
export interface PreviewTab {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
sourceContext?: {notice:string;quote:string;expectedHash?:string}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openPreviewTab(tabs: PreviewTab[], tab: PreviewTab): { tabs: PreviewTab[]; activeId: string } {
|
export function openPreviewTab(tabs: PreviewTab[], tab: PreviewTab): { tabs: PreviewTab[]; activeId: string } {
|
||||||
const i = tabs.findIndex(t => t.id === tab.id)
|
const i = tabs.findIndex(t => t.id === tab.id)
|
||||||
if (i >= 0) {
|
if (i >= 0) {
|
||||||
const next = tabs.slice()
|
const next = tabs.slice()
|
||||||
next[i] = { ...next[i], name: tab.name || next[i].name }
|
next[i] = { ...next[i], name: tab.name || next[i].name,sourceContext:tab.sourceContext }
|
||||||
return { tabs: next, activeId: tab.id }
|
return { tabs: next, activeId: tab.id }
|
||||||
}
|
}
|
||||||
return { tabs: [...tabs, tab], activeId: tab.id }
|
return { tabs: [...tabs, tab], activeId: tab.id }
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
canCreateProject, canManageProjectMembers, emptyProjectsNotice, memberDuty, noProjectError, projectCapabilitySummary,
|
||||||
|
} from './projectmeta'
|
||||||
|
|
||||||
|
const linked = (projectCreate: boolean) => ({ projectCreate, source: 'role', linkedActions: [], authorityEpoch: 3 })
|
||||||
|
|
||||||
|
describe('EmptyListNoCreate', () => {
|
||||||
|
it('asks a member without creation capability to wait for an invite instead of creating a project', () => {
|
||||||
|
const notice = emptyProjectsNotice(true, false)
|
||||||
|
expect(notice).toContain('等待项目负责人邀请')
|
||||||
|
expect(notice).not.toContain('新建')
|
||||||
|
expect(noProjectError(true, false)).not.toContain('新建')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call a list that has not arrived empty', () => {
|
||||||
|
expect(emptyProjectsNotice(false, true)).toBe('正在加载项目列表…')
|
||||||
|
expect(noProjectError(false, false)).toContain('尚未加载')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('points a creator to explicit project creation', () => {
|
||||||
|
expect(emptyProjectsNotice(true, true)).toContain('新建项目')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DefaultProjectDisabled creation entry', () => {
|
||||||
|
it('follows the server-computed creation link first', () => {
|
||||||
|
expect(canCreateProject({ auth: true, isAdmin: true, systemActions: ['project.create'], linkedCapabilities: linked(false) })).toBe(false)
|
||||||
|
expect(canCreateProject({ auth: true, systemActions: [], linkedCapabilities: linked(true) })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to explicit system actions and fails closed without an identity', () => {
|
||||||
|
expect(canCreateProject({ auth: true, systemActions: [] })).toBe(false)
|
||||||
|
expect(canCreateProject({ auth: true, systemActions: ['project.create'] })).toBe(true)
|
||||||
|
expect(canCreateProject(null)).toBe(false)
|
||||||
|
expect(canCreateProject({ auth: false })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('summarizes the creation source and the linked own-project management', () => {
|
||||||
|
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(true) })).toContain('来源:职位角色')
|
||||||
|
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: { ...linked(true), conflicts: ['project.members.manage'] } }))
|
||||||
|
.toContain('1 项关联被角色上限阻断')
|
||||||
|
expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(false) })).toContain('被邀请加入后项目自动出现')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CreatorOwnerMemberDuties', () => {
|
||||||
|
it('gives member management only to the immutable creator, not to an owner label', () => {
|
||||||
|
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice')).toMatchObject({ kind: 'creator', label: '创建者' })
|
||||||
|
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').duties).toContain('成员增删')
|
||||||
|
const owner = memberDuty({ owner: 'bob', role: 'owner' }, 'alice')
|
||||||
|
expect(owner).toMatchObject({ kind: 'owner', label: '项目负责人' })
|
||||||
|
expect(owner.duties).toContain('不含成员管理')
|
||||||
|
const member = memberDuty({ owner: 'carol', role: 'member' }, 'alice')
|
||||||
|
expect(member).toMatchObject({ kind: 'member', source: '成员关系' })
|
||||||
|
expect(member.duties).toContain('本人对话')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MemberManagementEntryHidden capability', () => {
|
||||||
|
it('requires an allowed member management action from the self check', () => {
|
||||||
|
expect(canManageProjectMembers(null)).toBe(false)
|
||||||
|
expect(canManageProjectMembers([{ action: 'member.manage', allowed: false }])).toBe(false)
|
||||||
|
expect(canManageProjectMembers([{ action: 'doc.read', allowed: true }])).toBe(false)
|
||||||
|
expect(canManageProjectMembers([{ action: 'member.manage', allowed: true }])).toBe(true)
|
||||||
|
expect(canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats the two old and new capability ids as equivalent for backward compatibility', () => {
|
||||||
|
const onlyLegacy = canManageProjectMembers([{ action: 'member.manage', allowed: true }])
|
||||||
|
const onlyModern = canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])
|
||||||
|
const both = canManageProjectMembers([
|
||||||
|
{ action: 'member.manage', allowed: true },
|
||||||
|
{ action: 'project.members.manage', allowed: true },
|
||||||
|
])
|
||||||
|
expect(onlyLegacy).toBe(onlyModern)
|
||||||
|
expect(onlyModern).toBe(true)
|
||||||
|
expect(both).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CreatorOwnerMemberDuties member identity', () => {
|
||||||
|
it('falls back to plain member when the creator record is missing', () => {
|
||||||
|
const member = memberDuty({ owner: 'carol', role: 'member' }, undefined)
|
||||||
|
expect(member).toMatchObject({ kind: 'member', label: '项目成员' })
|
||||||
|
expect(member.duties).toContain('基础协作')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the source of each member kind distinctly', () => {
|
||||||
|
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').source).toMatch(/创建项目/)
|
||||||
|
expect(memberDuty({ owner: 'bob', role: 'owner' }, 'alice').source).toMatch(/成员关系/)
|
||||||
|
expect(memberDuty({ owner: 'carol', role: 'member' }, 'alice').source).toBe('成员关系')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DefaultProjectDisabled empty state messaging', () => {
|
||||||
|
it('keeps both helpers pointing the member to wait for an invite', () => {
|
||||||
|
expect(emptyProjectsNotice(true, false)).toContain('等待项目负责人邀请')
|
||||||
|
expect(noProjectError(true, false)).toContain('等待项目负责人邀请')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the loading notice distinct from a real empty list', () => {
|
||||||
|
expect(emptyProjectsNotice(false, false)).toMatch(/加载/)
|
||||||
|
expect(noProjectError(false, false)).toMatch(/加载/)
|
||||||
|
expect(emptyProjectsNotice(false, false)).not.toBe(noProjectError(false, false))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,112 @@
|
|||||||
|
import type { LinkedCapabilities } from './api'
|
||||||
|
|
||||||
/** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */
|
/** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */
|
||||||
export function projectStage(settings?: Record<string, unknown> | null): string | null {
|
export function projectStage(settings?: Record<string, unknown> | null): string | null {
|
||||||
const stage = settings?.stage
|
const stage = settings?.stage
|
||||||
return typeof stage === 'string' && stage.trim() !== '' ? stage.trim() : null
|
return typeof stage === 'string' && stage.trim() !== '' ? stage.trim() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Service metadata describes readiness; permission is always checked by the server. */
|
||||||
|
export function projectWorkError(project: { settings?: Record<string, unknown> } | undefined): string | null {
|
||||||
|
if (!project) return '项目已不可用,请刷新项目列表或联系项目负责人'
|
||||||
|
const settings = project.settings
|
||||||
|
if (!settings || (!('rdm.state' in settings) && !('rdm.projectId' in settings))) return null
|
||||||
|
switch (settings['rdm.state']) {
|
||||||
|
case 'ready':
|
||||||
|
if (typeof settings['rdm.projectId'] === 'string' && settings['rdm.projectId'].trim()) return null
|
||||||
|
break
|
||||||
|
case 'provisioning': return '项目正在准备,请稍后刷新项目列表再试'
|
||||||
|
case 'failed': return '项目准备失败,请联系项目负责人重试创建'
|
||||||
|
case 'archived': return '项目已归档,请联系项目负责人恢复后再新建对话'
|
||||||
|
case 'closed': return '项目已关闭,请选择其他项目或联系项目负责人'
|
||||||
|
}
|
||||||
|
return '项目尚未绑定,请联系项目负责人完成项目接入'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectStateLabel(settings?: Record<string, unknown>): string | null {
|
||||||
|
switch (settings?.['rdm.state']) {
|
||||||
|
case 'provisioning': return '准备中'
|
||||||
|
case 'failed': return '准备失败'
|
||||||
|
case 'unbound': return '未接入'
|
||||||
|
case 'archived': return '已归档'
|
||||||
|
case 'closed': return '已关闭'
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectActionError(error: unknown): string {
|
||||||
|
const raw = String(error)
|
||||||
|
if (/\b401\b/.test(raw)) return '登录已失效,请重新登录后再试'
|
||||||
|
if (/\b403\b/.test(raw)) return '当前账号无法管理此项目,请联系项目负责人'
|
||||||
|
if (/\b404\b/.test(raw)) return '项目已不可用,请刷新项目列表或联系项目负责人'
|
||||||
|
if (/\b409\b/.test(raw)) return '项目名称已被占用或项目状态已变化,请更换名称或刷新后再试'
|
||||||
|
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
|
||||||
|
return raw.replace(/^Error:\s*/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectActor {
|
||||||
|
auth?: boolean; isAdmin?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只决定是否展示创建入口;服务端仍按当前身份最终判定。 */
|
||||||
|
export function canCreateProject(me: ProjectActor | null | undefined): boolean {
|
||||||
|
if (!me) return false
|
||||||
|
if (me.auth === false) return true
|
||||||
|
if (me.linkedCapabilities) return me.linkedCapabilities.projectCreate
|
||||||
|
// Backends without capability exposure keep the server as the only gate.
|
||||||
|
if (me.systemActions === undefined) return true
|
||||||
|
return !!me.isAdmin || me.systemActions.includes('project.create')
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAPABILITY_SOURCE_LABELS: Record<string, string> = {
|
||||||
|
role: '职位角色', 'system-grant': '明确系统授权', 'bootstrap-admin': '初始管理员',
|
||||||
|
}
|
||||||
|
|
||||||
|
function capabilitySourceLabel(source: string | undefined): string {
|
||||||
|
return (source && CAPABILITY_SOURCE_LABELS[source]) || source || '未知来源'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账户区一句话:本人是否能创建项目、来源、以及联动形成的本人项目管理资格。 */
|
||||||
|
export function projectCapabilitySummary(me: ProjectActor | null | undefined): string {
|
||||||
|
const linked = me?.linkedCapabilities
|
||||||
|
if (!linked) return canCreateProject(me) ? '可创建项目' : '未授予创建项目 · 被邀请加入后项目自动出现'
|
||||||
|
if (!linked.projectCreate) return '未授予创建项目 · 被邀请加入后项目自动出现'
|
||||||
|
const conflicts = linked.conflicts?.length ? ` · ${linked.conflicts.length} 项关联被角色上限阻断` : ''
|
||||||
|
return `可创建项目(来源:${capabilitySourceLabel(linked.source)})· 本人创建的项目自动具备设置、成员、项目权限与文档职责管理${conflicts}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空项目列表:未加载不当作空;无创建能力时只提示等待邀请,不诱导新建同名项目。 */
|
||||||
|
export function emptyProjectsNotice(loaded: boolean, canCreate: boolean): string {
|
||||||
|
if (!loaded) return '正在加载项目列表…'
|
||||||
|
if (canCreate) return '还没有项目:从「新建项目」开始。'
|
||||||
|
return '尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建对话时没有可用项目:不再自动创建「默认项目」。 */
|
||||||
|
export function noProjectError(loaded: boolean, canCreate: boolean): string {
|
||||||
|
if (!loaded) return '项目列表尚未加载完成,请稍候再试'
|
||||||
|
if (canCreate) return '还没有项目,请先新建项目'
|
||||||
|
return '尚无可用项目,请等待项目负责人邀请你加入'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberDuty { kind: 'creator' | 'owner' | 'member'; label: string; duties: string; source: string }
|
||||||
|
|
||||||
|
/** 项目内实际职责:成员管理只随不可变创建者;负责人只是界面身份。 */
|
||||||
|
export function memberDuty(member: { owner: string; role: string }, creator: string | undefined): MemberDuty {
|
||||||
|
if (creator && member.owner === creator) {
|
||||||
|
return { kind: 'creator', label: '创建者', duties: '项目设置、成员增删、项目内权限、文档职责分配 + 基础协作',
|
||||||
|
source: '随「创建项目」联动(仅本人创建的项目)' }
|
||||||
|
}
|
||||||
|
if (member.role === 'owner') {
|
||||||
|
return { kind: 'owner', label: '项目负责人', duties: '基础协作:项目阅读与本人对话;不含成员管理',
|
||||||
|
source: '成员关系(界面身份不授予创建者管理权)' }
|
||||||
|
}
|
||||||
|
return { kind: 'member', label: '项目成员', duties: '基础协作:查看与切换项目、两树一网阅读、本人对话', source: '成员关系' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEMBER_MANAGE_ACTIONS = new Set(['member.manage', 'project.members.manage'])
|
||||||
|
|
||||||
|
/** 本人自省结果里成员管理是否生效;未知(null)按无权处理。 */
|
||||||
|
export function canManageProjectMembers(actions: { action: string; allowed: boolean }[] | null | undefined): boolean {
|
||||||
|
return !!actions?.some(a => MEMBER_MANAGE_ACTIONS.has(a.action) && a.allowed)
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user