Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebcc33b367 | |||
| 13485558b5 | |||
| f63bb894d9 | |||
| d2b97491ef | |||
| c183893cdd | |||
| a5866ec00b | |||
| ab33ce06f3 | |||
| 65fb8ed2b2 | |||
| 194e5ac1ef | |||
| aa6a029530 |
@@ -0,0 +1,28 @@
|
||||
---
|
||||
description: 标准化分支、测试、发布闸门(始终应用)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 标准化开发测试发布闸门
|
||||
|
||||
开始任何开发、修复、发布前,先读:
|
||||
|
||||
- `ENGINEERING_WORKFLOW_RULES.md`
|
||||
- `PRODUCTION_RELEASE_RULES.md`
|
||||
- `AGENTS.md`
|
||||
|
||||
必须遵守:
|
||||
|
||||
- 新建分支必须基于最新 `origin/main`,推荐执行 `bash scripts/new-branch.sh feature/xxx`
|
||||
- 发布前必须重新检查当前分支是否落后 `origin/main`
|
||||
- 发布来源必须是干净、可追溯的 Git commit
|
||||
- 禁止从脏工作区、detached HEAD、落后主线的分支发布
|
||||
- 禁止直接 `rsync` 到 `103/105` 或 SSH 到生产手改源码
|
||||
|
||||
发布前必须执行:
|
||||
|
||||
```bash
|
||||
bash scripts/check-release-ready.sh
|
||||
```
|
||||
|
||||
如需临时绕过,只能在用户明确批准后使用项目文档中记录的显式环境变量。
|
||||
@@ -0,0 +1,21 @@
|
||||
# Agent 协作说明(Codex / Cloud / Cursor / Claude / 其它 AI 工具通用)
|
||||
|
||||
本文件供所有在仓库内工作的 AI 编码助手阅读。仓库级开发、测试、发布规范以 [ENGINEERING_WORKFLOW_RULES.md](ENGINEERING_WORKFLOW_RULES.md) 为准;生产发布规范以 [PRODUCTION_RELEASE_RULES.md](PRODUCTION_RELEASE_RULES.md) 为准。
|
||||
|
||||
## 必读:分支与发布闸门
|
||||
|
||||
1. 新建分支前必须先同步远端主线,推荐执行 `bash scripts/new-branch.sh feature/xxx`。
|
||||
2. 发布前必须重新检查当前分支是否落后 `origin/main`。
|
||||
3. 发布来源必须是干净、可追溯的 Git commit。
|
||||
4. 禁止从脏工作区、detached HEAD、落后主线的分支发布。
|
||||
5. 发布前必须执行:
|
||||
|
||||
```bash
|
||||
bash scripts/check-release-ready.sh
|
||||
```
|
||||
|
||||
## 仓库边界
|
||||
|
||||
- `memind_adm` 可以独立开发,但共享用户、权限、策略、技能、计费体系必须继续复用 `Memind` 主实现。
|
||||
- 发版须 Git commit,禁止本机直 `rsync` 到 `103/105`。
|
||||
- 共享用户、计费、空间额度、策略同步相关改动必须保留业务验收记录。
|
||||
@@ -0,0 +1,21 @@
|
||||
# Claude 协作说明
|
||||
|
||||
本仓库的开发、测试、发布规范与 `AGENTS.md` 同源。开始任何代码修改或发布操作前,先读:
|
||||
|
||||
1. [ENGINEERING_WORKFLOW_RULES.md](ENGINEERING_WORKFLOW_RULES.md)
|
||||
2. [PRODUCTION_RELEASE_RULES.md](PRODUCTION_RELEASE_RULES.md)
|
||||
3. [AGENTS.md](AGENTS.md)
|
||||
|
||||
## 强制规则
|
||||
|
||||
- 新建分支必须基于最新 `origin/main`。
|
||||
- 发布前必须重新检查当前分支是否落后 `origin/main`。
|
||||
- 发布来源必须是干净、可追溯的 Git commit。
|
||||
- 禁止从脏工作区、detached HEAD、落后主线的分支发布。
|
||||
- 发布前必须执行:
|
||||
|
||||
```bash
|
||||
bash scripts/check-release-ready.sh
|
||||
```
|
||||
|
||||
如果规则与口头指令冲突,先暂停并确认,不要绕过发布闸门。
|
||||
@@ -7,10 +7,38 @@
|
||||
|
||||
## 2. 开发约束
|
||||
|
||||
1. 每次开发都必须形成本地 Git commit。
|
||||
2. 一个 commit 只解决一类问题,避免把功能、环境、运维脚本混在一起。
|
||||
3. 不允许长期堆积“只有自己知道用途”的未提交改动。
|
||||
4. `.env`、账号、密钥、日志、pid 不进入 Git。
|
||||
1. 新建开发分支前必须同步远端主线。推荐统一使用:
|
||||
|
||||
```bash
|
||||
bash scripts/new-branch.sh feature/xxx
|
||||
```
|
||||
|
||||
等价手工命令:
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git switch main
|
||||
git pull --ff-only
|
||||
git switch -c feature/xxx
|
||||
```
|
||||
|
||||
也可以直接从远端主线建分支:
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git switch -c feature/xxx origin/main
|
||||
```
|
||||
|
||||
2. 每次开发都必须形成本地 Git commit。
|
||||
3. 一个 commit 只解决一类问题,避免把功能、环境、运维脚本混在一起。
|
||||
4. 不允许长期堆积“只有自己知道用途”的未提交改动。
|
||||
5. `.env`、账号、密钥、日志、pid 不进入 Git。
|
||||
6. 分支开发中如果主线继续变化,发布前必须重新对齐:
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
## 3. 测试约束
|
||||
|
||||
@@ -24,6 +52,13 @@
|
||||
2. 测试与生产统一走“打包发布”。
|
||||
3. 发布来源必须是可追溯 commit,不允许从不明工作区直接出包。
|
||||
4. 发布前必须有备份,发布后必须有健康检查和业务验收。
|
||||
5. 发布前必须通过统一闸门:
|
||||
|
||||
```bash
|
||||
bash scripts/check-release-ready.sh
|
||||
```
|
||||
|
||||
6. 分支落后 `origin/main`、工作区有未提交或未跟踪改动、处于 detached HEAD、或没有明确批准却从 `main` / `master` 发布,均禁止发版。
|
||||
|
||||
## 5. 文档约束
|
||||
|
||||
@@ -35,3 +70,4 @@
|
||||
1. 发布前要求工作区可读:`git status` 不能混入无关改动。
|
||||
2. 为每次正式发布保留 manifest、备份包路径、验证结果。
|
||||
3. 涉及数据库、计费、用户空间、鉴权的改动,要额外保留一份业务验收清单。
|
||||
4. 每个 AI 工具都必须先读本文件;自动化工具、Git hook、发布脚本以 `scripts/check-release-ready.sh` 的结果为准。
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
2. `memind_adm` 生产发布唯一合法路径是:本地已提交代码 -> 本地生成发布包 -> 上传 `103` -> `103` 全量备份当前 `memind_adm` -> 解包到 release 目录 -> 安装依赖/构建 -> 原子切换 live 目录 -> 重启和健康检查。
|
||||
3. `scripts/release-prod.sh` 是当前唯一允许的生产更新入口,`scripts/rsync_to_server.sh` 只保留为禁用提示,不得再用于发布。
|
||||
4. 发布包不得携带运行态资产;`.env`、日志、pid 文件、`.mindops/` 等只能从线上现有 live 目录继承。
|
||||
5. 每次生产发布前必须先做全量备份;发布失败必须自动回滚到切换前的 live 目录。
|
||||
6. 发布清单必须记录:本地 commit、分支、发布时间、发布编号、是否含额外手工环境变更。
|
||||
7. 生产验证至少包含 `http://127.0.0.1:8085/health` 和 `http://127.0.0.1:5174/` 的成功检查,并补充本次功能对应的业务路径验收。
|
||||
8. 共享用户体系、计费体系、策略体系、空间额度改动上线后,必须做一次真实登录和 `/admin-api/users` 的业务校验。
|
||||
9. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。
|
||||
5. 每次生产发布前必须先通过 `bash scripts/check-release-ready.sh`,确认分支不落后 `origin/main`、工作区干净、发布来源可追溯。
|
||||
6. 每次生产发布前必须先做全量备份;发布失败必须自动回滚到切换前的 live 目录。
|
||||
7. 发布清单必须记录:本地 commit、分支、发布时间、发布编号、是否含额外手工环境变更。
|
||||
8. 生产验证至少包含 `http://127.0.0.1:8085/health` 和 `http://127.0.0.1:5174/` 的成功检查,并补充本次功能对应的业务路径验收。
|
||||
9. 共享用户体系、计费体系、策略体系、空间额度改动上线后,必须做一次真实登录和 `/admin-api/users` 的业务校验。
|
||||
10. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。
|
||||
|
||||
Generated
+147
@@ -15,6 +15,7 @@
|
||||
"http-proxy-middleware": "^3.0.3",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"mysql2": "^3.22.5",
|
||||
"pg": "^8.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -3188,6 +3189,95 @@
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3246,6 +3336,45 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -3663,6 +3792,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||
@@ -3953,6 +4091,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"http-proxy-middleware": "^3.0.3",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"mysql2": "^3.22.5",
|
||||
"pg": "^8.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BASE_REF="${BASE_REF:-}"
|
||||
SKIP_FETCH=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash scripts/check-branch-baseline.sh [--skip-fetch]
|
||||
|
||||
Checks that the current branch is not behind origin/main.
|
||||
|
||||
Environment:
|
||||
BASE_REF Base ref to compare against. Defaults to origin/main when available.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-fetch) SKIP_FETCH=1 ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null
|
||||
|
||||
if [[ "${SKIP_FETCH}" -ne 1 ]] && git -C "${ROOT}" remote get-url origin >/dev/null 2>&1; then
|
||||
git -C "${ROOT}" fetch origin --prune
|
||||
fi
|
||||
|
||||
if [[ -z "${BASE_REF}" ]]; then
|
||||
for candidate in origin/main origin/master main master; do
|
||||
if git -C "${ROOT}" rev-parse --verify --quiet "${candidate}" >/dev/null; then
|
||||
BASE_REF="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
branch="$(git -C "${ROOT}" branch --show-current)"
|
||||
if [[ -z "${branch}" ]]; then
|
||||
echo "Baseline check failed: detached HEAD is not allowed for development or release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git -C "${ROOT}" rev-parse --verify --quiet "${BASE_REF}" >/dev/null; then
|
||||
echo "Baseline check failed: base ref not found: ${BASE_REF}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r ahead behind < <(git -C "${ROOT}" rev-list --left-right --count "HEAD...${BASE_REF}")
|
||||
|
||||
if [[ "${behind}" -ne 0 ]]; then
|
||||
echo "Baseline check failed: ${branch} is behind ${BASE_REF} by ${behind} commit(s)." >&2
|
||||
echo "Run: git fetch origin --prune && git rebase ${BASE_REF}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Baseline check passed: ${branch} is current with ${BASE_REF} (ahead ${ahead})."
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BASE_REF="${BASE_REF:-}"
|
||||
SKIP_FETCH=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash scripts/check-release-ready.sh [--skip-fetch]
|
||||
|
||||
Blocks releases unless the repository is on a named branch, the branch is not
|
||||
behind origin/main, and the worktree is clean.
|
||||
|
||||
Environment:
|
||||
BASE_REF Base ref to compare against. Defaults to origin/main when available.
|
||||
ALLOW_MAIN_RELEASE=1 Allow publishing directly from main/master.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-fetch) SKIP_FETCH=1 ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null
|
||||
|
||||
branch="$(git -C "${ROOT}" branch --show-current)"
|
||||
if [[ -z "${branch}" ]]; then
|
||||
echo "Release check failed: detached HEAD is not a release source." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${branch}" in
|
||||
main|master)
|
||||
if [[ "${ALLOW_MAIN_RELEASE:-0}" != "1" ]]; then
|
||||
echo "Release check failed: publish from a dedicated release/feature branch, not ${branch}." >&2
|
||||
echo "Set ALLOW_MAIN_RELEASE=1 only for an explicitly approved mainline release." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n "$(git -C "${ROOT}" status --porcelain=v1 --untracked-files=all)" ]]; then
|
||||
echo "Release check failed: worktree has uncommitted or untracked changes." >&2
|
||||
git -C "${ROOT}" status --short --untracked-files=all >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_FETCH}" -eq 1 ]]; then
|
||||
bash "${ROOT}/scripts/check-branch-baseline.sh" --skip-fetch
|
||||
else
|
||||
bash "${ROOT}/scripts/check-branch-baseline.sh"
|
||||
fi
|
||||
|
||||
effective_base="${BASE_REF}"
|
||||
if [[ -z "${effective_base}" ]]; then
|
||||
for candidate in origin/main origin/master main master; do
|
||||
if git -C "${ROOT}" rev-parse --verify --quiet "${candidate}" >/dev/null; then
|
||||
effective_base="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
head_sha="$(git -C "${ROOT}" rev-parse --short HEAD)"
|
||||
echo "Release check passed: ${branch} @ ${head_sha} is clean and current with ${effective_base}."
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BASE_REF="${BASE_REF:-}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash scripts/new-branch.sh <branch-name>
|
||||
|
||||
Creates a new development branch from the latest origin/main.
|
||||
|
||||
Environment:
|
||||
BASE_REF Base ref to create from. Defaults to origin/main when available.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || $# -ne 1 ]]; then
|
||||
usage
|
||||
[[ $# -eq 1 ]] && exit 0 || exit 1
|
||||
fi
|
||||
|
||||
branch="$1"
|
||||
|
||||
if [[ -n "$(git -C "${ROOT}" status --porcelain=v1 --untracked-files=all)" ]]; then
|
||||
echo "New branch check failed: current worktree is not clean." >&2
|
||||
git -C "${ROOT}" status --short --untracked-files=all >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git -C "${ROOT}" remote get-url origin >/dev/null 2>&1; then
|
||||
git -C "${ROOT}" fetch origin --prune
|
||||
fi
|
||||
|
||||
if [[ -z "${BASE_REF}" ]]; then
|
||||
for candidate in origin/main origin/master main master; do
|
||||
if git -C "${ROOT}" rev-parse --verify --quiet "${candidate}" >/dev/null; then
|
||||
BASE_REF="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
git -C "${ROOT}" rev-parse --verify --quiet "${BASE_REF}" >/dev/null || {
|
||||
echo "New branch check failed: base ref not found: ${BASE_REF}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
git -C "${ROOT}" switch -c "${branch}" "${BASE_REF}"
|
||||
echo "Created ${branch} from ${BASE_REF}."
|
||||
+23
-4
@@ -20,7 +20,7 @@ TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/memindadm-release.XXXXXX")"
|
||||
BUNDLE_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.tar.gz"
|
||||
MANIFEST_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.manifest.txt"
|
||||
SHA_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.sha256"
|
||||
MEMIND_SRC="${TEST_MEMIND_ROOT:-${ROOT}/../test-memind}"
|
||||
MEMIND_SRC="${TEST_MEMIND_ROOT:-${ROOT}/../Memind}"
|
||||
MEMIND_LIB="${ROOT}/memind-lib"
|
||||
DRY_RUN=0
|
||||
SKIP_BUILD=0
|
||||
@@ -82,9 +82,17 @@ need_cmd() {
|
||||
|
||||
need_cmd ssh
|
||||
need_cmd scp
|
||||
need_cmd rsync
|
||||
need_cmd tar
|
||||
need_cmd shasum
|
||||
|
||||
if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then
|
||||
bash "${ROOT}/scripts/check-release-ready.sh"
|
||||
else
|
||||
echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[[ -f "${IGNORE_FILE}" ]] || {
|
||||
echo "缺少忽略文件: ${IGNORE_FILE}" >&2
|
||||
exit 1
|
||||
@@ -108,14 +116,23 @@ fi
|
||||
}
|
||||
|
||||
[[ -d "${MEMIND_SRC}" ]] || {
|
||||
echo "缺少 test-memind 共享模块源: ${MEMIND_SRC}" >&2
|
||||
echo "缺少 Memind 共享模块源: ${MEMIND_SRC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
say "打包 Memind 共享模块到 memind-lib"
|
||||
rm -rf "${MEMIND_LIB}"
|
||||
mkdir -p "${MEMIND_LIB}"
|
||||
find "${MEMIND_SRC}" -maxdepth 1 -name '*.mjs' -exec cp -f {} "${MEMIND_LIB}/" \;
|
||||
rsync -a \
|
||||
--prune-empty-dirs \
|
||||
--exclude '.git/' \
|
||||
--exclude 'node_modules/' \
|
||||
--include '*/' \
|
||||
--include '*.mjs' \
|
||||
--include '*.json' \
|
||||
--include '*.js' \
|
||||
--exclude '*' \
|
||||
"${MEMIND_SRC}/" "${MEMIND_LIB}/"
|
||||
[[ -f "${MEMIND_LIB}/user-auth.mjs" ]] || {
|
||||
echo "memind-lib 缺少 user-auth.mjs" >&2
|
||||
exit 1
|
||||
@@ -187,7 +204,8 @@ say() {
|
||||
}
|
||||
|
||||
rollback() {
|
||||
if [[ -d "${OLD_LIVE_DIR}" && ! -d "${APP_DIR}" ]]; then
|
||||
if [[ -d "${OLD_LIVE_DIR}" ]]; then
|
||||
rm -rf "${APP_DIR}"
|
||||
mv "${OLD_LIVE_DIR}" "${APP_DIR}"
|
||||
fi
|
||||
ADM_ROOT="${APP_DIR}" ADM_PORT=5174 ADM_API_PORT=8085 bash "${APP_DIR}/scripts/remote_restart.sh" >/dev/null 2>&1 || true
|
||||
@@ -222,6 +240,7 @@ done
|
||||
|
||||
[[ -d "${RELEASE_DIR}/dist" ]] || { echo "release bundle missing dist/" >&2; exit 1; }
|
||||
[[ ! -d "${RELEASE_DIR}/src" ]] || { echo "release bundle must not contain src/" >&2; exit 1; }
|
||||
[[ -f "${RELEASE_DIR}/memind-lib/user-auth.mjs" ]] || { echo "release bundle missing memind-lib/user-auth.mjs" >&2; exit 1; }
|
||||
|
||||
say "安装运行依赖"
|
||||
cd "${RELEASE_DIR}"
|
||||
|
||||
@@ -96,6 +96,9 @@ export function createAdminApp(services) {
|
||||
wechatAdmin,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
@@ -301,6 +304,84 @@ export function createAdminApp(services) {
|
||||
res.json({ config: result });
|
||||
});
|
||||
|
||||
adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => {
|
||||
if (!memoryV2ConfigService) return res.status(503).json({ message: 'Memory V2 配置未启用' });
|
||||
res.json(await memoryV2ConfigService.getAdminConfig());
|
||||
});
|
||||
|
||||
adminApi.patch('/memory-v2/config', requireAdmin, async (req, res) => {
|
||||
if (!memoryV2ConfigService) return res.status(503).json({ message: 'Memory V2 配置未启用' });
|
||||
const result = await memoryV2ConfigService.updateAdminConfig(req.body ?? {}, {
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const accounts = await systemTestAccountService.listAccounts();
|
||||
res.json({ accounts });
|
||||
});
|
||||
|
||||
adminApi.post('/system-tests/accounts', requireAdmin, async (req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const result = await systemTestAccountService.createAccount({
|
||||
label: req.body?.label,
|
||||
username: req.body?.username,
|
||||
password: req.body?.password,
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return res.status(400).json({ message: result.message ?? '保存测试账号失败' });
|
||||
}
|
||||
res.status(201).json({ account: result.account });
|
||||
});
|
||||
|
||||
adminApi.delete('/system-tests/accounts/:accountId', requireAdmin, async (req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const result = await systemTestAccountService.deleteAccount(req.params.accountId);
|
||||
if (!result.ok) {
|
||||
return res.status(404).json({ message: result.message ?? '测试账号不存在' });
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
adminApi.post('/system-tests/skill-validation', requireAdmin, async (req, res) => {
|
||||
if (!adminSystemTestService?.runSkillValidation) {
|
||||
return res.status(503).json({ message: '系统测试服务未启用' });
|
||||
}
|
||||
const accountId = String(req.body?.accountId ?? '').trim();
|
||||
let username = String(req.body?.username ?? '').trim();
|
||||
let password = String(req.body?.password ?? '');
|
||||
const skillName = String(req.body?.skillName ?? '').trim();
|
||||
if (accountId) {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const account = await systemTestAccountService.getAccountSecret(accountId);
|
||||
if (!account) {
|
||||
return res.status(404).json({ message: '所选测试账号不存在,请刷新后重试' });
|
||||
}
|
||||
username = account.username;
|
||||
password = account.password;
|
||||
}
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ message: '请输入账号和密码' });
|
||||
}
|
||||
const result = await adminSystemTestService.runSkillValidation({
|
||||
username,
|
||||
password,
|
||||
skillName,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/bindings', requireAdmin, async (req, res) => {
|
||||
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
|
||||
res.json(await wechatAdmin.listBindings(req.query));
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
import { createDbPool, isDatabaseConfigured } from './db.mjs';
|
||||
import { projectRoot } from './load-env.mjs';
|
||||
import { importMemind, resolveMemindLib } from './lib-path.mjs';
|
||||
import { createSystemTestAccountService, ensureSystemTestAccountSchema } from './system-test-accounts.mjs';
|
||||
|
||||
export async function bootstrapAdminServices() {
|
||||
if (!isDatabaseConfigured()) {
|
||||
@@ -27,6 +28,8 @@ export async function bootstrapAdminServices() {
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
} = await importMemind('mindspace-config.mjs');
|
||||
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
|
||||
const { createAdminSystemTestService } = await importMemind('admin-system-tests.mjs');
|
||||
const { createOpsApi } = await importMemind('admin-routes.mjs');
|
||||
const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs');
|
||||
const {
|
||||
@@ -82,6 +85,14 @@ export async function bootstrapAdminServices() {
|
||||
apiTarget,
|
||||
apiSecret,
|
||||
});
|
||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
|
||||
env: process.env,
|
||||
});
|
||||
const adminSystemTestService = createAdminSystemTestService({
|
||||
pool,
|
||||
userAuth,
|
||||
portalBaseUrl: `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`,
|
||||
});
|
||||
const wechatMpConfig = loadWechatMpConfig();
|
||||
const wechatMpService = createWechatMpService({
|
||||
config: wechatMpConfig,
|
||||
@@ -107,6 +118,8 @@ export async function bootstrapAdminServices() {
|
||||
const subscriptionService = createSubscriptionService(pool, {
|
||||
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
|
||||
});
|
||||
await ensureSystemTestAccountSchema(pool);
|
||||
const systemTestAccountService = createSystemTestAccountService(pool);
|
||||
|
||||
console.log(`Admin DB connected (${process.env.MYSQL_DATABASE ?? 'via DATABASE_URL'})`);
|
||||
console.log(`Users root: ${usersRoot}`);
|
||||
@@ -122,6 +135,9 @@ export async function bootstrapAdminServices() {
|
||||
mindSpaceConfig,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
|
||||
@@ -103,6 +103,9 @@ ready
|
||||
wechatAdmin,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
@@ -121,6 +124,9 @@ ready
|
||||
wechatAdmin,
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const TABLE = 'h5_admin_system_test_accounts';
|
||||
|
||||
function resolveEncryptionKey(explicitKey) {
|
||||
const raw =
|
||||
explicitKey ??
|
||||
process.env.H5_SETTINGS_ENCRYPTION_KEY ??
|
||||
process.env.TKMIND_SERVER__SECRET_KEY ??
|
||||
'local-dev-secret';
|
||||
return crypto.createHash('sha256').update(raw).digest();
|
||||
}
|
||||
|
||||
function encryptSecret(plaintext, encryptionKey) {
|
||||
const key = resolveEncryptionKey(encryptionKey);
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
return {
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
tag: cipher.getAuthTag().toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
function decryptSecret({ ciphertext, iv, tag }, encryptionKey) {
|
||||
const key = resolveEncryptionKey(encryptionKey);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||
const plain = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertext, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
return plain.toString('utf8');
|
||||
}
|
||||
|
||||
function maskApiKey(apiKey) {
|
||||
if (!apiKey) return '';
|
||||
if (apiKey.length <= 8) return '*'.repeat(apiKey.length);
|
||||
const head = apiKey.slice(0, 4);
|
||||
const tail = apiKey.slice(-4);
|
||||
return `${head}${'*'.repeat(Math.max(apiKey.length - 8, 4))}${tail}`;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function toAccountRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
username: row.username,
|
||||
passwordMasked: maskApiKey(row.password_preview ?? ''),
|
||||
createdAt: Number(row.created_at ?? 0) || 0,
|
||||
updatedAt: Number(row.updated_at ?? 0) || 0,
|
||||
updatedBy: row.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureSystemTestAccountSchema(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
username VARCHAR(120) NOT NULL,
|
||||
password_ciphertext TEXT NOT NULL,
|
||||
password_iv VARCHAR(255) NOT NULL,
|
||||
password_tag VARCHAR(255) NOT NULL,
|
||||
password_preview VARCHAR(255) NOT NULL DEFAULT '',
|
||||
updated_by CHAR(36) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uniq_${TABLE}_username (username)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function createSystemTestAccountService(pool) {
|
||||
if (!pool) {
|
||||
throw new Error('system test account service requires pool');
|
||||
}
|
||||
|
||||
async function listAccounts() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, label, username, password_preview, updated_by, created_at, updated_at
|
||||
FROM ${TABLE}
|
||||
ORDER BY updated_at DESC, created_at DESC`,
|
||||
);
|
||||
return rows.map(toAccountRow);
|
||||
}
|
||||
|
||||
async function getAccountSecret(accountId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, label, username, password_ciphertext, password_iv, password_tag
|
||||
FROM ${TABLE}
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[accountId],
|
||||
);
|
||||
const row = rows[0] ?? null;
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
username: row.username,
|
||||
password: decryptSecret({
|
||||
ciphertext: row.password_ciphertext,
|
||||
iv: row.password_iv,
|
||||
tag: row.password_tag,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function createAccount({ label, username, password, updatedBy }) {
|
||||
const normalizedUsername = normalizeText(username);
|
||||
const normalizedLabel = normalizeText(label) || normalizedUsername;
|
||||
if (!normalizedUsername || !password) {
|
||||
return { ok: false, message: '账号和密码不能为空' };
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const encrypted = encryptSecret(String(password));
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO ${TABLE}
|
||||
(id, label, username, password_ciphertext, password_iv, password_tag, password_preview, updated_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
normalizedLabel,
|
||||
normalizedUsername,
|
||||
encrypted.ciphertext,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
maskApiKey(String(password)),
|
||||
updatedBy ?? null,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ER_DUP_ENTRY') {
|
||||
return { ok: false, message: '该测试账号已存在,请直接使用或先删除旧账号' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const account = await getAccountById(id);
|
||||
return { ok: true, account };
|
||||
}
|
||||
|
||||
async function getAccountById(accountId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, label, username, password_preview, updated_by, created_at, updated_at
|
||||
FROM ${TABLE}
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[accountId],
|
||||
);
|
||||
const row = rows[0] ?? null;
|
||||
return row ? toAccountRow(row) : null;
|
||||
}
|
||||
|
||||
async function deleteAccount(accountId) {
|
||||
const existing = await getAccountById(accountId);
|
||||
if (!existing) return { ok: false, message: '测试账号不存在' };
|
||||
await pool.query(`DELETE FROM ${TABLE} WHERE id = ? LIMIT 1`, [accountId]);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
listAccounts,
|
||||
getAccountSecret,
|
||||
createAccount,
|
||||
deleteAccount,
|
||||
};
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import { CapabilitiesPage } from './admin/pages/CapabilitiesPage';
|
||||
import { DashboardPage } from './admin/pages/DashboardPage';
|
||||
import { PoliciesPage } from './admin/pages/PoliciesPage';
|
||||
import { MindSpacePage } from './admin/pages/MindSpacePage';
|
||||
import { MemoryV2Page } from './admin/pages/MemoryV2Page';
|
||||
import { ProvidersPage } from './admin/pages/ProvidersPage';
|
||||
import { SkillsPage } from './admin/pages/SkillsPage';
|
||||
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
|
||||
import { UserDetailPage } from './admin/pages/UserDetailPage';
|
||||
import { UsersPage } from './admin/pages/UsersPage';
|
||||
import { WechatPage } from './admin/pages/WechatPage';
|
||||
@@ -20,6 +22,7 @@ import { CreatorsPage } from './ops/pages/CreatorsPage';
|
||||
import { FeaturedPage } from './ops/pages/FeaturedPage';
|
||||
import { ReportsPage } from './ops/pages/ReportsPage';
|
||||
import { ReviewPage } from './ops/pages/ReviewPage';
|
||||
import { PostsPage } from './ops/pages/PostsPage';
|
||||
import type { PortalUser } from './types';
|
||||
|
||||
function LegacyAdminRedirect() {
|
||||
@@ -113,8 +116,10 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
|
||||
<Route path="billing/*" element={<BillingPage />} />
|
||||
<Route path="capabilities" element={<CapabilitiesPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="system-tests" element={<SystemTestsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="mindspace" element={<MindSpacePage />} />
|
||||
<Route path="memory-v2" element={<MemoryV2Page />} />
|
||||
<Route path="providers" element={<ProvidersPage />} />
|
||||
<Route path="wechat" element={<WechatPage />} />
|
||||
<Route path="blocked-words" element={<BlockedWordsPage />} />
|
||||
@@ -125,6 +130,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
|
||||
)}
|
||||
<Route path="/ops" element={<OpsLayout user={user} onLogout={onLogout} />}>
|
||||
<Route index element={<ReviewPage />} />
|
||||
<Route path="posts" element={<PostsPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="featured" element={<FeaturedPage />} />
|
||||
<Route path="creators" element={<CreatorsPage />} />
|
||||
@@ -156,8 +162,10 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|
||||
|| pathname.startsWith('/billing')
|
||||
|| pathname.startsWith('/capabilities')
|
||||
|| pathname.startsWith('/skills')
|
||||
|| pathname.startsWith('/system-tests')
|
||||
|| pathname.startsWith('/policies')
|
||||
|| pathname.startsWith('/mindspace')
|
||||
|| pathname.startsWith('/memory-v2')
|
||||
|| pathname.startsWith('/providers')
|
||||
|| pathname.startsWith('/wechat')
|
||||
) {
|
||||
|
||||
@@ -28,6 +28,8 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
items: [
|
||||
{ to: '/wechat', label: '服务号' },
|
||||
{ to: '/mindspace', label: 'MindSpace 配置' },
|
||||
{ to: '/memory-v2', label: 'Memory V2' },
|
||||
{ to: '/system-tests', label: '系统测试验证' },
|
||||
{ to: '/capabilities', label: '能力' },
|
||||
{ to: '/skills', label: '技能' },
|
||||
{ to: '/policies', label: '策略' },
|
||||
|
||||
@@ -10,6 +10,8 @@ const QUICK_LINKS = [
|
||||
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
|
||||
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
|
||||
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
|
||||
{ to: '/memory-v2', label: 'Memory V2', desc: '长期记忆 backend 与前置路由开关' },
|
||||
{ to: '/system-tests', label: '系统测试验证', desc: '选择测试账号执行联调并汇总问题反馈' },
|
||||
{ to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
import { useCallback, useEffect, useState, type ChangeEvent } from 'react';
|
||||
import {
|
||||
getMemoryV2AdminConfig,
|
||||
listMemoryV2ModelOptions,
|
||||
updateMemoryV2AdminConfig,
|
||||
} from '../../api/client';
|
||||
import type {
|
||||
LlmGlobalSettings,
|
||||
LlmProviderKeyRow,
|
||||
MemoryV2AdminConfig,
|
||||
MemoryV2AdminConfigResponse,
|
||||
MemoryV2ModelApiType,
|
||||
} from '../../types';
|
||||
|
||||
type BackendKey =
|
||||
| 'pgvector'
|
||||
| 'qdrant'
|
||||
| 'weaviate'
|
||||
| 'mem0'
|
||||
| 'letta'
|
||||
| 'neo4j'
|
||||
| 'redisStreams'
|
||||
| 'langgraph';
|
||||
|
||||
type ModelBackendKey = 'mem0' | 'letta' | 'langgraph';
|
||||
|
||||
const BACKENDS: Array<{
|
||||
key: BackendKey;
|
||||
label: string;
|
||||
fields: Array<{ key: string; label: string; secret?: boolean }>;
|
||||
}> = [
|
||||
{
|
||||
key: 'pgvector',
|
||||
label: 'pgvector',
|
||||
fields: [
|
||||
{ key: 'databaseUrl', label: 'Database URL', secret: true },
|
||||
{ key: 'table', label: 'Table' },
|
||||
{ key: 'embeddingModule', label: 'Embedding Module' },
|
||||
{ key: 'poolMax', label: 'Pool Max' },
|
||||
{ key: 'limit', label: 'Recall Limit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'qdrant',
|
||||
label: 'Qdrant',
|
||||
fields: [
|
||||
{ key: 'url', label: 'Base URL' },
|
||||
{ key: 'collection', label: 'Collection' },
|
||||
{ key: 'embeddingModule', label: 'Embedding Module' },
|
||||
{ key: 'apiKey', label: 'API Key', secret: true },
|
||||
{ key: 'timeoutMs', label: 'Timeout Ms' },
|
||||
{ key: 'limit', label: 'Recall Limit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'weaviate',
|
||||
label: 'Weaviate',
|
||||
fields: [
|
||||
{ key: 'url', label: 'Base URL' },
|
||||
{ key: 'collection', label: 'Collection' },
|
||||
{ key: 'embeddingModule', label: 'Embedding Module' },
|
||||
{ key: 'apiKey', label: 'API Key', secret: true },
|
||||
{ key: 'timeoutMs', label: 'Timeout Ms' },
|
||||
{ key: 'limit', label: 'Recall Limit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'mem0',
|
||||
label: 'Mem0',
|
||||
fields: [
|
||||
{ key: 'apiKey', label: 'API Key', secret: true },
|
||||
{ key: 'projectId', label: 'Project ID' },
|
||||
{ key: 'baseUrl', label: 'Base URL' },
|
||||
{ key: 'writePath', label: 'Write Path' },
|
||||
{ key: 'compactPath', label: 'Compact Path' },
|
||||
{ key: 'timeoutMs', label: 'Timeout Ms' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'letta',
|
||||
label: 'Letta',
|
||||
fields: [
|
||||
{ key: 'apiKey', label: 'API Key', secret: true },
|
||||
{ key: 'projectId', label: 'Project ID' },
|
||||
{ key: 'agentId', label: 'Agent ID' },
|
||||
{ key: 'baseUrl', label: 'Base URL' },
|
||||
{ key: 'resolvePath', label: 'Resolve Path' },
|
||||
{ key: 'writePath', label: 'Write Path' },
|
||||
{ key: 'compactPath', label: 'Compact Path' },
|
||||
{ key: 'timeoutMs', label: 'Timeout Ms' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'neo4j',
|
||||
label: 'Neo4j',
|
||||
fields: [
|
||||
{ key: 'uri', label: 'Bolt URI' },
|
||||
{ key: 'httpUrl', label: 'HTTP URL' },
|
||||
{ key: 'user', label: 'Username' },
|
||||
{ key: 'password', label: 'Password', secret: true },
|
||||
{ key: 'database', label: 'Database' },
|
||||
{ key: 'timeoutMs', label: 'Timeout Ms' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'redisStreams',
|
||||
label: 'Redis Streams',
|
||||
fields: [
|
||||
{ key: 'url', label: 'Redis URL' },
|
||||
{ key: 'stream', label: 'Stream' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'langgraph',
|
||||
label: 'LangGraph',
|
||||
fields: [
|
||||
{ key: 'url', label: 'Base URL' },
|
||||
{ key: 'policyId', label: 'Policy ID' },
|
||||
{ key: 'apiKey', label: 'API Key', secret: true },
|
||||
{ key: 'resolvePath', label: 'Resolve Path' },
|
||||
{ key: 'timeoutMs', label: 'Timeout Ms' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MODEL_BACKENDS: ModelBackendKey[] = ['mem0', 'letta', 'langgraph'];
|
||||
const MODEL_API_TYPES: Array<{ value: MemoryV2ModelApiType; label: string }> = [
|
||||
{ value: 'chat', label: 'Chat API' },
|
||||
{ value: 'response', label: 'Response API' },
|
||||
];
|
||||
|
||||
function defaultConfig(): MemoryV2AdminConfig {
|
||||
return {
|
||||
global: {
|
||||
enabled: false,
|
||||
backend: 'legacy',
|
||||
profileEnabled: false,
|
||||
eventLogEnabled: false,
|
||||
vectorEnabled: false,
|
||||
failOpen: true,
|
||||
},
|
||||
chatIntentRouter: {
|
||||
enabled: false,
|
||||
modelProviderKeyId: '',
|
||||
model: '',
|
||||
modelApiType: 'chat',
|
||||
minConfidence: '0.65',
|
||||
memoryResolveEnabled: true,
|
||||
memoryResolveLimit: '8',
|
||||
timeoutMs: '1500',
|
||||
fallbackRoute: 'agent_orchestration',
|
||||
},
|
||||
pgvector: {},
|
||||
qdrant: {},
|
||||
weaviate: {},
|
||||
mem0: {},
|
||||
letta: {},
|
||||
neo4j: {},
|
||||
redisStreams: {},
|
||||
langgraph: {},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConfig(config?: Partial<MemoryV2AdminConfig> | null): MemoryV2AdminConfig {
|
||||
const base = defaultConfig();
|
||||
return {
|
||||
global: { ...base.global, ...(config?.global ?? {}) },
|
||||
chatIntentRouter: { ...base.chatIntentRouter, ...(config?.chatIntentRouter ?? {}) },
|
||||
pgvector: { ...base.pgvector, ...(config?.pgvector ?? {}) },
|
||||
qdrant: { ...base.qdrant, ...(config?.qdrant ?? {}) },
|
||||
weaviate: { ...base.weaviate, ...(config?.weaviate ?? {}) },
|
||||
mem0: { ...base.mem0, ...(config?.mem0 ?? {}) },
|
||||
letta: { ...base.letta, ...(config?.letta ?? {}) },
|
||||
neo4j: { ...base.neo4j, ...(config?.neo4j ?? {}) },
|
||||
redisStreams: { ...base.redisStreams, ...(config?.redisStreams ?? {}) },
|
||||
langgraph: { ...base.langgraph, ...(config?.langgraph ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
function isModelBackend(backend: BackendKey): backend is ModelBackendKey {
|
||||
return MODEL_BACKENDS.includes(backend as ModelBackendKey);
|
||||
}
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>): string[] {
|
||||
return [...new Set(values.map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function findProviderKey(keys: LlmProviderKeyRow[], keyId: string | null | undefined): LlmProviderKeyRow | null {
|
||||
const resolvedKeyId = String(keyId ?? '').trim();
|
||||
if (!resolvedKeyId) return null;
|
||||
return keys.find((key) => key.id === resolvedKeyId) ?? null;
|
||||
}
|
||||
|
||||
function providerLabel(key: LlmProviderKeyRow): string {
|
||||
return `${key.name} · ${key.providerLabel}`;
|
||||
}
|
||||
|
||||
function modelOptionsForProvider(
|
||||
providerKey: LlmProviderKeyRow | null,
|
||||
globalSettings: LlmGlobalSettings | null,
|
||||
): string[] {
|
||||
if (!providerKey) {
|
||||
return dedupeStrings([globalSettings?.globalModel, ...(globalSettings?.availableModels ?? [])]);
|
||||
}
|
||||
return dedupeStrings([providerKey.defaultModel, ...(providerKey.models ?? [])]);
|
||||
}
|
||||
|
||||
function describeEffectiveModelSource(
|
||||
section: MemoryV2AdminConfig[ModelBackendKey],
|
||||
providerKeys: LlmProviderKeyRow[],
|
||||
globalSettings: LlmGlobalSettings | null,
|
||||
): string {
|
||||
const providerKey = findProviderKey(providerKeys, section.modelProviderKeyId);
|
||||
const model = String(section.model ?? '').trim();
|
||||
const apiType = String(section.modelApiType ?? '').trim();
|
||||
const providerText = providerKey ? providerLabel(providerKey) : (globalSettings?.keyName
|
||||
? `${globalSettings.keyName} · ${globalSettings.providerLabel ?? '统一模型中心'}`
|
||||
: '统一模型中心默认');
|
||||
const modelText = model || providerKey?.defaultModel || globalSettings?.globalModel || 'provider 默认模型';
|
||||
const apiText = apiType || '未指定 API 类型';
|
||||
return `${providerText} / ${modelText} / ${apiText}`;
|
||||
}
|
||||
|
||||
export function MemoryV2Page() {
|
||||
const [payload, setPayload] = useState<MemoryV2AdminConfigResponse | null>(null);
|
||||
const [draft, setDraft] = useState<MemoryV2AdminConfig>(defaultConfig());
|
||||
const [providerKeys, setProviderKeys] = useState<LlmProviderKeyRow[]>([]);
|
||||
const [globalModelSettings, setGlobalModelSettings] = useState<LlmGlobalSettings | null>(null);
|
||||
const [supportedApiTypes, setSupportedApiTypes] = useState<MemoryV2ModelApiType[]>(['chat', 'response']);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyScope, setBusyScope] = useState<'global' | 'chatIntentRouter' | BackendKey | 'reload' | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setBusyScope('reload');
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const [configResult, modelOptions] = await Promise.all([
|
||||
getMemoryV2AdminConfig(),
|
||||
listMemoryV2ModelOptions(),
|
||||
]);
|
||||
setPayload(configResult);
|
||||
setDraft(normalizeConfig(configResult.config));
|
||||
setProviderKeys(modelOptions.keys.filter((item) => item.status === 'active'));
|
||||
setGlobalModelSettings(modelOptions.global);
|
||||
setSupportedApiTypes(modelOptions.supportedApiTypes);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 Memory V2 配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setBusyScope((current) => (current === 'reload' ? null : current));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const updateGlobalFlag = (key: keyof MemoryV2AdminConfig['global']) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
global: { ...current.global, [key]: event.target.checked },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateGlobalText = (key: keyof MemoryV2AdminConfig['global']) => (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
global: { ...current.global, [key]: event.target.value },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateRouterFlag = (key: string) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
chatIntentRouter: { ...current.chatIntentRouter, [key]: event.target.checked },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateRouterValue = (key: string) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
chatIntentRouter: { ...current.chatIntentRouter, [key]: event.target.value },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateBackendFlag = (backend: BackendKey) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[backend]: { ...current[backend], enabled: event.target.checked },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateBackendValue = (backend: BackendKey, field: string) => (
|
||||
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[backend]: { ...current[backend], [field]: event.target.value },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateModelProvider = (backend: ModelBackendKey) => (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const nextProviderKeyId = event.target.value;
|
||||
const nextProviderKey = findProviderKey(providerKeys, nextProviderKeyId);
|
||||
const nextOptions = modelOptionsForProvider(nextProviderKey, globalModelSettings);
|
||||
setDraft((current) => {
|
||||
const currentSection = current[backend];
|
||||
const currentModel = String(currentSection.model ?? '').trim();
|
||||
const nextModel = nextOptions.includes(currentModel)
|
||||
? currentModel
|
||||
: String(nextProviderKey?.defaultModel ?? globalModelSettings?.globalModel ?? '').trim();
|
||||
return {
|
||||
...current,
|
||||
[backend]: {
|
||||
...currentSection,
|
||||
modelProviderKeyId: nextProviderKeyId,
|
||||
model: nextModel,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateRouterModelProvider = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const nextProviderKeyId = event.target.value;
|
||||
const nextProviderKey = findProviderKey(providerKeys, nextProviderKeyId);
|
||||
const nextOptions = modelOptionsForProvider(nextProviderKey, globalModelSettings);
|
||||
setDraft((current) => {
|
||||
const currentSection = current.chatIntentRouter;
|
||||
const currentModel = String(currentSection.model ?? '').trim();
|
||||
const nextModel = nextOptions.includes(currentModel)
|
||||
? currentModel
|
||||
: String(nextProviderKey?.defaultModel ?? globalModelSettings?.globalModel ?? '').trim();
|
||||
return {
|
||||
...current,
|
||||
chatIntentRouter: {
|
||||
...currentSection,
|
||||
modelProviderKeyId: nextProviderKeyId,
|
||||
model: nextModel,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const applySavedConfig = (result: MemoryV2AdminConfigResponse, successMessage: string) => {
|
||||
setPayload(result);
|
||||
setDraft(normalizeConfig(result.config));
|
||||
setMessage(successMessage);
|
||||
};
|
||||
|
||||
const saveConfigSection = async (
|
||||
patch: Partial<MemoryV2AdminConfig>,
|
||||
scope: 'global' | 'chatIntentRouter' | BackendKey,
|
||||
successMessage: string,
|
||||
) => {
|
||||
setBusyScope(scope);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await updateMemoryV2AdminConfig(patch);
|
||||
applySavedConfig(result, successMessage);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存 Memory V2 配置失败');
|
||||
} finally {
|
||||
setBusyScope((current) => (current === scope ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
const saveGlobalConfig = async () => {
|
||||
await saveConfigSection(
|
||||
{ global: draft.global },
|
||||
'global',
|
||||
'全局 Memory V2 开关已保存。主站运行时下次访问记忆 facade 时会按新配置自动刷新。',
|
||||
);
|
||||
};
|
||||
|
||||
const saveRouterConfig = async () => {
|
||||
await saveConfigSection(
|
||||
{ chatIntentRouter: draft.chatIntentRouter },
|
||||
'chatIntentRouter',
|
||||
'前置 LLM 意图路由配置已保存。关闭时主站会退回原有 Direct Chat / Agent 判定链路。',
|
||||
);
|
||||
};
|
||||
|
||||
const saveBackendConfig = async (backend: BackendKey) => {
|
||||
await saveConfigSection(
|
||||
{ [backend]: draft[backend] },
|
||||
backend,
|
||||
`${BACKENDS.find((item) => item.key === backend)?.label ?? backend} 配置已保存。主站运行时下次访问记忆 facade 时会按新配置自动刷新。`,
|
||||
);
|
||||
};
|
||||
|
||||
const current = payload?.config ? normalizeConfig(payload.config) : draft;
|
||||
const routerSection = draft.chatIntentRouter;
|
||||
const currentRouterSection = current.chatIntentRouter;
|
||||
const routerProviderKey = findProviderKey(providerKeys, String(routerSection.modelProviderKeyId ?? ''));
|
||||
const routerAvailableModels = modelOptionsForProvider(routerProviderKey, globalModelSettings);
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>Memory V2 配置</h2>
|
||||
<p className="muted">这里是 Memory V2 的统一后台控制面。`memind_adm` 负责改配置,主站会从共享数据库读取并自动切换。</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
|
||||
<div className="admin-form">
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>全局开关</h2>
|
||||
<p className="muted">更新时间:{payload?.updatedAt ? new Date(payload.updatedAt).toLocaleString('zh-CN', { hour12: false }) : '未保存过数据库配置'}</p>
|
||||
</div>
|
||||
<div className="admin-actions">
|
||||
<button type="button" className="send-btn" disabled={loading || busyScope !== null} onClick={() => void saveGlobalConfig()}>
|
||||
{busyScope === 'global' ? '保存中...' : '保存全局'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-form-grid">
|
||||
<label className="inline-check">
|
||||
<input type="checkbox" checked={Boolean(draft.global.enabled)} onChange={updateGlobalFlag('enabled')} />
|
||||
启用 Memory V2
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input type="checkbox" checked={Boolean(draft.global.profileEnabled)} onChange={updateGlobalFlag('profileEnabled')} />
|
||||
启用 Profile
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input type="checkbox" checked={Boolean(draft.global.eventLogEnabled)} onChange={updateGlobalFlag('eventLogEnabled')} />
|
||||
启用 Event Log
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input type="checkbox" checked={Boolean(draft.global.vectorEnabled)} onChange={updateGlobalFlag('vectorEnabled')} />
|
||||
启用 Vector
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input type="checkbox" checked={Boolean(draft.global.failOpen)} onChange={updateGlobalFlag('failOpen')} />
|
||||
Memory 故障时 fail-open
|
||||
</label>
|
||||
<label>
|
||||
<span>默认 Backend</span>
|
||||
<select value={String(draft.global.backend ?? 'legacy')} onChange={updateGlobalText('backend')}>
|
||||
<option value="legacy">legacy</option>
|
||||
{BACKENDS.map((backend) => (
|
||||
<option key={backend.key} value={backend.key}>{backend.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{!loading && (
|
||||
<p className="muted">
|
||||
当前数据库配置默认 backend:<strong>{String(current.global.backend ?? 'legacy')}</strong>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>前置 LLM 意图路由</h2>
|
||||
<p className="muted">关闭时主站完全退回原有 Direct Chat / Agent 判定;打开后才会调用轻量 LLM,并可读取 Memory V2 的轻量上下文。</p>
|
||||
</div>
|
||||
<div className="admin-actions">
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(routerSection.enabled)}
|
||||
onChange={updateRouterFlag('enabled')}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={loading || busyScope !== null}
|
||||
onClick={() => void saveRouterConfig()}
|
||||
>
|
||||
{busyScope === 'chatIntentRouter' ? '保存中...' : '保存路由'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-form-grid">
|
||||
<label>
|
||||
<span>模型来源</span>
|
||||
<select
|
||||
value={String(routerSection.modelProviderKeyId ?? '')}
|
||||
onChange={updateRouterModelProvider}
|
||||
>
|
||||
<option value="">跟随统一模型中心默认</option>
|
||||
{providerKeys.map((item) => (
|
||||
<option key={item.id} value={item.id}>{providerLabel(item)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>模型</span>
|
||||
<select
|
||||
value={String(routerSection.model ?? '')}
|
||||
onChange={updateRouterValue('model')}
|
||||
>
|
||||
<option value="">
|
||||
{routerProviderKey ? '使用当前 Provider 默认模型' : '跟随统一模型中心默认模型'}
|
||||
</option>
|
||||
{routerAvailableModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>模型类型</span>
|
||||
<select
|
||||
value={String(routerSection.modelApiType ?? 'chat')}
|
||||
onChange={updateRouterValue('modelApiType')}
|
||||
>
|
||||
{MODEL_API_TYPES.map((apiType) => (
|
||||
<option key={apiType.value} value={apiType.value}>{apiType.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>最低置信度</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={String(routerSection.minConfidence ?? '0.65')}
|
||||
onChange={updateRouterValue('minConfidence')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>记忆条数</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={String(routerSection.memoryResolveLimit ?? '8')}
|
||||
onChange={updateRouterValue('memoryResolveLimit')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>超时 Ms</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="30000"
|
||||
value={String(routerSection.timeoutMs ?? '1500')}
|
||||
onChange={updateRouterValue('timeoutMs')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>低置信 / 失败回退</span>
|
||||
<select
|
||||
value={String(routerSection.fallbackRoute ?? 'agent_orchestration')}
|
||||
onChange={updateRouterValue('fallbackRoute')}
|
||||
>
|
||||
<option value="agent_orchestration">Agent 编排</option>
|
||||
<option value="direct_chat">Direct Chat</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(routerSection.memoryResolveEnabled)}
|
||||
onChange={updateRouterFlag('memoryResolveEnabled')}
|
||||
/>
|
||||
路由前读取 Memory V2
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted">
|
||||
当前数据库配置:<strong>{currentRouterSection.enabled ? '已启用' : '未启用'}</strong>
|
||||
{' · '}
|
||||
生效模型链路:<strong>{describeEffectiveModelSource(routerSection, providerKeys, globalModelSettings)}</strong>
|
||||
</p>
|
||||
{!providerKeys.length && (
|
||||
<p className="muted">
|
||||
统一模型中心当前没有可用的激活 key;若启用路由,请先配置 Provider key 与模型列表。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{BACKENDS.map((backend) => {
|
||||
const section = draft[backend.key];
|
||||
const currentSection = current[backend.key];
|
||||
const providerKey = isModelBackend(backend.key)
|
||||
? findProviderKey(providerKeys, section.modelProviderKeyId)
|
||||
: null;
|
||||
const availableModels = isModelBackend(backend.key)
|
||||
? modelOptionsForProvider(providerKey, globalModelSettings)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section key={backend.key} className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>{backend.label}</h2>
|
||||
<p className="muted">单独启用后,只有当全局默认 backend 指向它时才会成为主读写目标。</p>
|
||||
</div>
|
||||
<div className="admin-actions">
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(section.enabled)}
|
||||
onChange={updateBackendFlag(backend.key)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={loading || busyScope !== null}
|
||||
onClick={() => void saveBackendConfig(backend.key)}
|
||||
>
|
||||
{busyScope === backend.key ? '保存中...' : `保存 ${backend.label}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-form-grid">
|
||||
{backend.fields.map((field) => {
|
||||
const configured = Boolean(currentSection?.[`${field.key}Configured`]);
|
||||
const masked = String(currentSection?.[`${field.key}Masked`] ?? '');
|
||||
return (
|
||||
<label key={field.key}>
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
type={field.secret ? 'password' : 'text'}
|
||||
value={String(section[field.key] ?? '')}
|
||||
placeholder={field.secret && configured ? `已配置:${masked || '******'};留空保持不变` : ''}
|
||||
onChange={updateBackendValue(backend.key, field.key)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isModelBackend(backend.key) && (
|
||||
<>
|
||||
<div className="admin-form-grid" style={{ marginTop: 12 }}>
|
||||
<label>
|
||||
<span>模型来源</span>
|
||||
<select
|
||||
value={String(section.modelProviderKeyId ?? '')}
|
||||
onChange={updateModelProvider(backend.key)}
|
||||
>
|
||||
<option value="">跟随统一模型中心默认</option>
|
||||
{providerKeys.map((item) => (
|
||||
<option key={item.id} value={item.id}>{providerLabel(item)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>模型</span>
|
||||
<select
|
||||
value={String(section.model ?? '')}
|
||||
onChange={updateBackendValue(backend.key, 'model')}
|
||||
>
|
||||
<option value="">
|
||||
{providerKey ? '使用当前 Provider 默认模型' : '跟随统一模型中心默认模型'}
|
||||
</option>
|
||||
{availableModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>模型类型</span>
|
||||
<select
|
||||
value={String(section.modelApiType ?? '')}
|
||||
onChange={updateBackendValue(backend.key, 'modelApiType')}
|
||||
>
|
||||
<option value="">未指定</option>
|
||||
{supportedApiTypes.map((apiType) => (
|
||||
<option key={apiType} value={apiType}>
|
||||
{MODEL_API_TYPES.find((item) => item.value === apiType)?.label ?? apiType}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted">
|
||||
当前生效模型链路:<strong>{describeEffectiveModelSource(section, providerKeys, globalModelSettings)}</strong>
|
||||
</p>
|
||||
{!providerKeys.length && (
|
||||
<p className="muted">
|
||||
统一模型中心当前没有可用的激活 key,请先到“统一模型中心”配置 provider key 与模型列表。
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="admin-actions">
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={busyScope !== null && busyScope !== 'reload'}>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
createSystemTestAccount,
|
||||
deleteSystemTestAccount,
|
||||
listSkillCatalog,
|
||||
listSystemTestAccounts,
|
||||
runSystemSkillValidation,
|
||||
} from '../../api/client';
|
||||
import type { AdminSystemTestAccount, AdminSystemTestReport, SkillDefinition } from '../../types';
|
||||
import { formatTime } from '../utils/format';
|
||||
|
||||
const DEFAULT_SKILL = 'service-integration-smoke';
|
||||
|
||||
function statusLabel(status: 'passed' | 'warning' | 'failed') {
|
||||
if (status === 'passed') return '通过';
|
||||
if (status === 'warning') return '待处理';
|
||||
return '失败';
|
||||
}
|
||||
|
||||
function AddAccountModal({
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSave: (payload: { label: string; username: string; password: string }) => Promise<void>;
|
||||
}) {
|
||||
const [label, setLabel] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const normalizedUsername = username.trim();
|
||||
const normalizedLabel = label.trim() || normalizedUsername;
|
||||
if (!normalizedUsername || !password) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({
|
||||
label: normalizedLabel,
|
||||
username: normalizedUsername,
|
||||
password,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存测试账号失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={(event) => event.target === event.currentTarget && onClose()}>
|
||||
<div className="modal-box" style={{ maxWidth: 520 }}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h3>添加测试账号</h3>
|
||||
<p className="muted">保存到后台配置,后续管理员都可以直接复用。</p>
|
||||
</div>
|
||||
<button type="button" className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
<form className="admin-form plan-form" onSubmit={handleSubmit}>
|
||||
<label className="plan-form-row">
|
||||
<span>显示名称</span>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder="例如 John 测试号"
|
||||
/>
|
||||
</label>
|
||||
<label className="plan-form-row">
|
||||
<span>账号</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="例如 john"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="plan-form-row">
|
||||
<span>密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入该账号密码"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div className="plan-form-actions">
|
||||
<button type="button" className="admin-btn-secondary" onClick={onClose} disabled={saving}>取消</button>
|
||||
<button type="submit" className="send-btn" disabled={saving || !username.trim() || !password}>
|
||||
{saving ? '保存中…' : '保存账号'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemTestsPage() {
|
||||
const [catalog, setCatalog] = useState<SkillDefinition[]>([]);
|
||||
const [skillName, setSkillName] = useState(DEFAULT_SKILL);
|
||||
const [accounts, setAccounts] = useState<AdminSystemTestAccount[]>([]);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [accountsLoading, setAccountsLoading] = useState(true);
|
||||
const [accountBusyId, setAccountBusyId] = useState<string | null>(null);
|
||||
const [showAddAccount, setShowAddAccount] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [report, setReport] = useState<AdminSystemTestReport | null>(null);
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextCatalog = await listSkillCatalog();
|
||||
setCatalog(nextCatalog);
|
||||
if (nextCatalog.some((item) => item.name === DEFAULT_SKILL)) {
|
||||
setSkillName(DEFAULT_SKILL);
|
||||
} else if (nextCatalog[0]?.name) {
|
||||
setSkillName(nextCatalog[0].name);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 skill 列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setAccountsLoading(true);
|
||||
try {
|
||||
const nextAccounts = await listSystemTestAccounts();
|
||||
setAccounts(nextAccounts);
|
||||
setSelectedAccountId((current) =>
|
||||
nextAccounts.some((item) => item.id === current) ? current : (nextAccounts[0]?.id ?? ''),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载测试账号失败');
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccounts();
|
||||
}, [loadAccounts]);
|
||||
|
||||
const selectedSkill = useMemo(
|
||||
() => catalog.find((item) => item.name === skillName) ?? null,
|
||||
[catalog, skillName],
|
||||
);
|
||||
|
||||
const selectedAccount = useMemo(
|
||||
() => accounts.find((item) => item.id === selectedAccountId) ?? null,
|
||||
[accounts, selectedAccountId],
|
||||
);
|
||||
|
||||
const handleSaveAccount = async (payload: { label: string; username: string; password: string }) => {
|
||||
const account = await createSystemTestAccount(payload);
|
||||
setAccounts((current) => [account, ...current]);
|
||||
setSelectedAccountId(account.id);
|
||||
setShowAddAccount(false);
|
||||
};
|
||||
|
||||
const handleRemoveAccount = async () => {
|
||||
if (!selectedAccount) return;
|
||||
setAccountBusyId(selectedAccount.id);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteSystemTestAccount(selectedAccount.id);
|
||||
const nextAccounts = accounts.filter((item) => item.id !== selectedAccount.id);
|
||||
setAccounts(nextAccounts);
|
||||
setSelectedAccountId(nextAccounts[0]?.id ?? '');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '删除测试账号失败');
|
||||
} finally {
|
||||
setAccountBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedAccount) {
|
||||
setError('请先选择一个测试账号,或先添加账号');
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextReport = await runSystemSkillValidation({
|
||||
accountId: selectedAccount.id,
|
||||
username: '',
|
||||
password: '',
|
||||
skillName,
|
||||
});
|
||||
setReport(nextReport);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '系统测试执行失败');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>系统测试验证</h2>
|
||||
<p className="muted">选择测试账号后,按选定 skill 执行整条服务联调,并汇总异常反馈。</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>执行参数</h2>
|
||||
<p className="muted">默认推荐使用 service-integration-smoke,对真实账号跑登录、直连聊天、记忆读取和 skill 输出验证。</p>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="muted">加载 skill 列表中…</p>
|
||||
) : (
|
||||
<form className="admin-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
<span>验证 Skill</span>
|
||||
<select value={skillName} onChange={(e) => setSkillName(e.target.value)} className="admin-select">
|
||||
{catalog.map((item) => (
|
||||
<option key={item.name} value={item.name}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="form-span-all system-test-account-panel">
|
||||
<label className="system-test-account-select">
|
||||
<span>测试账号</span>
|
||||
<select
|
||||
value={selectedAccountId}
|
||||
onChange={(event) => setSelectedAccountId(event.target.value)}
|
||||
className="admin-select"
|
||||
disabled={accounts.length === 0 || accountsLoading}
|
||||
>
|
||||
{accountsLoading ? (
|
||||
<option value="">加载测试账号中…</option>
|
||||
) : accounts.length === 0 ? (
|
||||
<option value="">请先添加测试账号</option>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.label} ({account.username})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="system-test-account-actions">
|
||||
<button type="button" className="admin-btn-secondary" onClick={() => setShowAddAccount(true)}>
|
||||
+ 添加账号
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-secondary"
|
||||
onClick={() => void handleRemoveAccount()}
|
||||
disabled={!selectedAccount || accountBusyId === selectedAccount?.id}
|
||||
>
|
||||
{accountBusyId === selectedAccount?.id ? '删除中…' : '删除当前账号'}
|
||||
</button>
|
||||
<button type="button" className="admin-btn-secondary" onClick={() => void loadAccounts()} disabled={accountsLoading}>
|
||||
{accountsLoading ? '刷新中…' : '刷新账号'}
|
||||
</button>
|
||||
</div>
|
||||
{selectedAccount ? (
|
||||
<div className="system-test-account-summary muted">
|
||||
当前使用: <strong>{selectedAccount.label}</strong> · @{selectedAccount.username} · 密码 {selectedAccount.passwordMasked}
|
||||
</div>
|
||||
) : (
|
||||
<div className="system-test-account-summary muted">
|
||||
还没有可用测试账号,请先添加账号和密码。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedSkill && (
|
||||
<p className="muted">
|
||||
当前 skill: <strong>{selectedSkill.label}</strong> · {selectedSkill.description}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="send-btn" disabled={running || loading}>
|
||||
{running ? '验证执行中…' : '开始验证'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{showAddAccount && (
|
||||
<AddAccountModal
|
||||
onClose={() => setShowAddAccount(false)}
|
||||
onSave={handleSaveAccount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>验证摘要</h2>
|
||||
<p className="muted">
|
||||
账号 @{report.account.username} · skill {report.selectedSkill} · Portal {report.portalBaseUrl}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-grid">
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">总体结果</div>
|
||||
<div className="admin-stat-value">{report.ok ? '通过' : '有问题'}</div>
|
||||
<div className="muted">
|
||||
开始 {formatTime(report.startedAt)} / 完成 {formatTime(report.finishedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">通过</div>
|
||||
<div className="admin-stat-value">{report.summary.passed}</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">待处理</div>
|
||||
<div className="admin-stat-value">{report.summary.warnings}</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">失败</div>
|
||||
<div className="admin-stat-value">{report.summary.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>步骤结果</h2>
|
||||
</div>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>步骤</th>
|
||||
<th>状态</th>
|
||||
<th>结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.steps.map((step) => (
|
||||
<tr key={step.key}>
|
||||
<td>{step.label}</td>
|
||||
<td>{statusLabel(step.status)}</td>
|
||||
<td>
|
||||
<div>{step.message}</div>
|
||||
{step.details && (
|
||||
<details style={{ marginTop: 8 }}>
|
||||
<summary className="muted">查看详情</summary>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||
{JSON.stringify(step.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>问题反馈</h2>
|
||||
</div>
|
||||
{report.issues.length === 0 ? (
|
||||
<p className="muted">本轮未发现额外问题。</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>级别</th>
|
||||
<th>步骤</th>
|
||||
<th>问题</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.issues.map((issue, index) => (
|
||||
<tr key={`${issue.stepKey}-${index}`}>
|
||||
<td>{issue.severity === 'error' ? '错误' : '提醒'}</td>
|
||||
<td>{issue.stepKey}</td>
|
||||
<td>
|
||||
<div>{issue.title}</div>
|
||||
<div className="muted" style={{ marginTop: 6 }}>{issue.detail}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
AdminDashboardSummary,
|
||||
AdminServiceRestartAction,
|
||||
AdminServiceRestartResult,
|
||||
AdminSystemTestReport,
|
||||
AdminSystemTestAccount,
|
||||
AdminSubscription,
|
||||
AdminUserRow,
|
||||
AuthStatus,
|
||||
@@ -26,6 +28,9 @@ import type {
|
||||
PolicyMap,
|
||||
PortalUser,
|
||||
MindSpaceAdminConfig,
|
||||
MemoryV2AdminConfig,
|
||||
MemoryV2AdminConfigResponse,
|
||||
MemoryV2ModelApiType,
|
||||
SkillDefinition,
|
||||
SkillMap,
|
||||
UsageRecord,
|
||||
@@ -278,6 +283,70 @@ export async function updateMindSpaceAdminConfig(
|
||||
return result.config;
|
||||
}
|
||||
|
||||
export async function getMemoryV2AdminConfig(): Promise<MemoryV2AdminConfigResponse> {
|
||||
return portalFetch('/admin-api/memory-v2/config');
|
||||
}
|
||||
|
||||
export async function updateMemoryV2AdminConfig(
|
||||
payload: Partial<MemoryV2AdminConfig>,
|
||||
): Promise<MemoryV2AdminConfigResponse> {
|
||||
return portalFetch('/admin-api/memory-v2/config', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listMemoryV2ModelOptions(): Promise<{
|
||||
global: LlmGlobalSettings;
|
||||
keys: LlmProviderKeyRow[];
|
||||
supportedApiTypes: MemoryV2ModelApiType[];
|
||||
}> {
|
||||
const [global, keys] = await Promise.all([
|
||||
getLlmGlobalSettings(),
|
||||
listLlmProviderKeys(),
|
||||
]);
|
||||
return {
|
||||
global,
|
||||
keys,
|
||||
supportedApiTypes: ['chat', 'response'],
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSystemSkillValidation(payload: {
|
||||
accountId?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
skillName: string;
|
||||
}): Promise<AdminSystemTestReport> {
|
||||
return portalFetch('/admin-api/system-tests/skill-validation', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSystemTestAccounts(): Promise<AdminSystemTestAccount[]> {
|
||||
const result = await portalFetch<{ accounts: AdminSystemTestAccount[] }>('/admin-api/system-tests/accounts');
|
||||
return result.accounts ?? [];
|
||||
}
|
||||
|
||||
export async function createSystemTestAccount(payload: {
|
||||
label?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<AdminSystemTestAccount> {
|
||||
const result = await portalFetch<{ account: AdminSystemTestAccount }>('/admin-api/system-tests/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return result.account;
|
||||
}
|
||||
|
||||
export async function deleteSystemTestAccount(accountId: string): Promise<void> {
|
||||
await portalFetch(`/admin-api/system-tests/accounts/${encodeURIComponent(accountId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function listWechatBindings(params?: {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
clearUserCapabilityOverrides,
|
||||
clearUserSkillOverrides,
|
||||
getRoleCapabilities,
|
||||
getRoleSkills,
|
||||
getUserCapabilities,
|
||||
getUserSkills,
|
||||
listCapabilityCatalog,
|
||||
listSkillCatalog,
|
||||
updateRoleCapabilities,
|
||||
updateRoleSkills,
|
||||
updateUserCapabilities,
|
||||
updateUserSkills,
|
||||
} from '../api/client';
|
||||
import type { AdminUserRow, SkillDefinition, SkillMap } from '../types';
|
||||
|
||||
const WEB_CAPABILITY_KEY = 'web';
|
||||
const WEB_SKILL_NAME = 'web';
|
||||
|
||||
function SkillGrid({
|
||||
catalog,
|
||||
values,
|
||||
@@ -51,6 +60,75 @@ function SkillGrid({
|
||||
);
|
||||
}
|
||||
|
||||
function WebSearchToolPanel({
|
||||
roleEnabled,
|
||||
userEnabled,
|
||||
showUser,
|
||||
disabled,
|
||||
onRoleChange,
|
||||
onUserChange,
|
||||
}: {
|
||||
roleEnabled: boolean;
|
||||
userEnabled: boolean;
|
||||
showUser: boolean;
|
||||
disabled?: boolean;
|
||||
onRoleChange: (enabled: boolean) => void;
|
||||
onUserChange: (enabled: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="capability-grid" style={{ marginBottom: '1.25rem' }}>
|
||||
<div className="capability-group">
|
||||
<div className="capability-group-header">
|
||||
<h3>联网搜索工具</h3>
|
||||
<span className="capability-group-count">platform/web</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li className="capability-item">
|
||||
<div className="capability-item-info">
|
||||
<span className="capability-label">
|
||||
网页搜索
|
||||
<code className="skill-name-tag">{WEB_CAPABILITY_KEY}</code>
|
||||
<span className="risk-pill risk-medium">风险 中</span>
|
||||
</span>
|
||||
<span className="muted capability-desc">
|
||||
在 Agent session 挂载 <code>platform/web</code> 扩展,提供{' '}
|
||||
<code>web_search</code> / <code>fetch_url</code>。需同时开启下方{' '}
|
||||
<code>{WEB_SKILL_NAME}</code> 技能;用户策略 <code>network_egress=deny</code>{' '}
|
||||
时会自动禁用。
|
||||
</span>
|
||||
</div>
|
||||
{!showUser ? (
|
||||
<label className="capability-toggle" aria-label="启用网页搜索">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={roleEnabled}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onRoleChange(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track">
|
||||
<span className="toggle-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<label className="capability-toggle" aria-label="启用网页搜索">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={userEnabled}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onUserChange(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track">
|
||||
<span className="toggle-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillSettings({
|
||||
users,
|
||||
userId,
|
||||
@@ -62,8 +140,12 @@ export function SkillSettings({
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<SkillDefinition[]>([]);
|
||||
const [roleSkills, setRoleSkills] = useState<SkillMap>({});
|
||||
const [roleWebEnabled, setRoleWebEnabled] = useState(false);
|
||||
const [hasWebCapabilityCatalog, setHasWebCapabilityCatalog] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||||
const [userSkills, setUserSkills] = useState<SkillMap>({});
|
||||
const [userWebEnabled, setUserWebEnabled] = useState(false);
|
||||
const [userWebOverride, setUserWebOverride] = useState<boolean | null>(null);
|
||||
const [userOverrides, setUserOverrides] = useState<SkillMap>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
@@ -73,12 +155,16 @@ export function SkillSettings({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, roleState] = await Promise.all([
|
||||
const [catalogItems, roleState, capabilityCatalog, roleCapabilities] = await Promise.all([
|
||||
listSkillCatalog(),
|
||||
getRoleSkills('user'),
|
||||
listCapabilityCatalog(),
|
||||
getRoleCapabilities('user'),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setRoleSkills(roleState.skills);
|
||||
setHasWebCapabilityCatalog(capabilityCatalog.some((item) => item.key === WEB_CAPABILITY_KEY));
|
||||
setRoleWebEnabled(Boolean(roleCapabilities.capabilities[WEB_CAPABILITY_KEY]));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载技能目录失败');
|
||||
} finally {
|
||||
@@ -89,13 +175,24 @@ export function SkillSettings({
|
||||
const loadUserSkills = useCallback(async (userId: string) => {
|
||||
if (!userId) {
|
||||
setUserSkills({});
|
||||
setUserWebEnabled(false);
|
||||
setUserWebOverride(null);
|
||||
setUserOverrides({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await getUserSkills(userId);
|
||||
const [state, capabilityState] = await Promise.all([
|
||||
getUserSkills(userId),
|
||||
getUserCapabilities(userId),
|
||||
]);
|
||||
setUserSkills(state.skills);
|
||||
setUserOverrides(state.overrides);
|
||||
setUserWebEnabled(Boolean(capabilityState.capabilities[WEB_CAPABILITY_KEY]));
|
||||
setUserWebOverride(
|
||||
Object.prototype.hasOwnProperty.call(capabilityState.overrides, WEB_CAPABILITY_KEY)
|
||||
? Boolean(capabilityState.overrides[WEB_CAPABILITY_KEY])
|
||||
: null,
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载用户技能失败');
|
||||
}
|
||||
@@ -119,9 +216,14 @@ export function SkillSettings({
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateRoleSkills('user', roleSkills);
|
||||
setRoleSkills(result.skills);
|
||||
setMessage('普通用户默认技能已保存,并已同步到各用户工作区');
|
||||
const [skillsResult] = await Promise.all([
|
||||
updateRoleSkills('user', roleSkills),
|
||||
hasWebCapabilityCatalog
|
||||
? updateRoleCapabilities('user', { [WEB_CAPABILITY_KEY]: roleWebEnabled })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
setRoleSkills(skillsResult.skills);
|
||||
setMessage('普通用户默认技能与联网工具已保存,并已同步到各用户工作区');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
@@ -132,10 +234,15 @@ export function SkillSettings({
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateUserSkills(selectedUserId, userSkills);
|
||||
setUserSkills(result.skills);
|
||||
const [skillsResult] = await Promise.all([
|
||||
updateUserSkills(selectedUserId, userSkills),
|
||||
hasWebCapabilityCatalog
|
||||
? updateUserCapabilities(selectedUserId, { [WEB_CAPABILITY_KEY]: userWebEnabled })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
setUserSkills(skillsResult.skills);
|
||||
await loadUserSkills(selectedUserId);
|
||||
setMessage('用户技能已保存,并已安装到其 MindSpace 发布目录');
|
||||
setMessage('用户技能与联网工具已保存,并已安装到其 MindSpace 发布目录');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
@@ -146,15 +253,28 @@ export function SkillSettings({
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await clearUserSkillOverrides(selectedUserId);
|
||||
await Promise.all([
|
||||
clearUserSkillOverrides(selectedUserId),
|
||||
hasWebCapabilityCatalog ? clearUserCapabilityOverrides(selectedUserId) : Promise.resolve(),
|
||||
]);
|
||||
await loadUserSkills(selectedUserId);
|
||||
setMessage('已恢复为角色默认技能');
|
||||
setMessage('已恢复为角色默认技能与联网工具');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const regularUsers = users.filter((user) => user.role === 'user');
|
||||
const webSkillEnabledForRole = Boolean(roleSkills[WEB_SKILL_NAME]);
|
||||
const webSkillEnabledForUser = Boolean(userSkills[WEB_SKILL_NAME]);
|
||||
const webMismatchRole = useMemo(
|
||||
() => hasWebCapabilityCatalog && webSkillEnabledForRole && !roleWebEnabled,
|
||||
[hasWebCapabilityCatalog, webSkillEnabledForRole, roleWebEnabled],
|
||||
);
|
||||
const webMismatchUser = useMemo(
|
||||
() => hasWebCapabilityCatalog && webSkillEnabledForUser && !userWebEnabled,
|
||||
[hasWebCapabilityCatalog, webSkillEnabledForUser, userWebEnabled],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
@@ -166,18 +286,36 @@ export function SkillSettings({
|
||||
</div>
|
||||
<p className="muted capability-intro">
|
||||
勾选后技能会安装到用户 <code>MindSpace/用户名/.agents/skills/</code>,对话中可通过 load_skill
|
||||
使用。开通「静态页面发布」技能会自动启用页面写入能力。
|
||||
使用。开通「静态页面发布」技能会自动启用页面写入能力。联网搜索需在下方同时开启{' '}
|
||||
<strong>网页搜索工具</strong> 与 <code>web</code> 技能。
|
||||
</p>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : !hasWebCapabilityCatalog ? (
|
||||
<p className="banner banner-error">
|
||||
当前 Portal 能力目录缺少 <code>web</code>,请更新 Memind 后端并重启 admin-api。
|
||||
</p>
|
||||
) : catalog.length === 0 ? (
|
||||
<p className="muted">暂无平台技能,请在 Memind/skills/ 下添加 SKILL.md</p>
|
||||
) : (
|
||||
<>
|
||||
{!userOnly && (
|
||||
<>
|
||||
<h3 className="capability-section-title">普通用户默认</h3>
|
||||
<WebSearchToolPanel
|
||||
roleEnabled={roleWebEnabled}
|
||||
userEnabled={userWebEnabled}
|
||||
showUser={false}
|
||||
onRoleChange={setRoleWebEnabled}
|
||||
onUserChange={setUserWebEnabled}
|
||||
/>
|
||||
{webMismatchRole ? (
|
||||
<p className="banner banner-error">
|
||||
已开启 <code>web</code> 技能但未启用网页搜索工具,Agent 将无法使用 web_search。
|
||||
</p>
|
||||
) : null}
|
||||
<h3 className="capability-section-title">普通用户默认技能</h3>
|
||||
<SkillGrid
|
||||
catalog={catalog}
|
||||
@@ -186,13 +324,13 @@ export function SkillSettings({
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
|
||||
保存默认技能
|
||||
保存默认配置
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!userOnly && <h3 className="capability-section-title">单用户技能覆盖</h3>}
|
||||
{!userOnly && <h3 className="capability-section-title">单用户覆盖</h3>}
|
||||
{!userOnly && (
|
||||
<div className="admin-form capability-user-picker">
|
||||
<select value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
|
||||
@@ -207,11 +345,27 @@ export function SkillSettings({
|
||||
)}
|
||||
{selectedUserId && (
|
||||
<>
|
||||
{userWebOverride != null && (
|
||||
<p className="muted">联网工具已单独覆盖为 {userWebOverride ? '开启' : '关闭'}。</p>
|
||||
)}
|
||||
<WebSearchToolPanel
|
||||
roleEnabled={roleWebEnabled}
|
||||
userEnabled={userWebEnabled}
|
||||
showUser
|
||||
onRoleChange={setRoleWebEnabled}
|
||||
onUserChange={setUserWebEnabled}
|
||||
/>
|
||||
{webMismatchUser ? (
|
||||
<p className="banner banner-error">
|
||||
该用户已开启 <code>web</code> 技能但未启用网页搜索工具。
|
||||
</p>
|
||||
) : null}
|
||||
{Object.keys(userOverrides).length > 0 && (
|
||||
<p className="muted">
|
||||
已设置 {Object.keys(userOverrides).length} 项单独覆盖。
|
||||
已设置 {Object.keys(userOverrides).length} 项技能单独覆盖。
|
||||
</p>
|
||||
)}
|
||||
<h3 className="capability-section-title">用户技能</h3>
|
||||
<SkillGrid
|
||||
catalog={catalog}
|
||||
values={userSkills}
|
||||
@@ -221,7 +375,7 @@ export function SkillSettings({
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveUserOverrides()}>
|
||||
保存用户技能
|
||||
保存用户配置
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => void resetUserOverrides()}>
|
||||
恢复角色默认
|
||||
|
||||
@@ -1649,6 +1649,30 @@ body,
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.system-test-account-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
|
||||
.system-test-account-select {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.system-test-account-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.system-test-account-summary {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Plan catalog table extras ──────────────────────────────────── */
|
||||
.plan-desc {
|
||||
font-size: 11px;
|
||||
|
||||
@@ -24,12 +24,24 @@ export type ReviewPost = {
|
||||
title: string;
|
||||
summary: string;
|
||||
cover_url: string;
|
||||
status: string;
|
||||
author: { display_name: string; slug: string };
|
||||
category: { name: string; slug: string; icon: string };
|
||||
published_at: string;
|
||||
sla_warning: boolean;
|
||||
};
|
||||
|
||||
export type PlazaCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
export async function fetchPlazaCategories() {
|
||||
return opsFetch<{ categories: PlazaCategory[] }>('/api/ops/v1/categories');
|
||||
}
|
||||
|
||||
export async function fetchReviewQueue(query = 'status=pending_review') {
|
||||
const suffix = query.startsWith('?') ? query : query ? `?${query}` : '';
|
||||
return opsFetch<{ posts: ReviewPost[]; next_cursor: string | null; has_more: boolean }>(
|
||||
|
||||
@@ -10,6 +10,7 @@ const opsSections = [
|
||||
{
|
||||
label: '内容管理',
|
||||
items: [
|
||||
{ to: '/ops/posts', label: '帖子管理' },
|
||||
{ to: '/ops/reports', label: '举报处理' },
|
||||
{ to: '/ops/featured', label: '精选管理' },
|
||||
{ to: '/ops/creators', label: '创作者' },
|
||||
|
||||
@@ -708,3 +708,61 @@ input[type='checkbox'] {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.ops-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ops-filter-label {
|
||||
flex: 0 0 auto;
|
||||
padding-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--ops-muted);
|
||||
}
|
||||
|
||||
.ops-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ops-chip {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--ops-text);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ops-chip.active {
|
||||
border-color: rgba(120, 170, 255, 0.45);
|
||||
background: rgba(120, 170, 255, 0.14);
|
||||
}
|
||||
|
||||
.ops-title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ops-inline-action {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ops-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
fetchPlazaCategories,
|
||||
fetchReviewQueue,
|
||||
reviewPost,
|
||||
type PlazaCategory,
|
||||
type ReviewPost,
|
||||
} from '../api/client';
|
||||
import { plazaPreviewUrl } from '../lib/site';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: 'published', label: '已发布' },
|
||||
{ key: 'hidden', label: '已下线' },
|
||||
] as const;
|
||||
|
||||
type StatusTab = (typeof STATUS_TABS)[number]['key'];
|
||||
|
||||
function formatPublishedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
export function PostsPage() {
|
||||
const [tab, setTab] = useState<StatusTab>('published');
|
||||
const [categories, setCategories] = useState<PlazaCategory[]>([]);
|
||||
const [categorySlug, setCategorySlug] = useState<string | null>(null);
|
||||
const [posts, setPosts] = useState<ReviewPost[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageCursors, setPageCursors] = useState<Array<string | null>>([null]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPlazaCategories()
|
||||
.then((data) => setCategories(data.categories))
|
||||
.catch(() => {
|
||||
setCategories([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async ({
|
||||
status = tab,
|
||||
cursor = pageCursors[pageIndex] ?? null,
|
||||
category = categorySlug,
|
||||
search = keyword,
|
||||
nextPageIndex = pageIndex,
|
||||
}: {
|
||||
status?: StatusTab;
|
||||
cursor?: string | null;
|
||||
category?: string | null;
|
||||
search?: string;
|
||||
nextPageIndex?: number;
|
||||
} = {}) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
status,
|
||||
limit: String(PAGE_SIZE),
|
||||
});
|
||||
if (cursor) query.set('cursor', cursor);
|
||||
if (category) query.set('category', category);
|
||||
if (search.trim()) query.set('keyword', search.trim());
|
||||
const data = await fetchReviewQueue(query.toString());
|
||||
setPosts(data.posts);
|
||||
setHasMore(data.has_more);
|
||||
setPageIndex(nextPageIndex);
|
||||
if (data.has_more && data.next_cursor) {
|
||||
setPageCursors((current) => {
|
||||
const next = current.slice(0, nextPageIndex + 1);
|
||||
next[nextPageIndex + 1] = data.next_cursor;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[tab, pageCursors, pageIndex, categorySlug, keyword],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPageIndex(0);
|
||||
setPageCursors([null]);
|
||||
void loadPage({ cursor: null, nextPageIndex: 0 });
|
||||
}, [tab, categorySlug]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPageIndex(0);
|
||||
setPageCursors([null]);
|
||||
void loadPage({ cursor: null, nextPageIndex: 0, search: keyword });
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (pageIndex <= 0 || loading) return;
|
||||
const nextPageIndex = pageIndex - 1;
|
||||
void loadPage({ cursor: pageCursors[nextPageIndex] ?? null, nextPageIndex });
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (!hasMore || loading) return;
|
||||
const nextPageIndex = pageIndex + 1;
|
||||
void loadPage({ cursor: pageCursors[nextPageIndex] ?? null, nextPageIndex });
|
||||
};
|
||||
|
||||
const handleToggleVisibility = async (id: string, action: 'hide' | 'approve') => {
|
||||
const label = action === 'hide' ? '下线' : '上线';
|
||||
if (!window.confirm(`确认${label}该帖子?`)) return;
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
try {
|
||||
await reviewPost(id, action);
|
||||
await loadPage();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `${label}失败`);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="card stack">
|
||||
<div className="ops-summary-row">
|
||||
<div className="toolbar">
|
||||
{STATUS_TABS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={tab === item.key ? 'btn' : 'btn secondary'}
|
||||
onClick={() => setTab(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="caption ops-cell-muted">每页 {PAGE_SIZE} 条,下线后用户端 Feed 与详情页同步不可见</p>
|
||||
</div>
|
||||
|
||||
<div className="ops-filter-row">
|
||||
<span className="ops-filter-label">分类</span>
|
||||
<div className="ops-chip-row">
|
||||
<button
|
||||
type="button"
|
||||
className={categorySlug === null ? 'ops-chip active' : 'ops-chip'}
|
||||
onClick={() => setCategorySlug(null)}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{categories.length === 0 ? (
|
||||
<span className="caption ops-cell-muted">分类暂不可用,请重启 Admin API 后刷新</span>
|
||||
) : null}
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
className={categorySlug === category.slug ? 'ops-chip active' : 'ops-chip'}
|
||||
onClick={() => setCategorySlug(category.slug)}
|
||||
>
|
||||
{category.icon ? `${category.icon} ` : ''}
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ops-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索标题 / 作者"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') handleSearch();
|
||||
}}
|
||||
className="search-input"
|
||||
/>
|
||||
<button type="button" className="btn secondary" onClick={handleSearch}>
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
|
||||
{loading && posts.length === 0 ? <div className="card empty-state">加载中…</div> : null}
|
||||
{!loading && posts.length === 0 ? (
|
||||
<div className="card empty-state">暂无{tab === 'published' ? '已发布' : '已下线'}帖子</div>
|
||||
) : null}
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<div className="card ops-table-wrap">
|
||||
<table className="ops-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题 / 操作</th>
|
||||
<th>分类 / 作者</th>
|
||||
<th>摘要</th>
|
||||
<th>发布时间</th>
|
||||
<th>查看</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((post) => (
|
||||
<tr key={post.id}>
|
||||
<td>
|
||||
<div className="ops-title-row">
|
||||
<strong>{post.title}</strong>
|
||||
{tab === 'published' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary ops-inline-action"
|
||||
disabled={busyId === post.id}
|
||||
onClick={() => void handleToggleVisibility(post.id, 'hide')}
|
||||
>
|
||||
{busyId === post.id ? '处理中…' : '下线'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ops-inline-action"
|
||||
disabled={busyId === post.id}
|
||||
onClick={() => void handleToggleVisibility(post.id, 'approve')}
|
||||
>
|
||||
{busyId === post.id ? '处理中…' : '上线'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="ops-stack-mini">
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
onClick={() => setCategorySlug(post.category.slug)}
|
||||
>
|
||||
{post.category.icon} {post.category.name}
|
||||
</button>
|
||||
<div className="ops-cell-muted">@{post.author.slug}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="ops-cell-muted">{post.summary || '无摘要'}</div>
|
||||
</td>
|
||||
<td>{formatPublishedAt(post.published_at)}</td>
|
||||
<td>
|
||||
<a className="btn secondary" href={plazaPreviewUrl(post.id)} target="_blank" rel="noreferrer">
|
||||
预览
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="ops-pagination">
|
||||
<button type="button" className="btn secondary" disabled={pageIndex <= 0 || loading} onClick={handlePrevPage}>
|
||||
上一页
|
||||
</button>
|
||||
<span className="ops-cell-muted">
|
||||
第 {pageIndex + 1} 页 · 本页 {posts.length} 条
|
||||
</span>
|
||||
<button type="button" className="btn secondary" disabled={!hasMore || loading} onClick={handleNextPage}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -255,6 +255,82 @@ export type MindSpaceAdminConfig = {
|
||||
publicPageLimit: number;
|
||||
};
|
||||
|
||||
export type MemoryV2AdminSection = Record<string, string | boolean>;
|
||||
export type MemoryV2ModelApiType = 'chat' | 'response';
|
||||
|
||||
export type MemoryV2AdminConfig = {
|
||||
global: {
|
||||
enabled?: boolean;
|
||||
backend?: string;
|
||||
profileEnabled?: boolean;
|
||||
eventLogEnabled?: boolean;
|
||||
vectorEnabled?: boolean;
|
||||
failOpen?: boolean;
|
||||
};
|
||||
chatIntentRouter: MemoryV2AdminSection;
|
||||
pgvector: MemoryV2AdminSection;
|
||||
qdrant: MemoryV2AdminSection;
|
||||
weaviate: MemoryV2AdminSection;
|
||||
mem0: MemoryV2AdminSection;
|
||||
letta: MemoryV2AdminSection;
|
||||
neo4j: MemoryV2AdminSection;
|
||||
redisStreams: MemoryV2AdminSection;
|
||||
langgraph: MemoryV2AdminSection;
|
||||
};
|
||||
|
||||
export type MemoryV2AdminConfigResponse = {
|
||||
config: MemoryV2AdminConfig;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type AdminSystemTestStepStatus = 'passed' | 'warning' | 'failed';
|
||||
|
||||
export type AdminSystemTestStep = {
|
||||
key: string;
|
||||
label: string;
|
||||
status: AdminSystemTestStepStatus;
|
||||
message: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AdminSystemTestIssue = {
|
||||
stepKey: string;
|
||||
severity: 'warning' | 'error';
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type AdminSystemTestReport = {
|
||||
ok: boolean;
|
||||
startedAt: number;
|
||||
finishedAt: number;
|
||||
portalBaseUrl: string;
|
||||
selectedSkill: string;
|
||||
account: {
|
||||
userId?: string;
|
||||
username: string;
|
||||
displayName?: string | null;
|
||||
};
|
||||
summary: {
|
||||
passed: number;
|
||||
warnings: number;
|
||||
failed: number;
|
||||
};
|
||||
steps: AdminSystemTestStep[];
|
||||
issues: AdminSystemTestIssue[];
|
||||
};
|
||||
|
||||
export type AdminSystemTestAccount = {
|
||||
id: string;
|
||||
label: string;
|
||||
username: string;
|
||||
passwordMasked: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
updatedBy?: string | null;
|
||||
};
|
||||
|
||||
export type WechatBinding = {
|
||||
userId: string;
|
||||
username: string;
|
||||
|
||||
Reference in New Issue
Block a user