Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47c07f9d12 | |||
| 0904184412 | |||
| aaa2eb482a | |||
| 1679e72deb | |||
| 4844543061 | |||
| 99132c9fa7 | |||
| 944d2aa3db | |||
| 7c16a35fda | |||
| e4369cd82e | |||
| 54b9bd4354 | |||
| 365e530b5f | |||
| d46cdfb282 | |||
| 2142027f85 | |||
| 8fdf39f37e | |||
| 70c65c37d5 | |||
| e72637789c | |||
| 0a102ea1c3 | |||
| 1ab7fa9991 | |||
| 6d02a0c29f | |||
| a6c3db60fb | |||
| c921ae3b16 | |||
| 31c084fce6 | |||
| 1e7a3f17ff | |||
| 3ce2977e32 | |||
| f4e9414d63 | |||
| 2d8b51885a | |||
| 91f534ed66 | |||
| f455d3edb2 | |||
| bec225436f | |||
| ce309a3b28 | |||
| abd95a6231 | |||
| 08c3b89b67 | |||
| 3371337346 | |||
| 899a591cda | |||
| 0988c4907a | |||
| d370648867 | |||
| 4f4fb0873c | |||
| 6b8a106414 | |||
| 55a8513de4 | |||
| d986dbaeb0 | |||
| 16c6b9198e | |||
| 4df4708ba1 | |||
| 1e992bcdf8 | |||
| 8368aa262c | |||
| e9ddbf16da | |||
| 22ac1f4c0b | |||
| 9910a0eec0 | |||
| 70981b9ca9 | |||
| 8a0d5600b3 | |||
| 184d88dbb8 | |||
| 791794c0a6 | |||
| 9cfb47d945 | |||
| 27859d3e19 | |||
| 43f3780a38 | |||
| a1f9149c27 | |||
| f0bba18841 | |||
| f679cfa179 | |||
| f68ffcfecd | |||
| ec94a4fe00 | |||
| 46f8592375 | |||
| cdf2882302 | |||
| d156113e71 | |||
| a8f2ac17be | |||
| 43ac9a5761 | |||
| e992e0b35c | |||
| e389b54a3a | |||
| 84c780a6f8 | |||
| 03f900fbe5 |
@@ -4,5 +4,3 @@
|
||||
src/components/AdventurePanel.tsx
|
||||
src/data/customWorldCharacterLoadout.ts
|
||||
dist_check_monster_position/**
|
||||
# 固定上游UTF-8测试刻意包含U+FFFD;upstream-integrity.test.mjs逐字节验证来源hash。
|
||||
apps/ai-game-creator-shell/src-tauri/vendor/codex-utils-path-uri/src/api_path_string_tests.rs
|
||||
|
||||
@@ -235,10 +235,6 @@ VITE_DEBUG_MODE=""
|
||||
# This is read by api-server and exposed through /api/runtime/frontend-config.
|
||||
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
||||
|
||||
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
|
||||
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
|
||||
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
|
||||
|
||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||
# the fixed `bge-large-zh` embedding model.
|
||||
|
||||
@@ -23,6 +23,3 @@
|
||||
*.meta text
|
||||
*.anim text
|
||||
*.controller text
|
||||
|
||||
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
|
||||
packages/shared/src/contracts/generated/** linguist-generated=true
|
||||
|
||||
@@ -31,26 +31,28 @@ env:
|
||||
RUSTC_WRAPPER: ''
|
||||
CARGO_BUILD_RUSTC_WRAPPER: ''
|
||||
|
||||
# job 声明顺序就是 runner 领取顺序,因此把两条最长尾的客户端 Rust lane 排在前面,
|
||||
# job 声明顺序就是 runner 领取顺序,因此把最长尾的客户端 Rust 门禁排在前面,
|
||||
# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。
|
||||
#
|
||||
# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在
|
||||
# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成一个分组一个 job:
|
||||
# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、
|
||||
# `AI game creator shell web tests`(typecheck + 壳内测试)、`AI game creator shell
|
||||
# Rust lane 1/2`、`lane 2/2`(每条 lane 顺序运行两片 AGC 壳 bin 单测)、`AI game creator
|
||||
# shell Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的
|
||||
# Rust shard 1/4` 到 `4/4`(AGC 壳 bin 单测按名单分 4 片)、`AI game creator shell
|
||||
# Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的
|
||||
# 共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地
|
||||
# `npm run check:native-shells` 仍是同一条串行序列。
|
||||
#
|
||||
# AGC 壳的 bin 单测(2466 条)仍按名单分 4 片、片内保持 `--test-threads=1`;两条 lane
|
||||
# 各自顺序运行两片,让一次 job 只做一次依赖预热,同时避免同一容器内多进程并行争抢
|
||||
# 共享 HOME、target 与固定临时路径。每个分片调用仍会自校验「片并集等于全集且互斥」。
|
||||
# AGC 壳的 bin 单测(2466 条)按名单分 4 片、一片一个 job:片内保持 `--test-threads=1`
|
||||
# (当年线程并行会互相干扰的是进程内后台锁与异步终态),片与片之间靠 job 级并发摊开。
|
||||
# 不要改回「一个 job 里多进程并行这几片」:同一容器内它们会争抢共享 HOME、target 与固定
|
||||
# 临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」。
|
||||
jobs:
|
||||
# AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;这里不装 npm
|
||||
# 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。
|
||||
ai-game-creator-shell-rust-lane-1:
|
||||
name: AI game creator shell Rust lane 1/2
|
||||
# AGC 壳自身的 Rust bin 单测分片,4 片各自独立 job 并发执行、片内仍保持
|
||||
# `--test-threads=1`。这里不装 npm 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,
|
||||
# 也只需要 AGC 壳自己那份锁定依赖。
|
||||
ai-game-creator-shell-rust-shard-1:
|
||||
name: AI game creator shell Rust shard 1/4
|
||||
runs-on: genarrative-ci
|
||||
steps:
|
||||
- name: Checkout full history from Gitea
|
||||
@@ -85,11 +87,41 @@ jobs:
|
||||
- name: Run AI game creator shell Rust shard 1/4
|
||||
run: npm run check:native-shells:agc-rust-shard-1
|
||||
|
||||
ai-game-creator-shell-rust-shard-2:
|
||||
name: AI game creator shell Rust shard 2/4
|
||||
runs-on: genarrative-ci
|
||||
steps:
|
||||
- name: Checkout full history from Gitea
|
||||
env:
|
||||
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: genarrative-gitea-checkout
|
||||
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Prepare AI game creator shell Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 5); do
|
||||
if cargo fetch --locked \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 5 ]]; then
|
||||
echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Run AI game creator shell Rust shard 2/4
|
||||
run: npm run check:native-shells:agc-rust-shard-2
|
||||
|
||||
ai-game-creator-shell-rust-lane-2:
|
||||
name: AI game creator shell Rust lane 2/2
|
||||
ai-game-creator-shell-rust-shard-3:
|
||||
name: AI game creator shell Rust shard 3/4
|
||||
runs-on: genarrative-ci
|
||||
steps:
|
||||
- name: Checkout full history from Gitea
|
||||
@@ -121,6 +153,36 @@ jobs:
|
||||
- name: Run AI game creator shell Rust shard 3/4
|
||||
run: npm run check:native-shells:agc-rust-shard-3
|
||||
|
||||
ai-game-creator-shell-rust-shard-4:
|
||||
name: AI game creator shell Rust shard 4/4
|
||||
runs-on: genarrative-ci
|
||||
steps:
|
||||
- name: Checkout full history from Gitea
|
||||
env:
|
||||
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: genarrative-gitea-checkout
|
||||
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Prepare AI game creator shell Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 5); do
|
||||
if cargo fetch --locked \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 5 ]]; then
|
||||
echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Run AI game creator shell Rust shard 4/4
|
||||
run: npm run check:native-shells:agc-rust-shard-4
|
||||
|
||||
@@ -159,7 +221,8 @@ jobs:
|
||||
- name: Run AI game creator shell agent-run smoke
|
||||
run: npm run check:native-shells:agc-rust-smoke
|
||||
|
||||
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。
|
||||
# AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate
|
||||
# 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。
|
||||
ai-game-creator-shell-rust-crates:
|
||||
name: AI game creator shell Rust crates
|
||||
runs-on: genarrative-ci
|
||||
@@ -200,15 +263,13 @@ jobs:
|
||||
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
|
||||
# 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红
|
||||
# (见 #327 / PR #316 run 1950)。
|
||||
# Cocos 插件也使用独立且未提交的锁文件,一并预热。
|
||||
# 这些 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
||||
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
||||
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
|
||||
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
|
||||
# 解析,不再触碰 registry index。
|
||||
for manifest_path in \
|
||||
server-rs/crates/agent-runtime-core/Cargo.toml \
|
||||
server-rs/crates/agent-runtime-orchestration/Cargo.toml \
|
||||
plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml; do
|
||||
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
|
||||
for attempt in $(seq 1 5); do
|
||||
if cargo fetch \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
@@ -223,40 +284,6 @@ jobs:
|
||||
done
|
||||
done
|
||||
|
||||
- name: Prepare Unity plugin Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 5); do
|
||||
if cargo fetch --locked \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 5 ]]; then
|
||||
echo 'Unity plugin Cargo dependency fetch failed after 5 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Prepare Godot plugin Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 5); do
|
||||
if cargo fetch --locked \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 5 ]]; then
|
||||
echo 'Godot plugin Cargo dependency fetch failed after 5 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Run AI game creator shell shared crate gates
|
||||
run: npm run check:native-shells:agc-rust-crates
|
||||
|
||||
|
||||
@@ -47,16 +47,8 @@ temp*build*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-arm64/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-x64/
|
||||
/plugins/agc-cocos-editor/native/payload/
|
||||
/plugins/agc-unity-editor/dotnet/**/bin/
|
||||
/plugins/agc-unity-editor/dotnet/**/obj/
|
||||
/plugins/agc-unity-editor/dotnet/publish/
|
||||
/plugins/agc-unity-editor/dotnet/native-build/
|
||||
/apps/ai-game-creator-shell/logs/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
|
||||
/apps/ai-game-creator-shell/.llm-drafts/
|
||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||
/apps/mobile-shell/.expo/
|
||||
|
||||
+1
-10
@@ -1,14 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "packages/shared/src/contracts/generated/**/*.ts",
|
||||
"options": {
|
||||
"printWidth": 1000,
|
||||
"singleQuote": false
|
||||
}
|
||||
}
|
||||
]
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
|
||||
- 本文件只保留 Agent 进入仓库后必须立即遵守的最高优先级规则;完整执行细则见 [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
||||
- 团队级长期项目记忆位于 [`docs/project-memory/`](docs/project-memory/),供 3 名开发人员和各自本地 Agent 通过 Git 同步。
|
||||
- [`.codex/`](.codex/) 保存仓库级 Codex 工具资源,例如 skills、plugins、hooks 和配置模板;长期项目知识写入 `docs/` 与 `docs/project-memory/`。
|
||||
- [`.codex/`](.codex/) 只保存仓库级 Codex 工具资源,例如 skills、plugins、hooks 和配置模板;长期项目知识不要写入 `.codex/`。
|
||||
- 若 `docs/project-memory/shared-memory/` 与当前代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
|
||||
|
||||
## 开始任务前
|
||||
|
||||
- 先写清一句话交付结果、验收判据和修改范围,再按“必须项 / 风险项 / 可选项”排序;优先完成修改、定向验证和边界检查组成的最小闭环。设置时间盒和检查点,新增发现只有在影响交付判据时才扩大范围,其余记录为后续事项。
|
||||
- Agent 可见内容直接描述当前任务、输入和成功条件,细节按调用需要提供。
|
||||
- 先写清一句话交付结果、验收判据和不做项,再按“必须项 / 风险项 / 可选项”排序;优先完成修改、定向验证和边界检查组成的最小闭环。设置时间盒和检查点,新增发现只有在影响交付判据时才扩大范围,否则记录为后续事项;工具探测、历史整理或验证便利不能自行改变任务目标。
|
||||
- 简单自包含任务可以直接执行;复杂开发、跨模块修改、后端 / UI / 文档体系调整前,按顺序读取:
|
||||
1. 本文件。
|
||||
2. [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
||||
@@ -24,20 +23,24 @@
|
||||
|
||||
- 禁止提交个人 `~/.codex` 配置、`.env`、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。
|
||||
- 不要在 `.gitignore` 中新增 `.env.local`。
|
||||
- 现有中文文案、注释、剧情和文档保持中文;看到乱码时先确认真实编码并恢复正确中文。
|
||||
- 修改包含中文的文件时优先局部补丁;修改后优先运行仓库编码检查。
|
||||
- 新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。
|
||||
- 不要擅自把现有中文文案、注释、剧情或文档改写成英文;看到中文乱码时先确认真实编码,不要沿用乱码或用英文替换。
|
||||
- 修改包含中文的文件时优先局部补丁,避免整文件重写;修改后优先运行仓库编码检查。
|
||||
- 后续新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;历史文档不要求批量重命名,除非本次任务明确涉及。
|
||||
- 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`。
|
||||
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件。
|
||||
- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用。共享组件承载通用表现与交互,领域规则、后端副作用和正式业务状态由后端负责。
|
||||
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,直接清理实现、专属测试和说明,将权威文档更新为当前状态;历史由 Git 保存。
|
||||
- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移按实际需求保留最小、白名单化的兼容,并配套契约或迁移测试;迁移完成后同步删除兼容实现与对应测试。
|
||||
- UI 面板优先呈现任务内容与操作,说明按当前操作需要提供;移动端优先,同时保证网页端可正常显示和操作。
|
||||
- 点击按钮弹出的独立面板使用弹窗、抽屉、popover 或页面级 portal。
|
||||
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件,不新建平行系统或平行页面。
|
||||
- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。
|
||||
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,坚持“四不写”:
|
||||
1. 不写历史兼容代码。
|
||||
2. 不写用于维持退役行为的防御性兼容测试。
|
||||
3. 不写仅说明其曾存在或已删除的墓碑注释。
|
||||
4. 不写仅记录其已删除的墓碑文档;直接将权威文档更新为当前状态。
|
||||
- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移不适用“四不写”;必要兼容应最小化、白名单化并配套契约或迁移测试,迁移完成后同步删除兼容实现与对应测试。
|
||||
- UI 面板中不要默认写功能说明、规则描述或开发解释文案;移动端优先,同时保证网页端可正常显示和操作。
|
||||
- 点击按钮弹出独立面板的设计,不要实现成在当前面板下面追加内容。
|
||||
|
||||
## 任务路由
|
||||
|
||||
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
||||
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI,不使用 GitHub `gh` 或 GitLab `glab`,除非仓库已迁移。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
||||
- 需要仓库级 Codex skills/plugins 时,再读取 [`.codex/README.md`](.codex/README.md)。
|
||||
- 涉及 AI 游戏创作独立 App、多智能体 Runtime、本地项目产物或本地 HTTP 预览时,先读取 [`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
|
||||
- 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 前,必须读取并按 [`genarrative-play-type-integration`](.codex/skills/genarrative-play-type-integration/SKILL.md) 执行。
|
||||
@@ -47,10 +50,10 @@
|
||||
## 后端红线
|
||||
|
||||
- 后端最新技术约束以 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 为准。
|
||||
- 后端路线固定为 `server-rs + Axum + SpacetimeDB`。
|
||||
- 后端路线固定为 `server-rs + Axum + SpacetimeDB`;旧 `server-node`、Express、PostgreSQL、Go 服务端、`maincloud` / `Maincloud` / `MAINCLOUD` 只作为历史残留,不作为兼容目标。
|
||||
- DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。
|
||||
- 前端负责表现、交互和临时 UI 状态;正式业务状态与规则以后端投影和后端 API 为准。
|
||||
- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准。
|
||||
- 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。
|
||||
- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准;不得在前端、`api-server` 或临时兼容层中重新发明旧接口。
|
||||
- 凡修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义,必须在同一次变更中同步更新权威契约 [`docs/openapi/genarrative-external-v1.openapi.json`](docs/openapi/genarrative-external-v1.openapi.json) 及对应契约测试;Rust 实现与 OpenAPI 未保持一致时任务不得视为完成。
|
||||
- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值;需要删除、改名、重排或改类型时,必须先询问用户并确认迁移计划。
|
||||
- 修改 SpacetimeDB schema 后必须同步 `migration.rs`、表目录和生成绑定,并运行 `npm run check:spacetime-schema`。
|
||||
|
||||
@@ -3,14 +3,12 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
createAdminAccount,
|
||||
executeAdminRechargeRefund,
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
updateAdminAccount,
|
||||
updateAdminAgcTemplate,
|
||||
uploadAdminEditorShowcaseCampaignImage,
|
||||
upsertAdminFeatureGateConfig,
|
||||
upsertProfileWalletConfig,
|
||||
@@ -20,51 +18,6 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
|
||||
const library = { revision: 'revision-new', writable: true, templates: [] };
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: library }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const controller = new AbortController();
|
||||
expect(await getAdminAgcTemplates('admin-token', controller.signal)).toEqual(
|
||||
library,
|
||||
);
|
||||
const update = {
|
||||
expectedRevision: 'revision-old',
|
||||
title: '空白模板',
|
||||
summary: '简介',
|
||||
tags: ['2D'],
|
||||
enabled: true,
|
||||
cover: { contentType: 'image/png', dataBase64: 'aW1hZ2U=' },
|
||||
};
|
||||
expect(
|
||||
await updateAdminAgcTemplate('admin-token', 'template/1', update),
|
||||
).toEqual(library);
|
||||
expect(fetchMock.mock.calls[0]).toEqual([
|
||||
'/admin/api/agc-templates',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||
}),
|
||||
]);
|
||||
expect(fetchMock.mock.calls[1]).toEqual([
|
||||
'/admin/api/agc-templates/template%2F1',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer admin-token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(update),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
AdminAccountListResponse,
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||
AdminCreateAccountRequest,
|
||||
AdminCreateAccountResponse,
|
||||
@@ -33,8 +32,6 @@ import type {
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
AdminProjectSnapshotListQuery,
|
||||
AdminProjectSnapshotListResponse,
|
||||
AdminRechargeOrderListQuery,
|
||||
AdminRechargeOrderListResponse,
|
||||
AdminRechargeRefundActionResponse,
|
||||
@@ -48,7 +45,6 @@ import type {
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpdateAccountRequest,
|
||||
AdminUpdateAccountResponse,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
@@ -202,92 +198,6 @@ export function listAdminAccounts(token: string) {
|
||||
return request<AdminAccountListResponse>('/admin/api/accounts', { token });
|
||||
}
|
||||
|
||||
export function listAdminProjectSnapshots(
|
||||
token: string,
|
||||
query: AdminProjectSnapshotListQuery = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (query.cursor) params.set('cursor', query.cursor);
|
||||
params.set('limit', String(query.limit ?? 20));
|
||||
return request<AdminProjectSnapshotListResponse>(
|
||||
`/admin/api/project-snapshots?${params.toString()}`,
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadAdminProjectSnapshot(
|
||||
token: string,
|
||||
userId: string,
|
||||
projectId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
|
||||
const response = await fetch(buildRequestUrl(path), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.trim()}`,
|
||||
Accept: 'application/zip',
|
||||
[API_RESPONSE_ENVELOPE_HEADER]: 'v1',
|
||||
},
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw buildAdminApiError(
|
||||
response,
|
||||
parseJsonResponse(responseText),
|
||||
responseText,
|
||||
);
|
||||
}
|
||||
const contentType = response.headers
|
||||
.get('content-type')
|
||||
?.split(';')[0]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
if (contentType !== 'application/zip') {
|
||||
await response.body?.cancel();
|
||||
throw new AdminApiError({
|
||||
message: '下载失败:服务端未返回 ZIP 工程文件',
|
||||
status: response.status,
|
||||
code: 'INVALID_PROJECT_ARCHIVE_RESPONSE',
|
||||
});
|
||||
}
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
filename: projectArchiveFilename(
|
||||
response.headers.get('content-disposition'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function projectArchiveFilename(contentDisposition: string | null): string {
|
||||
const extended = contentDisposition?.match(
|
||||
/(?:^|;)\s*filename\*=UTF-8'[^']*'([^;]+)/i,
|
||||
);
|
||||
const ordinary = contentDisposition?.match(
|
||||
/(?:^|;)\s*filename=(?:"((?:[^"\\]|\\.)*)"|([^;]+))/i,
|
||||
);
|
||||
let filename =
|
||||
ordinary?.[1]?.replace(/\\(.)/g, '$1') ?? ordinary?.[2]?.trim() ?? '';
|
||||
if (extended?.[1]) {
|
||||
try {
|
||||
filename = decodeURIComponent(extended[1].trim());
|
||||
} catch {
|
||||
// 非法扩展编码继续使用普通文件名。
|
||||
}
|
||||
}
|
||||
const safeName = Array.from(filename, (character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code < 32 || code === 127 ? '_' : character;
|
||||
})
|
||||
.join('')
|
||||
.replace(/[<>:"/\\|?*]/g, '_')
|
||||
.trim()
|
||||
.replace(/[. ]+$/, '');
|
||||
if (!safeName || safeName.length > 240) return 'project.zip';
|
||||
return /\.zip$/i.test(safeName) ? safeName : `${safeName}.zip`;
|
||||
}
|
||||
|
||||
export function createAdminAccount(
|
||||
token: string,
|
||||
payload: AdminCreateAccountRequest,
|
||||
@@ -1178,21 +1088,3 @@ export function saveAgcModelCatalog(
|
||||
{ token, method: 'PUT', body },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
|
||||
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
|
||||
token,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAdminAgcTemplate(
|
||||
token: string,
|
||||
id: string,
|
||||
body: AdminUpdateAgcTemplateRequest,
|
||||
) {
|
||||
return request<AdminAgcTemplateLibraryResponse>(
|
||||
`/admin/api/agc-templates/${encodeURIComponent(id)}`,
|
||||
{ token, method: 'PUT', body },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,27 +96,6 @@ export interface AdminMeResponse {
|
||||
admin: AdminSessionPayload;
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotEntry {
|
||||
userId: string;
|
||||
projectId: string;
|
||||
projectName: string | null;
|
||||
syncRevision: number;
|
||||
syncedAtMs: number;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
status: 'ready' | 'partial' | 'unverified';
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotListQuery {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotListResponse {
|
||||
items: AdminProjectSnapshotEntry[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface AdminErrorReportEntry {
|
||||
batchId: string;
|
||||
eventCount: number;
|
||||
@@ -144,8 +123,6 @@ export interface AdminErrorReportListResponse {
|
||||
}
|
||||
|
||||
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
|
||||
firstFingerprint?: string;
|
||||
firstSource?: string;
|
||||
note?: string;
|
||||
events: Array<Record<string, unknown>>;
|
||||
logNames: string[];
|
||||
@@ -1033,35 +1010,3 @@ export interface AdminAgcModelCatalog {
|
||||
defaultModelId: string;
|
||||
models: AdminAgcModel[];
|
||||
}
|
||||
|
||||
export interface AdminAgcTemplatePayload {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
runtime: string;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
templateVersion: string;
|
||||
enabled: boolean;
|
||||
coverUrl: string;
|
||||
zipSizeBytes: number;
|
||||
}
|
||||
|
||||
export interface AdminAgcTemplateLibraryResponse {
|
||||
revision: string;
|
||||
writable: boolean;
|
||||
templates: AdminAgcTemplatePayload[];
|
||||
}
|
||||
|
||||
export interface AdminUpdateAgcTemplateRequest {
|
||||
expectedRevision: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
enabled: boolean;
|
||||
cover?: {
|
||||
contentType: string;
|
||||
dataBase64: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
downloadAdminProjectSnapshot,
|
||||
listAdminProjectSnapshots,
|
||||
} from './adminApiClient';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('项目列表携带分页与后台授权,解析标准响应', async () => {
|
||||
const payload = { items: [], nextCursor: 'next' };
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true, data: payload })),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const controller = new AbortController();
|
||||
expect(
|
||||
await listAdminProjectSnapshots(
|
||||
'admin-token',
|
||||
{ cursor: 'user/a+项目', limit: 20 },
|
||||
controller.signal,
|
||||
),
|
||||
).toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('PK\u0003\u0004', {
|
||||
headers: {
|
||||
'content-type': 'application/zip',
|
||||
'content-disposition':
|
||||
"attachment; filename=project.zip; filename*=UTF-8''%E4%B8%89%E6%B6%88-r2.zip",
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const controller = new AbortController();
|
||||
const archive = await downloadAdminProjectSnapshot(
|
||||
'admin-token',
|
||||
'user/a',
|
||||
'project/b',
|
||||
controller.signal,
|
||||
);
|
||||
expect(archive.filename).toBe('三消-r2.zip');
|
||||
expect(archive.blob.type).toBe('application/zip');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer admin-token',
|
||||
Accept: 'application/zip',
|
||||
}),
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['attachment; filename="工程.zip"; filename*=UTF-8\'\'%broken', '工程.zip'],
|
||||
['attachment; filename="../secret.zip"', '.._secret.zip'],
|
||||
["attachment; filename*=UTF-8''unsafe%00%1F%7F.zip", 'unsafe___.zip'],
|
||||
[null, 'project.zip'],
|
||||
])('ZIP 附件名兼容安全回退 %s', async (header, expected) => {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/zip' };
|
||||
// Response 的 Headers 只接受 Latin-1;真实 UTF-8 文件名使用 filename*。
|
||||
if (header)
|
||||
headers['content-disposition'] = header.replace('工程', 'project');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
||||
);
|
||||
expect(
|
||||
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
|
||||
).toBe(expected.replace('工程', 'project'));
|
||||
});
|
||||
|
||||
test.each([401, 403, 409, 500])(
|
||||
'下载 HTTP %s 保留后台错误,不返回 ZIP',
|
||||
async (status) => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: { code: 'SNAPSHOT_FAILURE', message: '工程尚未同步完成' },
|
||||
}),
|
||||
{
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||
).rejects.toMatchObject({
|
||||
status,
|
||||
code: 'SNAPSHOT_FAILURE',
|
||||
message: '工程尚未同步完成',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response('{"ok":false}', {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
||||
});
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from '../auth/adminAuthStore';
|
||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
||||
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
|
||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||
@@ -32,7 +31,6 @@ import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
|
||||
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
|
||||
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
|
||||
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
|
||||
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
|
||||
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
|
||||
@@ -295,12 +293,6 @@ export function AdminApp() {
|
||||
{activeRouteId === 'agc-models' ? (
|
||||
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
{activeRouteId === 'agc-templates' ? (
|
||||
<AdminAgcTemplatesPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'editor-showcase' ? (
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token={token}
|
||||
@@ -316,12 +308,6 @@ export function AdminApp() {
|
||||
{activeRouteId === 'accounts' ? (
|
||||
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
{activeRouteId === 'project-snapshots' ? (
|
||||
<AdminProjectSnapshotsPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Bug,
|
||||
Coins,
|
||||
Database,
|
||||
FolderArchive,
|
||||
GitBranch,
|
||||
Images,
|
||||
LayoutDashboard,
|
||||
@@ -50,10 +49,8 @@ const routeIcons = {
|
||||
'editor-generation-pricing': Coins,
|
||||
'editor-showcase': Star,
|
||||
'editor-assets': Images,
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
'agc-models': ListChecks,
|
||||
'agc-templates': Images,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
export function AdminShell({
|
||||
|
||||
@@ -122,55 +122,3 @@ test('零权限 member 不回落到 Dashboard', () => {
|
||||
expect(routes).toEqual([]);
|
||||
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
|
||||
});
|
||||
|
||||
test('项目工程入口对 owner 与已授权 member 开放且可分配权限', () => {
|
||||
const route = {
|
||||
id: 'project-snapshots',
|
||||
label: '项目工程',
|
||||
hash: '#project-snapshots',
|
||||
};
|
||||
expect(adminRoutes.filter((item) => !item.ownerOnly)).toContainEqual(route);
|
||||
expect(resolveAdminRoute('#project-snapshots')).toBe('project-snapshots');
|
||||
expect(
|
||||
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }),
|
||||
).toContainEqual(route);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['project-snapshots'],
|
||||
}),
|
||||
).toEqual([route]);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['tracking'],
|
||||
}),
|
||||
).not.toContainEqual(route);
|
||||
});
|
||||
|
||||
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'agc-templates',
|
||||
label: '模板管理',
|
||||
hash: '#agc-templates',
|
||||
});
|
||||
expect(resolveAdminRoute('#agc-templates')).toBe('agc-templates');
|
||||
expect(routeHash('agc-templates')).toBe('#agc-templates');
|
||||
expect(
|
||||
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }).some(
|
||||
(route) => route.id === 'agc-templates',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['agc-templates'],
|
||||
}).map((route) => route.id),
|
||||
).toEqual(['agc-templates']);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['editor-assets'],
|
||||
}).some((route) => route.id === 'agc-templates'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -16,9 +16,7 @@ export type AdminRouteId =
|
||||
| 'editor-generation-pricing'
|
||||
| 'editor-showcase'
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
| 'agc-templates'
|
||||
| 'accounts';
|
||||
|
||||
export type AdminTabPermission = Exclude<
|
||||
@@ -54,10 +52,8 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
hash: '#editor-generation-pricing',
|
||||
},
|
||||
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
||||
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import '@genarrative/shared/styles.css';
|
||||
import './styles/admin.css';
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
|
||||
@@ -1,498 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AdminApiError,
|
||||
getAdminAgcTemplates,
|
||||
updateAdminAgcTemplate,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminAgcTemplatePayload,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminAgcTemplatesPage } from './AdminAgcTemplatesPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../api/adminApiClient')>()),
|
||||
getAdminAgcTemplates: vi.fn(),
|
||||
updateAdminAgcTemplate: vi.fn(),
|
||||
}));
|
||||
|
||||
const template: AdminAgcTemplatePayload = {
|
||||
id: 'cocos-empty-2d',
|
||||
title: '空白 2D',
|
||||
summary: '二维项目',
|
||||
tags: ['2D', '入门'],
|
||||
runtime: 'cocos',
|
||||
engine: 'Cocos Creator',
|
||||
engineVersion: '3.8.8',
|
||||
templateVersion: '1',
|
||||
enabled: true,
|
||||
coverUrl: 'https://example.test/cover.png',
|
||||
zipSizeBytes: 2048,
|
||||
};
|
||||
const library: AdminAgcTemplateLibraryResponse = {
|
||||
revision: 'rev-1',
|
||||
writable: true,
|
||||
templates: [
|
||||
template,
|
||||
{
|
||||
...template,
|
||||
id: 'godot-starter',
|
||||
title: 'Godot 起步',
|
||||
runtime: 'godot',
|
||||
engine: 'Godot',
|
||||
engineVersion: '4',
|
||||
tags: ['3D'],
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getAdminAgcTemplates)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => structuredClone(library));
|
||||
vi.mocked(updateAdminAgcTemplate)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_token, id, update) => ({
|
||||
...structuredClone(library),
|
||||
revision: 'rev-2',
|
||||
templates: library.templates.map((entry) =>
|
||||
entry.id === id
|
||||
? {
|
||||
...entry,
|
||||
title: update.title,
|
||||
summary: update.summary,
|
||||
tags: update.tags,
|
||||
enabled: update.enabled,
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
'URL',
|
||||
Object.assign(class extends URL {}, {
|
||||
createObjectURL: vi.fn(() => 'blob:template-cover'),
|
||||
revokeObjectURL: vi.fn(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function openEditor() {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '编辑 空白 2D' }));
|
||||
return screen.getByRole('dialog', { name: '编辑模板' });
|
||||
}
|
||||
|
||||
async function confirmWrite() {
|
||||
const confirmation = await screen.findByRole('dialog', { name: '确认操作' });
|
||||
fireEvent.click(within(confirmation).getByRole('button', { name: '确认' }));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((next, fail) => {
|
||||
resolve = next;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('按名称、ID、标签、运行时和上下架状态筛选,展示版本与封面', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('空白 2D');
|
||||
expect(screen.getByText('Cocos Creator 3.8.8')).not.toBeNull();
|
||||
expect(screen.getAllByText('模板 1')).toHaveLength(2);
|
||||
expect(screen.getAllByText('2.0 KiB')).toHaveLength(2);
|
||||
expect(
|
||||
screen.getByRole('img', { name: '空白 2D封面' }).getAttribute('src'),
|
||||
).toBe(template.coverUrl);
|
||||
for (const value of ['空白', 'cocos-empty-2d', '入门']) {
|
||||
fireEvent.change(screen.getByLabelText('搜索模板'), { target: { value } });
|
||||
expect(screen.getByText('空白 2D')).not.toBeNull();
|
||||
expect(screen.queryByText('Godot 起步')).toBeNull();
|
||||
}
|
||||
fireEvent.change(screen.getByLabelText('搜索模板'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('运行时'), {
|
||||
target: { value: 'godot' },
|
||||
});
|
||||
expect(screen.queryByText('空白 2D')).toBeNull();
|
||||
fireEvent.change(screen.getByLabelText('上架状态'), {
|
||||
target: { value: 'enabled' },
|
||||
});
|
||||
expect(screen.getByText('没有符合筛选条件的模板')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('独立弹窗保存经写确认,只提交展示字段和冻结 revision', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const editor = await openEditor();
|
||||
expect(editor.closest('.admin-agc-templates')).toBeNull();
|
||||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||||
target: { value: ' 新名称 ' },
|
||||
});
|
||||
fireEvent.change(within(editor).getByLabelText('简介'), {
|
||||
target: { value: '新简介' },
|
||||
});
|
||||
fireEvent.change(within(editor).getByLabelText('标签'), {
|
||||
target: { value: '2D, 新手,2D' },
|
||||
});
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||||
await confirmWrite();
|
||||
await waitFor(() =>
|
||||
expect(updateAdminAgcTemplate).toHaveBeenCalledWith('token', template.id, {
|
||||
expectedRevision: 'rev-1',
|
||||
title: '新名称',
|
||||
summary: '新简介',
|
||||
tags: ['2D', '新手'],
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '编辑模板' })).toBeNull(),
|
||||
);
|
||||
expect(screen.getByText('新名称')).not.toBeNull();
|
||||
expect(screen.getByText('已保存')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('上下架复用确认且只用服务端结果更新列表', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下架 空白 2D' }));
|
||||
await confirmWrite();
|
||||
await waitFor(() =>
|
||||
expect(updateAdminAgcTemplate).toHaveBeenCalledWith('token', template.id, {
|
||||
expectedRevision: 'rev-1',
|
||||
title: template.title,
|
||||
summary: template.summary,
|
||||
tags: template.tags,
|
||||
enabled: false,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '上架 空白 2D' }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('只读模式禁用字段、保存与上下架', async () => {
|
||||
vi.mocked(getAdminAgcTemplates).mockResolvedValue({
|
||||
...library,
|
||||
writable: false,
|
||||
});
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
expect(
|
||||
await screen.findByText('当前为只读模式,无法保存或上下架'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '下架 空白 2D' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
const editor = await openEditor();
|
||||
expect(
|
||||
(within(editor).getByLabelText('名称') as HTMLInputElement).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(within(editor).getByRole('button', { name: '保存' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('409 保留草稿,显式刷新后重新编辑才采用新 revision,不自动重试', async () => {
|
||||
vi.mocked(updateAdminAgcTemplate).mockRejectedValueOnce(
|
||||
new AdminApiError({ message: '模板库已被修改', status: 409 }),
|
||||
);
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
let editor = await openEditor();
|
||||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||||
target: { value: '未保存草稿' },
|
||||
});
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
await confirmWrite();
|
||||
await screen.findByText('模板库已被修改');
|
||||
expect((screen.getByLabelText('名称') as HTMLInputElement).value).toBe(
|
||||
'未保存草稿',
|
||||
);
|
||||
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
vi.mocked(getAdminAgcTemplates).mockResolvedValueOnce({
|
||||
...library,
|
||||
revision: 'rev-3',
|
||||
templates: [{ ...template, title: '最新名称' }],
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新后重新编辑' }));
|
||||
await screen.findByDisplayValue('最新名称');
|
||||
editor = screen.getByRole('dialog', { name: '编辑模板' });
|
||||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||||
target: { value: '重新编辑' },
|
||||
});
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
await confirmWrite();
|
||||
await waitFor(() =>
|
||||
expect(updateAdminAgcTemplate).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
template.id,
|
||||
expect.objectContaining({ expectedRevision: 'rev-3', title: '重新编辑' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('防止重复确认和保存中重复提交', async () => {
|
||||
const pending = deferred<AdminAgcTemplateLibraryResponse>();
|
||||
vi.mocked(updateAdminAgcTemplate).mockReturnValue(pending.promise);
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const editor = await openEditor();
|
||||
const form = within(editor).getByLabelText('名称').closest('form')!;
|
||||
fireEvent.submit(form);
|
||||
fireEvent.submit(form);
|
||||
expect(screen.getAllByRole('dialog', { name: '确认操作' })).toHaveLength(1);
|
||||
await confirmWrite();
|
||||
await waitFor(() => expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1));
|
||||
fireEvent.submit(form);
|
||||
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1);
|
||||
await act(async () => pending.resolve(library));
|
||||
});
|
||||
|
||||
test.each([409, 503])(
|
||||
'滚到表单底部后出现 HTTP %s 写入错误,聚焦并仅滚动弹窗到提示区',
|
||||
async (status) => {
|
||||
vi.mocked(updateAdminAgcTemplate).mockRejectedValueOnce(
|
||||
new AdminApiError({ message: '本次保存失败', status }),
|
||||
);
|
||||
render(
|
||||
<div data-testid="background-page">
|
||||
<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />
|
||||
</div>,
|
||||
);
|
||||
const background = screen.getByTestId('background-page');
|
||||
background.scrollTop = 240;
|
||||
const editor = await openEditor();
|
||||
const viewport = editor.querySelector<HTMLElement>(
|
||||
'.genarrative-ui-modal__body',
|
||||
)!;
|
||||
viewport.scrollTop = 400;
|
||||
const bounds = (top: number, bottom: number) =>
|
||||
({
|
||||
top,
|
||||
bottom,
|
||||
height: bottom - top,
|
||||
left: 0,
|
||||
right: 300,
|
||||
width: 300,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(
|
||||
bounds(100, 450),
|
||||
);
|
||||
const originalBounds = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
return this.classList.contains('admin-agc-template-feedback')
|
||||
? bounds(-280, -120)
|
||||
: originalBounds.call(this);
|
||||
},
|
||||
);
|
||||
const windowScroll = vi
|
||||
.spyOn(window, 'scrollTo')
|
||||
.mockImplementation(() => {});
|
||||
const focus = vi.spyOn(HTMLElement.prototype, 'focus');
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
await confirmWrite();
|
||||
const message = await screen.findByRole('alert');
|
||||
const feedback = message.parentElement!;
|
||||
expect(document.activeElement).toBe(feedback);
|
||||
expect(feedback.tabIndex).toBe(-1);
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
expect(viewport.scrollTop).toBe(20);
|
||||
expect(background.scrollTop).toBe(240);
|
||||
expect(windowScroll).not.toHaveBeenCalled();
|
||||
if (status === 409) {
|
||||
expect(
|
||||
within(feedback).getByRole('button', { name: '刷新后重新编辑' }),
|
||||
).not.toBeNull();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('token 切换后忽略旧列表和未确认写操作', async () => {
|
||||
const pending = deferred<AdminAgcTemplateLibraryResponse>();
|
||||
vi.mocked(getAdminAgcTemplates).mockReturnValueOnce(pending.promise);
|
||||
const props = { onUnauthorized: vi.fn() };
|
||||
const view = render(<AdminAgcTemplatesPage token="old" {...props} />);
|
||||
view.rerender(<AdminAgcTemplatesPage token="new" {...props} />);
|
||||
await screen.findByText('空白 2D');
|
||||
await act(async () =>
|
||||
pending.resolve({
|
||||
...library,
|
||||
templates: [{ ...template, title: '过期列表' }],
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText('过期列表')).toBeNull();
|
||||
const editor = await openEditor();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
view.rerender(<AdminAgcTemplatesPage token="third" {...props} />);
|
||||
await screen.findByText('空白 2D');
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(['success', 'unauthorized'])(
|
||||
'token 切换后忽略旧保存 %s,不污染新会话',
|
||||
async (result) => {
|
||||
const pending = deferred<AdminAgcTemplateLibraryResponse>();
|
||||
vi.mocked(updateAdminAgcTemplate).mockReturnValueOnce(pending.promise);
|
||||
const onUnauthorized = vi.fn();
|
||||
const view = render(
|
||||
<AdminAgcTemplatesPage token="old" onUnauthorized={onUnauthorized} />,
|
||||
);
|
||||
const editor = await openEditor();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
await confirmWrite();
|
||||
await waitFor(() =>
|
||||
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
view.rerender(
|
||||
<AdminAgcTemplatesPage token="new" onUnauthorized={onUnauthorized} />,
|
||||
);
|
||||
await screen.findByText('空白 2D');
|
||||
await act(async () => {
|
||||
if (result === 'success') {
|
||||
pending.resolve({
|
||||
...library,
|
||||
templates: [{ ...template, title: '旧保存结果' }],
|
||||
});
|
||||
} else {
|
||||
pending.reject(
|
||||
new AdminApiError({ status: 401, message: '旧会话失效' }),
|
||||
);
|
||||
}
|
||||
});
|
||||
expect(onUnauthorized).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('旧会话失效')).toBeNull();
|
||||
expect(screen.queryByText('旧保存结果')).toBeNull();
|
||||
expect(screen.queryByText('已保存')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test('封面在草稿中转 base64,保存携带,取消释放预览 URL 且不串到下一条', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
let editor = await openEditor();
|
||||
const file = new File(['cover'], 'cover.png', { type: 'image/png' });
|
||||
fireEvent.change(within(editor).getByLabelText('更换封面'), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByText('正在读取封面')).toBeNull());
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
await confirmWrite();
|
||||
await waitFor(() =>
|
||||
expect(updateAdminAgcTemplate).toHaveBeenCalledWith(
|
||||
'token',
|
||||
template.id,
|
||||
expect.objectContaining({
|
||||
cover: { contentType: 'image/png', dataBase64: 'Y292ZXI=' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:template-cover'),
|
||||
);
|
||||
editor = await openEditor();
|
||||
fireEvent.change(within(editor).getByLabelText('更换封面'), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '取消' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑 Godot 起步' }));
|
||||
expect(
|
||||
screen.getByRole('img', { name: '模板封面预览' }).getAttribute('src'),
|
||||
).toBe(template.coverUrl);
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['bad.svg', 'image/svg+xml', 1, '封面仅支持 PNG、JPEG 或 WebP'],
|
||||
[
|
||||
'large.png',
|
||||
'image/png',
|
||||
5 * 1024 * 1024 + 1,
|
||||
'封面文件须大于 0 且不超过 5 MiB',
|
||||
],
|
||||
])('拒绝无效封面 %s,不上传', async (name, type, size, message) => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const editor = await openEditor();
|
||||
const file = new File(['x'], name, { type });
|
||||
Object.defineProperty(file, 'size', { value: size });
|
||||
fireEvent.change(within(editor).getByLabelText('更换封面'), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
expect(screen.getByText(message)).not.toBeNull();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('名称和标签校验失败时不进入写确认', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const editor = await openEditor();
|
||||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||||
target: { value: ' ' },
|
||||
});
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
expect(screen.getByText('名称须为 1–80 个字符')).not.toBeNull();
|
||||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||||
target: { value: '有效名称' },
|
||||
});
|
||||
fireEvent.change(within(editor).getByLabelText('标签'), {
|
||||
target: {
|
||||
value: Array.from({ length: 17 }, (_, index) => `标签${index}`).join(','),
|
||||
},
|
||||
});
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
expect(
|
||||
screen.getByText('最多 16 个标签,每个标签不超过 32 个字符'),
|
||||
).not.toBeNull();
|
||||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('读取失败显示真实错误,401 交给会话处理且不显示空库', async () => {
|
||||
const onUnauthorized = vi.fn();
|
||||
vi.mocked(getAdminAgcTemplates).mockRejectedValueOnce(
|
||||
new AdminApiError({ status: 503, message: '模板服务不可用' }),
|
||||
);
|
||||
render(
|
||||
<AdminAgcTemplatesPage token="token" onUnauthorized={onUnauthorized} />,
|
||||
);
|
||||
await screen.findByText('模板服务不可用');
|
||||
expect(screen.queryByText('暂无模板')).toBeNull();
|
||||
vi.mocked(getAdminAgcTemplates).mockRejectedValueOnce(
|
||||
new AdminApiError({ status: 401, message: '会话失效' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await waitFor(() =>
|
||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatAdminTimestamp,
|
||||
parseAdminTimestamp,
|
||||
} from './AdminErrorReportsPage';
|
||||
|
||||
describe('错误报告时间格式化', () => {
|
||||
it('解析 SpacetimeDB seconds.microsZ 时间', () => {
|
||||
expect(parseAdminTimestamp('1778207451.731746Z')).toBe(1778207451731);
|
||||
});
|
||||
|
||||
it('解析微秒字符串并拒绝无效时间', () => {
|
||||
expect(parseAdminTimestamp('1778207451731746')).toBe(1778207451731);
|
||||
expect(formatAdminTimestamp('not-a-date')).toBe('-');
|
||||
expect(formatAdminTimestamp('999999999999999999999999')).toBe('-');
|
||||
});
|
||||
});
|
||||
@@ -17,57 +17,6 @@ type Props = { token: string; onUnauthorized: (message?: string) => void };
|
||||
|
||||
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
|
||||
const ERROR_REPORT_PAGE_SIZE = 50;
|
||||
const ERROR_REPORT_STATUS_LABELS: Record<string, string> = {
|
||||
new: '待处理',
|
||||
'in-progress': '处理中',
|
||||
resolved: '已解决',
|
||||
};
|
||||
|
||||
export function parseAdminTimestamp(value: string | null | undefined) {
|
||||
const normalized = value?.trim() ?? '';
|
||||
if (/^-?\d+\.\d{6}Z$/u.test(normalized)) {
|
||||
const [secondsText, microsText] = normalized.slice(0, -1).split('.');
|
||||
const seconds = Number(secondsText);
|
||||
const micros = Number(microsText);
|
||||
return Number.isFinite(seconds) && Number.isFinite(micros)
|
||||
? seconds * 1000 + Math.floor(micros / 1000)
|
||||
: Number.NaN;
|
||||
}
|
||||
if (/^-?\d+$/u.test(normalized)) {
|
||||
const numeric = Number(normalized);
|
||||
if (!Number.isFinite(numeric)) return Number.NaN;
|
||||
if (Math.abs(numeric) >= 1e14) return Math.floor(numeric / 1000);
|
||||
if (Math.abs(numeric) >= 1e11) return numeric;
|
||||
return numeric * 1000;
|
||||
}
|
||||
return Date.parse(normalized);
|
||||
}
|
||||
|
||||
export function formatAdminTimestamp(value: string | null | undefined) {
|
||||
const timestamp = parseAdminTimestamp(value);
|
||||
if (!Number.isFinite(timestamp)) return '-';
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isFinite(date.getTime())) return '-';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(date);
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
function eventText(event: Record<string, unknown>, key: string) {
|
||||
const value = event[key];
|
||||
return typeof value === 'string' || typeof value === 'number'
|
||||
? String(value)
|
||||
: '';
|
||||
}
|
||||
|
||||
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
|
||||
@@ -80,24 +29,6 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
|
||||
const openReportRequestId = useRef(0);
|
||||
const loadRequestId = useRef(0);
|
||||
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const previousFocus = document.activeElement as HTMLElement | null;
|
||||
const focusFrame = window.requestAnimationFrame(() => {
|
||||
detailCloseButtonRef.current?.focus();
|
||||
});
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setSelected(null);
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(focusFrame);
|
||||
window.removeEventListener('keydown', handleEscape);
|
||||
previousFocus?.focus?.();
|
||||
};
|
||||
}, [selected]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const requestId = ++loadRequestId.current;
|
||||
@@ -253,15 +184,9 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
<td>{report.batchId}</td>
|
||||
<td>{report.eventCount}</td>
|
||||
<td>{report.source ?? '-'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-error-report-status is-${report.status}`}
|
||||
>
|
||||
{ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{report.status}</td>
|
||||
<td>{report.userId}</td>
|
||||
<td>{formatAdminTimestamp(report.createdAt)}</td>
|
||||
<td>{new Date(report.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -299,173 +224,66 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
className="admin-detail-modal"
|
||||
role="dialog"
|
||||
aria-label="错误报告详情"
|
||||
aria-modal="true"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setSelected(null);
|
||||
}}
|
||||
>
|
||||
<div className="admin-detail-modal__panel admin-error-report-detail">
|
||||
<header className="admin-error-report-detail__header">
|
||||
<div className="admin-error-report-detail__heading">
|
||||
<span className="admin-error-report-detail__eyebrow">
|
||||
错误报告详情
|
||||
</span>
|
||||
<h2>{selected.batchId}</h2>
|
||||
<div className="admin-error-report-detail__badges">
|
||||
<span
|
||||
className={`admin-error-report-status is-${selected.status}`}
|
||||
>
|
||||
{ERROR_REPORT_STATUS_LABELS[selected.status] ??
|
||||
selected.status}
|
||||
</span>
|
||||
<span className="admin-error-report-detail__badge">
|
||||
{selected.firstSource ?? selected.source ?? '未知来源'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-error-report-detail__close"
|
||||
ref={detailCloseButtonRef}
|
||||
onClick={() => setSelected(null)}
|
||||
aria-label="关闭错误报告详情"
|
||||
>
|
||||
×
|
||||
<div className="admin-detail-modal__panel">
|
||||
<header>
|
||||
<h2>{selected.batchId}</h2>
|
||||
<button type="button" onClick={() => setSelected(null)}>
|
||||
关闭
|
||||
</button>
|
||||
</header>
|
||||
<div className="admin-error-report-detail__summary">
|
||||
<div>
|
||||
<span>用户</span>
|
||||
<strong>{selected.userId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发生时间</span>
|
||||
<strong>{formatAdminTimestamp(selected.createdAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>错误事件</span>
|
||||
<strong>{selected.eventCount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>应用日志</span>
|
||||
<strong>{selected.logCount}</strong>
|
||||
</div>
|
||||
<p>
|
||||
用户:{selected.userId} · 事件:{selected.eventCount} · 日志:
|
||||
{selected.logCount}
|
||||
</p>
|
||||
<pre>{JSON.stringify(selected.events.slice(0, 20), null, 2)}</pre>
|
||||
{selected.userDescription ? (
|
||||
<p>用户描述:{selected.userDescription}</p>
|
||||
) : null}
|
||||
<label className="admin-detail-modal__note">
|
||||
处理备注
|
||||
<textarea
|
||||
value={selected.note ?? ''}
|
||||
onChange={(event) =>
|
||||
setSelected((current) =>
|
||||
current
|
||||
? { ...current, note: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
placeholder="记录处理结论或后续跟进事项"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<div className="admin-detail-modal__actions">
|
||||
<select
|
||||
value={selected.status}
|
||||
onChange={(event) => void saveStatus(event.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveStatus(selected.status)}
|
||||
disabled={busy}
|
||||
>
|
||||
保存备注
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void download(selected.batchId)}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? '下载中…' : '下载诊断包'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-error-report-detail__body">
|
||||
<section className="admin-error-report-detail__section">
|
||||
<div className="admin-error-report-detail__section-heading">
|
||||
<div>
|
||||
<h3>错误事件</h3>
|
||||
<span>展示最近 20 条,完整内容请下载诊断包。</span>
|
||||
</div>
|
||||
<span className="admin-error-report-detail__count">
|
||||
{selected.events.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-error-report-event-list">
|
||||
{selected.events.length === 0 ? (
|
||||
<p className="admin-error-report-detail__empty">
|
||||
归档中没有可展示的错误事件。
|
||||
</p>
|
||||
) : null}
|
||||
{selected.events.slice(0, 20).map((event, index) => {
|
||||
const message = eventText(event, 'message') || '未知错误';
|
||||
const stack = eventText(event, 'stack');
|
||||
const occurredAt = eventText(event, 'occurredAt');
|
||||
return (
|
||||
<article
|
||||
className="admin-error-report-event"
|
||||
key={`${index}-${message}`}
|
||||
>
|
||||
<div className="admin-error-report-event__topline">
|
||||
<span className="admin-error-report-event__index">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span>{eventText(event, 'source') || 'client'}</span>
|
||||
<span>{eventText(event, 'count') || '1'} 次</span>
|
||||
<time>{formatAdminTimestamp(occurredAt)}</time>
|
||||
</div>
|
||||
<p className="admin-error-report-event__message">
|
||||
{message}
|
||||
</p>
|
||||
{eventText(event, 'fingerprint') ? (
|
||||
<code>{eventText(event, 'fingerprint')}</code>
|
||||
) : null}
|
||||
{stack ? (
|
||||
<details>
|
||||
<summary>查看调用栈</summary>
|
||||
<pre>{stack}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{selected.userDescription ? (
|
||||
<section className="admin-error-report-detail__section">
|
||||
<div className="admin-error-report-detail__section-heading">
|
||||
<div>
|
||||
<h3>用户描述</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="admin-error-report-detail__description">
|
||||
{selected.userDescription}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="admin-error-report-detail__section admin-error-report-detail__section--note">
|
||||
<label className="admin-detail-modal__note">
|
||||
<span>处理备注</span>
|
||||
<textarea
|
||||
value={selected.note ?? ''}
|
||||
onChange={(event) =>
|
||||
setSelected((current) =>
|
||||
current
|
||||
? { ...current, note: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
placeholder="记录处理结论或后续跟进事项"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
<footer className="admin-error-report-detail__footer">
|
||||
<label className="admin-error-report-detail__status-field">
|
||||
状态
|
||||
<select
|
||||
value={selected.status}
|
||||
onChange={(event) => void saveStatus(event.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{ERROR_REPORT_STATUS_LABELS[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="admin-error-report-detail__footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveStatus(selected.status)}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? '保存中…' : '保存备注'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void download(selected.batchId)}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? '下载中…' : '下载诊断包'}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -154,27 +154,6 @@ test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async ()
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页可选择模板库并默认启用零比例灰度', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), ['agc']);
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'agc:template-library',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
||||
).toBe('template-library');
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
true,
|
||||
);
|
||||
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
||||
'0',
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页保存时转换数组和百分比', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||
|
||||
@@ -27,17 +27,9 @@ interface GateTargetOption {
|
||||
|
||||
const GATE_PREFIX_LABELS: Record<string, string> = {
|
||||
'image-editor': '画布',
|
||||
agc: '客户端',
|
||||
};
|
||||
|
||||
const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||
{
|
||||
prefix: 'agc',
|
||||
suffix: 'template-library',
|
||||
key: 'agc:template-library',
|
||||
label: '模板库',
|
||||
description: '客户端模板库灰度',
|
||||
},
|
||||
{
|
||||
prefix: 'image-editor',
|
||||
suffix: 'agent-sidebar',
|
||||
@@ -188,7 +180,7 @@ export function AdminGrayReleaseConfigPage({
|
||||
setSelectedGateKey('');
|
||||
setGatePrefix(option.prefix);
|
||||
setGateKey(option.key);
|
||||
setEnabled(option.key === 'agc:template-library');
|
||||
setEnabled(false);
|
||||
setRolloutPercent('0');
|
||||
setAllowUserIds('');
|
||||
setAllowUserTags('');
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AdminApiError,
|
||||
downloadAdminProjectSnapshot,
|
||||
listAdminProjectSnapshots,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||
import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', async () => ({
|
||||
...(await vi.importActual<typeof import('../api/adminApiClient')>(
|
||||
'../api/adminApiClient',
|
||||
)),
|
||||
downloadAdminProjectSnapshot: vi.fn(),
|
||||
listAdminProjectSnapshots: vi.fn(),
|
||||
}));
|
||||
|
||||
const entry: AdminProjectSnapshotEntry = {
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
projectName: '三消工程',
|
||||
syncRevision: 3,
|
||||
syncedAtMs: 1_700_000_000_000,
|
||||
fileCount: 12,
|
||||
totalBytes: 2048,
|
||||
status: 'ready',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [entry], nextCursor: null });
|
||||
vi.mocked(downloadAdminProjectSnapshot).mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('按项目展示完整性并限制未完成工程下载', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValue({
|
||||
items: [
|
||||
entry,
|
||||
{
|
||||
...entry,
|
||||
projectId: 'partial-project',
|
||||
projectName: '未完成工程',
|
||||
status: 'partial',
|
||||
},
|
||||
{
|
||||
...entry,
|
||||
projectId: 'legacy-project',
|
||||
projectName: null,
|
||||
status: 'unverified',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const completeRow = (await screen.findByText('三消工程')).closest('tr')!;
|
||||
expect(within(completeRow).getByText('2 KiB')).toBeTruthy();
|
||||
expect(
|
||||
within(completeRow)
|
||||
.getByRole('button', { name: '下载完整工程' })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '同步未完成' }).hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
expect(screen.getByText('完整性未知')).toBeTruthy();
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '下载已存文件' })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
||||
nextCursor: null,
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('远端清单读取失败'))
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: null });
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
|
||||
await screen.findByText('第二工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'token',
|
||||
{ cursor: 'page-2', limit: 20 },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(screen.getByText('三消工程')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByText('第二工程')).toBeTruthy();
|
||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByText('暂无已上传项目');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
{ cursor: null, limit: 20 },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:archive');
|
||||
const revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal(
|
||||
'URL',
|
||||
class extends URL {
|
||||
static createObjectURL = createObjectURL;
|
||||
static revokeObjectURL = revokeObjectURL;
|
||||
},
|
||||
);
|
||||
let savedFilename = '';
|
||||
let savedHref = '';
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
||||
this: HTMLAnchorElement,
|
||||
) {
|
||||
savedFilename = this.download;
|
||||
savedHref = this.href;
|
||||
});
|
||||
const blob = new Blob(['PK'], { type: 'application/zip' });
|
||||
vi.mocked(downloadAdminProjectSnapshot).mockResolvedValue({
|
||||
blob,
|
||||
filename: '三消工程-r3.zip',
|
||||
});
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const button = await screen.findByRole('button', { name: '下载完整工程' });
|
||||
vi.useFakeTimers();
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
});
|
||||
expect(savedFilename).toBe('三消工程-r3.zip');
|
||||
expect(savedHref).toBe('blob:archive');
|
||||
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
||||
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
|
||||
'token',
|
||||
'user-1',
|
||||
'project-1',
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:archive');
|
||||
});
|
||||
|
||||
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
||||
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
||||
(_token, _user, _project, signal) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const view = render(
|
||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
|
||||
expect(
|
||||
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted,
|
||||
).toBe(true);
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
||||
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
const signal = vi.mocked(listAdminProjectSnapshots).mock.calls.at(-1)?.[2];
|
||||
view.unmount();
|
||||
expect(signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test('下载登录失效走现有会话处理,403 错误保留页面', async () => {
|
||||
const onUnauthorized = vi.fn();
|
||||
vi.mocked(downloadAdminProjectSnapshot)
|
||||
.mockRejectedValueOnce(
|
||||
new AdminApiError({ status: 403, message: '无项目工程权限' }),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new AdminApiError({ status: 401, message: '已过期' }),
|
||||
);
|
||||
render(
|
||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||
expect(await screen.findByText('无项目工程权限')).toBeTruthy();
|
||||
expect(onUnauthorized).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载完整工程' }));
|
||||
await waitFor(() =>
|
||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||
);
|
||||
});
|
||||
|
||||
test('首次列表失败显示错误而非空项目,401 失效回到会话处理', async () => {
|
||||
const onUnauthorized = vi.fn();
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockRejectedValueOnce(new Error('清单存储不可用'))
|
||||
.mockRejectedValueOnce(
|
||||
new AdminApiError({ status: 401, message: '已过期' }),
|
||||
);
|
||||
render(
|
||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
|
||||
);
|
||||
await screen.findByText('清单存储不可用');
|
||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await waitFor(() =>
|
||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||
);
|
||||
});
|
||||
|
||||
test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
|
||||
let finishOldRequest!: (value: {
|
||||
items: AdminProjectSnapshotEntry[];
|
||||
nextCursor: null;
|
||||
}) => void;
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishOldRequest = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectName: '新账号工程' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
const onUnauthorized = vi.fn();
|
||||
const view = render(
|
||||
<AdminProjectSnapshotsPage
|
||||
token="old-token"
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>,
|
||||
);
|
||||
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
|
||||
view.rerender(
|
||||
<AdminProjectSnapshotsPage
|
||||
token="new-token"
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('新账号工程');
|
||||
expect(oldSignal?.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
finishOldRequest({ items: [entry], nextCursor: null });
|
||||
});
|
||||
expect(screen.queryByText('三消工程')).toBeNull();
|
||||
expect(screen.getByText('新账号工程')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('卸载后完成的下载不会创建浏览器文件', async () => {
|
||||
let finishDownload!: (value: { blob: Blob; filename: string }) => void;
|
||||
vi.mocked(downloadAdminProjectSnapshot).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finishDownload = resolve;
|
||||
}),
|
||||
);
|
||||
const click = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {});
|
||||
const view = render(
|
||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3];
|
||||
view.unmount();
|
||||
expect(signal?.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
finishDownload({ blob: new Blob(['PK']), filename: 'old.zip' });
|
||||
});
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
import { Download, RefreshCcw, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
downloadAdminProjectSnapshot,
|
||||
listAdminProjectSnapshots,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminProjectSnapshotsPageProps {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
const snapshotStatuses = {
|
||||
ready: {
|
||||
label: '已同步',
|
||||
className: 'admin-status-ok',
|
||||
action: '下载完整工程',
|
||||
},
|
||||
partial: {
|
||||
label: '同步未完成',
|
||||
className: 'admin-status-pending',
|
||||
action: '同步未完成',
|
||||
},
|
||||
unverified: {
|
||||
label: '完整性未知',
|
||||
className: 'admin-status-pending',
|
||||
action: '下载已存文件',
|
||||
},
|
||||
};
|
||||
|
||||
export function AdminProjectSnapshotsPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminProjectSnapshotsPageProps) {
|
||||
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
||||
const listController = useRef<AbortController | null>(null);
|
||||
const downloadController = useRef<AbortController | null>(null);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (cursor: string | null = null) => {
|
||||
listController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
listController.current = controller;
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await listAdminProjectSnapshots(
|
||||
token,
|
||||
{ cursor, limit: 20 },
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setItems((current) => {
|
||||
if (!cursor) return response.items;
|
||||
const entries = new Map(
|
||||
current.map((entry) => [snapshotKey(entry), entry]),
|
||||
);
|
||||
response.items.forEach((entry) =>
|
||||
entries.set(snapshotKey(entry), entry),
|
||||
);
|
||||
return [...entries.values()];
|
||||
});
|
||||
setNextCursor(response.nextCursor);
|
||||
setHasLoaded(true);
|
||||
} catch (error: unknown) {
|
||||
if (!controller.signal.aborted)
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
if (listController.current === controller) {
|
||||
listController.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[token, onUnauthorized],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setItems([]);
|
||||
setNextCursor(null);
|
||||
setHasLoaded(false);
|
||||
setDownloadingKey(null);
|
||||
void loadPage();
|
||||
return () => {
|
||||
listController.current?.abort();
|
||||
listController.current = null;
|
||||
downloadController.current?.abort();
|
||||
downloadController.current = null;
|
||||
};
|
||||
}, [loadPage]);
|
||||
|
||||
async function downloadProject(entry: AdminProjectSnapshotEntry) {
|
||||
if (downloadController.current || entry.status === 'partial') return;
|
||||
const controller = new AbortController();
|
||||
downloadController.current = controller;
|
||||
setDownloadingKey(snapshotKey(entry));
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const archive = await downloadAdminProjectSnapshot(
|
||||
token,
|
||||
entry.userId,
|
||||
entry.projectId,
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
const objectUrl = URL.createObjectURL(archive.blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = archive.filename;
|
||||
document.body.append(link);
|
||||
try {
|
||||
link.click();
|
||||
} finally {
|
||||
link.remove();
|
||||
// 给浏览器时间接管下载,随后释放临时 URL。
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!controller.signal.aborted)
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
if (downloadController.current === controller) {
|
||||
downloadController.current = null;
|
||||
setDownloadingKey(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDownload() {
|
||||
downloadController.current?.abort();
|
||||
downloadController.current = null;
|
||||
setDownloadingKey(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<h2>项目工程</h2>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={() => void loadPage()}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '加载中' : '刷新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="alert">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<section
|
||||
className="admin-panel admin-stack"
|
||||
aria-label="项目工程列表"
|
||||
aria-busy={isLoading}
|
||||
>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-project-snapshot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目</th>
|
||||
<th>用户 ID</th>
|
||||
<th>同步时间</th>
|
||||
<th>文件数</th>
|
||||
<th>体积</th>
|
||||
<th>完整性</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((entry) => {
|
||||
const status = snapshotStatuses[entry.status];
|
||||
const isDownloading = downloadingKey === snapshotKey(entry);
|
||||
return (
|
||||
<tr key={snapshotKey(entry)}>
|
||||
<td data-label="项目">
|
||||
<strong>{entry.projectName || entry.projectId}</strong>
|
||||
<small>{entry.projectId}</small>
|
||||
</td>
|
||||
<td data-label="用户 ID">{entry.userId}</td>
|
||||
<td data-label="同步时间">
|
||||
<span>
|
||||
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
})}
|
||||
<small>版本 {entry.syncRevision}</small>
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="文件数">
|
||||
{entry.fileCount.toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td data-label="体积">{formatBytes(entry.totalBytes)}</td>
|
||||
<td data-label="完整性">
|
||||
<span className={`admin-status ${status.className}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="操作">
|
||||
{isDownloading ? (
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
type="button"
|
||||
onClick={cancelDownload}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
<span>取消下载</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={
|
||||
entry.status === 'partial' ||
|
||||
downloadingKey !== null
|
||||
}
|
||||
type="button"
|
||||
onClick={() => void downloadProject(entry)}
|
||||
>
|
||||
<Download size={16} aria-hidden="true" />
|
||||
<span>{status.action}</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasLoaded && items.length === 0 && !errorMessage ? (
|
||||
<p className="admin-muted-text">暂无已上传项目</p>
|
||||
) : null}
|
||||
{nextCursor ? (
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={() => void loadPage(nextCursor)}
|
||||
>
|
||||
{isLoading ? '加载中' : '加载更多'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
||||
return `${entry.userId}/${entry.projectId}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
||||
const unit = Math.min(
|
||||
Math.floor(Math.log2(Math.max(1, bytes)) / 10),
|
||||
units.length - 1,
|
||||
);
|
||||
return `${(bytes / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} ${units[unit]}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user