Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ade1b7803e | |||
| 71000b6df1 | |||
| c047543825 | |||
| 34b3af1d2a | |||
| f7cba30b6b | |||
| 273f12633c | |||
| 5f5aa17152 | |||
| 3a57d9fbdf | |||
| 382b925ab9 | |||
| c1482012c6 | |||
| c3a17a6efc | |||
| 1bfdc3a760 | |||
| 1e27cd229d | |||
| 774710452f | |||
| aaecb82622 | |||
| 64ad24ac77 | |||
| 75ec3361dc | |||
| bc1dc868a1 | |||
| 50204ff7aa | |||
| 70fa160819 | |||
| 2e78cbf152 | |||
| 840baf208f | |||
| d8af0fa882 | |||
| faed0ec251 | |||
| 6e63a118e0 | |||
| 4ae5ab14fa | |||
| b73a329b2c | |||
| 06004bfeea | |||
| ecea7b4ad3 | |||
| b1e82d64cb | |||
| b7622f4a24 | |||
| cb840c6e8c | |||
| 0a9cdf6395 | |||
| e382d23892 | |||
| 37ae7508b8 | |||
| 1ae6c6cd06 | |||
| 459f8a60e5 | |||
| 63c495dab0 | |||
| b37d91882c | |||
| dd339638cc | |||
| 0caf998227 | |||
| 6eed6bd13f | |||
| b543a060b7 | |||
| 9993b2feb5 | |||
| d348cf9064 | |||
| e640219243 | |||
| da2ad83c3b | |||
| ad19c9475e | |||
| 975a2e577c | |||
| 1b40f030e7 | |||
| 098693636a | |||
| e17af3008e | |||
| 64bd006981 | |||
| 6a4cb2bc18 | |||
| 1b3a3b003f | |||
| ff8e517c8e | |||
| d874a13a92 | |||
| 5eba53a5cc | |||
| 33ddb5de27 | |||
| bf8f96c115 | |||
| e469831543 | |||
| 869abad879 | |||
| 280a2dc5d1 | |||
| 3c6e084a41 | |||
| ed3369a494 | |||
| fdc48fe725 | |||
| 2748468d12 | |||
| 09ad0073fe | |||
| 42b702d362 | |||
| edfd1f9143 | |||
| 423f2d7184 | |||
| 656c89e4b2 | |||
| aa9076c95c | |||
| 0ec1179bf1 | |||
| 2938a49cac | |||
| ae0f9376c9 | |||
| 29d0cbb4df | |||
| 41366dd71d | |||
| 721e45f01b | |||
| f4b496caca | |||
| fc0fd846f9 | |||
| fa99d9ad62 | |||
| 64a52005f8 | |||
| 645d806fed | |||
| 0b9d16e8f1 | |||
| d5c4e0e780 | |||
| 30e27abc60 | |||
| b607501916 | |||
| 46e3cd9744 | |||
| 5fb3db662b | |||
| 87b798322e | |||
| ec286b3480 | |||
| 35733f0e33 | |||
| 3c7b02b9f8 | |||
| f2030a616f | |||
| b3b5d77990 | |||
| 1da106af7d | |||
| cd2edd6966 | |||
| 22e830ff93 | |||
| c266ae7b50 | |||
| a543b75cf7 | |||
| f213987f9a | |||
| 2c623bb577 | |||
| 3353906e6f | |||
| 17716347e2 | |||
| 9ad66a67a3 | |||
| ba3aa3ccdb | |||
| 2b38eaafba | |||
| 7d5b9071e7 | |||
| 9e83f1d88c | |||
| 7e35d7c344 | |||
| 5ec40c8b83 | |||
| 948a80fc49 | |||
| dfd6fadedf |
@@ -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,15 +3,12 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
createAdminAccount,
|
||||
executeAdminRechargeRefund,
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
updateAdminAccount,
|
||||
updateAdminAgcTemplate,
|
||||
uploadAdminEditorShowcaseCampaignImage,
|
||||
upsertAdminFeatureGateConfig,
|
||||
upsertProfileWalletConfig,
|
||||
@@ -21,93 +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('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
|
||||
const imported = {
|
||||
revision: 'rev-2',
|
||||
writable: true,
|
||||
templates: [],
|
||||
imported: [
|
||||
{
|
||||
id: 'alpha',
|
||||
templateVersion: '0.1.0',
|
||||
zipSizeBytes: 4,
|
||||
zipSha256: 'a'.repeat(64),
|
||||
reusedObjects: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'manifest',
|
||||
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
|
||||
);
|
||||
form.append(
|
||||
'zip_0',
|
||||
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
|
||||
);
|
||||
|
||||
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('/admin/api/agc-templates/import');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toBe(form);
|
||||
expect(init.headers).not.toHaveProperty('Content-Type');
|
||||
expect(init.headers.Authorization).toBe('Bearer admin-token');
|
||||
});
|
||||
|
||||
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
AdminAccountListResponse,
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||
AdminCreateAccountRequest,
|
||||
AdminCreateAccountResponse,
|
||||
@@ -30,13 +29,9 @@ import type {
|
||||
AdminExternalApiKeyListQuery,
|
||||
AdminExternalApiKeyListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminImportAgcTemplatesResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
AdminProjectSnapshotChannelsResponse,
|
||||
AdminProjectSnapshotListQuery,
|
||||
AdminProjectSnapshotListResponse,
|
||||
AdminRechargeOrderListQuery,
|
||||
AdminRechargeOrderListResponse,
|
||||
AdminRechargeRefundActionResponse,
|
||||
@@ -50,7 +45,6 @@ import type {
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpdateAccountRequest,
|
||||
AdminUpdateAccountResponse,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
@@ -89,8 +83,6 @@ interface AdminRequestOptions {
|
||||
method?: string;
|
||||
token?: string;
|
||||
body?: unknown;
|
||||
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
|
||||
formData?: FormData;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
@@ -178,8 +170,6 @@ export async function request<T>(
|
||||
if (typeof options.body !== 'undefined') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(options.body);
|
||||
} else if (options.formData) {
|
||||
init.body = options.formData;
|
||||
}
|
||||
|
||||
const response = await fetch(buildRequestUrl(path), init);
|
||||
@@ -208,107 +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);
|
||||
if (query.channel) params.set('channel', query.channel);
|
||||
params.set('limit', String(query.limit ?? 20));
|
||||
return request<AdminProjectSnapshotListResponse>(
|
||||
`/admin/api/project-snapshots?${params.toString()}`,
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminProjectSnapshotChannels(
|
||||
token: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return request<AdminProjectSnapshotChannelsResponse>(
|
||||
'/admin/api/project-snapshots/channels',
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadAdminProjectSnapshot(
|
||||
token: string,
|
||||
channel: string,
|
||||
userId: string,
|
||||
projectId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (channel) params.set('channel', channel);
|
||||
const query = params.toString();
|
||||
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`;
|
||||
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,
|
||||
@@ -1199,33 +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 },
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
|
||||
export function importAdminAgcTemplates(token: string, formData: FormData) {
|
||||
return request<AdminImportAgcTemplatesResponse>(
|
||||
'/admin/api/agc-templates/import',
|
||||
{
|
||||
token,
|
||||
method: 'POST',
|
||||
formData,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,36 +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';
|
||||
channel: string;
|
||||
authorDisplayName?: string | null;
|
||||
authorPublicUserCode?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotListQuery {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
channel?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotListResponse {
|
||||
items: AdminProjectSnapshotEntry[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotChannelsResponse {
|
||||
defaultChannel: string;
|
||||
channels: string[];
|
||||
}
|
||||
|
||||
export interface AdminErrorReportEntry {
|
||||
batchId: string;
|
||||
eventCount: number;
|
||||
@@ -153,8 +123,6 @@ export interface AdminErrorReportListResponse {
|
||||
}
|
||||
|
||||
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
|
||||
firstFingerprint?: string;
|
||||
firstSource?: string;
|
||||
note?: string;
|
||||
events: Array<Record<string, unknown>>;
|
||||
logNames: string[];
|
||||
@@ -1042,67 +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;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplateItemPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
runtime: string;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
templateVersion: string;
|
||||
entry: string;
|
||||
zipField: string;
|
||||
coverField: string;
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplatesManifest {
|
||||
expectedRevision: string;
|
||||
templates: AdminImportAgcTemplateItemPayload[];
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplateResult {
|
||||
id: string;
|
||||
templateVersion: string;
|
||||
zipSizeBytes: number;
|
||||
zipSha256: string;
|
||||
reusedObjects: boolean;
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplatesResponse
|
||||
extends AdminAgcTemplateLibraryResponse {
|
||||
imported: AdminImportAgcTemplateResult[];
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
downloadAdminProjectSnapshot,
|
||||
getAdminProjectSnapshotChannels,
|
||||
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, channel: 'release' },
|
||||
controller.signal,
|
||||
),
|
||||
).toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&channel=release&limit=20',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('渠道列表按后台授权读取,解析本部署渠道', async () => {
|
||||
const payload = { defaultChannel: 'release', channels: ['dev', 'release'] };
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true, data: payload })),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/project-snapshots/channels',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
'release',
|
||||
'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?channel=release',
|
||||
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', 'release', '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', 'release', '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', 'release', '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';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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,431 +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,
|
||||
getAdminProjectSnapshotChannels,
|
||||
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(),
|
||||
getAdminProjectSnapshotChannels: 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',
|
||||
channel: 'dev',
|
||||
authorDisplayName: '陶泥作者',
|
||||
authorPublicUserCode: 'SY-00000007',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [entry], nextCursor: null });
|
||||
vi.mocked(getAdminProjectSnapshotChannels)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] });
|
||||
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: 'page-3',
|
||||
})
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' });
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
const pagination = await screen.findByRole('navigation', {
|
||||
name: '项目工程分页',
|
||||
});
|
||||
expect(pagination.textContent).toContain('第 1 页');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '上一页' }).hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第二工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'token',
|
||||
{ cursor: 'page-2', limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(screen.queryByText('三消工程')).toBeNull();
|
||||
expect(pagination.textContent).toContain('第 2 页');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
||||
await screen.findByText('三消工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(pagination.textContent).toContain('第 1 页');
|
||||
});
|
||||
|
||||
test('切换每页条数从第一页按新条数重新加载', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
||||
nextCursor: 'page-3',
|
||||
})
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: null });
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第二工程');
|
||||
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
|
||||
expect(pagination.textContent).toContain('第 2 页');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('每页条数'), {
|
||||
target: { value: '50' },
|
||||
});
|
||||
await screen.findByText('三消工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: null, limit: 50, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
// 重新加载时页脚会重建,必须重新取节点再断言。
|
||||
expect(
|
||||
screen.getByRole('navigation', { name: '项目工程分页' }).textContent,
|
||||
).toContain('第 1 页');
|
||||
});
|
||||
|
||||
test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
||||
nextCursor: null,
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('翻页读取失败'));
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第二工程');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByRole('alert');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: 'page-2', limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
|
||||
expect(pagination.textContent).toContain('第 2 页');
|
||||
expect(screen.getByText('第二工程')).toBeTruthy();
|
||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
||||
|
||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByText('暂无已上传项目');
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
test('用户列与素材查询同口径展示昵称、陶泥号和用户详情入口', async () => {
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const row = (await screen.findByText('三消工程')).closest('tr')!;
|
||||
expect(within(row).getByText('陶泥作者')).toBeTruthy();
|
||||
expect(within(row).getByText('SY-00000007')).toBeTruthy();
|
||||
expect(
|
||||
within(row).getByRole('button', { name: '查看用户信息' }),
|
||||
).toBeTruthy();
|
||||
expect(within(row).queryByText('user-1')).toBeNull();
|
||||
});
|
||||
|
||||
test('默认查询本部署渠道,切换渠道后从第一页按该渠道重新查询', async () => {
|
||||
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
|
||||
defaultChannel: 'release',
|
||||
channels: ['dev', 'release'],
|
||||
});
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, channel: 'dev', projectName: 'dev 工程' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'release' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
const channelSelect = screen.getByLabelText('项目工程渠道');
|
||||
expect((channelSelect as HTMLSelectElement).value).toBe('release');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第 2 页,本页 1 个项目');
|
||||
fireEvent.change(channelSelect, { target: { value: 'dev' } });
|
||||
await screen.findByText('dev 工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(screen.getByText('第 1 页,本页 1 个项目')).toBeTruthy();
|
||||
});
|
||||
|
||||
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',
|
||||
'dev',
|
||||
'user-1',
|
||||
'project-1',
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:archive');
|
||||
});
|
||||
|
||||
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
||||
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
||||
(_token, _channel, _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]?.[4]?.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}
|
||||
/>,
|
||||
);
|
||||
// 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。
|
||||
await waitFor(() =>
|
||||
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
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]?.[4];
|
||||
view.unmount();
|
||||
expect(signal?.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
finishDownload({ blob: new Blob(['PK']), filename: 'old.zip' });
|
||||
});
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,412 +0,0 @@
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
RefreshCcw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
downloadAdminProjectSnapshot,
|
||||
getAdminProjectSnapshotChannels,
|
||||
listAdminProjectSnapshots,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||
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: '下载已存文件',
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const PAGE_SIZE_OPTIONS = [20, 50, 100];
|
||||
|
||||
export function AdminProjectSnapshotsPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminProjectSnapshotsPageProps) {
|
||||
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
// null 表示渠道还没确定:先读完可选渠道再发列表请求,避免用错渠道白跑一次。
|
||||
const [channel, setChannel] = useState<string | null>(null);
|
||||
const [channelOptions, setChannelOptions] = useState<string[]>([]);
|
||||
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);
|
||||
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
|
||||
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
|
||||
const pageCursors = useRef<(string | null)[]>([null]);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (cursor: string | null, limit: number, page: number) => {
|
||||
listController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
listController.current = controller;
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await listAdminProjectSnapshots(
|
||||
token,
|
||||
{ cursor, limit, channel },
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setItems(response.items);
|
||||
setNextCursor(response.nextCursor);
|
||||
setPageIndex(page);
|
||||
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, channel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 换令牌或首次进入时取一次可选渠道;已选渠道保持不变,只在还没选时落到本部署渠道。
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await getAdminProjectSnapshotChannels(
|
||||
token,
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setChannelOptions(response.channels);
|
||||
setChannel((current) => current ?? response.defaultChannel);
|
||||
} catch (error: unknown) {
|
||||
if (controller.signal.aborted) return;
|
||||
// 渠道列表失败不阻塞查询:不带渠道按本部署渠道查询,并提示失败原因。
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
setChannel((current) => current ?? '');
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [token, onUnauthorized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (channel === null) return undefined;
|
||||
pageCursors.current = [null];
|
||||
setItems([]);
|
||||
setNextCursor(null);
|
||||
setPageIndex(1);
|
||||
setHasLoaded(false);
|
||||
setDownloadingKey(null);
|
||||
void loadPage(null, pageSize, 1);
|
||||
return () => {
|
||||
listController.current?.abort();
|
||||
listController.current = null;
|
||||
downloadController.current?.abort();
|
||||
downloadController.current = null;
|
||||
};
|
||||
}, [loadPage, pageSize, channel]);
|
||||
|
||||
function goToNextPage() {
|
||||
if (!nextCursor) return;
|
||||
pageCursors.current[pageIndex] = nextCursor;
|
||||
void loadPage(nextCursor, pageSize, pageIndex + 1);
|
||||
}
|
||||
|
||||
function goToPreviousPage() {
|
||||
if (pageIndex <= 1) return;
|
||||
void loadPage(
|
||||
pageCursors.current[pageIndex - 2] ?? null,
|
||||
pageSize,
|
||||
pageIndex - 1,
|
||||
);
|
||||
}
|
||||
|
||||
function refreshCurrentPage() {
|
||||
void loadPage(
|
||||
pageCursors.current[pageIndex - 1] ?? null,
|
||||
pageSize,
|
||||
pageIndex,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
channel ?? '',
|
||||
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);
|
||||
}
|
||||
|
||||
// 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。
|
||||
const visibleChannelOptions = channelOptions.length
|
||||
? channelOptions
|
||||
: channel
|
||||
? [channel]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<h2>项目工程</h2>
|
||||
<div className="admin-action-row">
|
||||
<label className="admin-field admin-field-compact">
|
||||
<span>渠道</span>
|
||||
<select
|
||||
aria-label="项目工程渠道"
|
||||
value={channel ?? ''}
|
||||
onChange={(event) => setChannel(event.target.value)}
|
||||
>
|
||||
{visibleChannelOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshCurrentPage}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '加载中' : '刷新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</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>用户</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="用户">
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
{projectOwnerDisplayName(entry)}
|
||||
<small>
|
||||
{entry.authorPublicUserCode?.trim() || '-'}
|
||||
</small>
|
||||
</div>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.userId}
|
||||
publicUserCode={entry.authorPublicUserCode}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</div>
|
||||
</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}
|
||||
{hasLoaded ? (
|
||||
<nav
|
||||
className="admin-action-row admin-project-snapshot-pagination"
|
||||
aria-label="项目工程分页"
|
||||
>
|
||||
<span className="admin-project-snapshot-pagination-info">
|
||||
第 {pageIndex} 页
|
||||
{items.length ? `,本页 ${items.length} 个项目` : ''}
|
||||
</span>
|
||||
<div className="admin-action-row">
|
||||
<label className="admin-field admin-field-compact">
|
||||
<span>每页</span>
|
||||
<select
|
||||
aria-label="每页条数"
|
||||
value={pageSize}
|
||||
onChange={(event) => setPageSize(Number(event.target.value))}
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
aria-label="上一页"
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading || pageIndex <= 1}
|
||||
title="上一页"
|
||||
type="button"
|
||||
onClick={goToPreviousPage}
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
<span>上一页</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="下一页"
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading || !nextCursor}
|
||||
title="下一页"
|
||||
type="button"
|
||||
onClick={goToNextPage}
|
||||
>
|
||||
<span>下一页</span>
|
||||
<ChevronRight size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) {
|
||||
return (
|
||||
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
|
||||
);
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
attachCoverFiles,
|
||||
buildTemplateImportFormData,
|
||||
deriveTemplateUploadRow,
|
||||
parseTemplateTags,
|
||||
sanitizeTemplateId,
|
||||
TEMPLATE_IMPORT_MAX_BATCH,
|
||||
type TemplateUploadRow,
|
||||
validateTemplateUploadRows,
|
||||
} from './adminAgcTemplateUploadModel';
|
||||
|
||||
function zipFile(name: string) {
|
||||
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
|
||||
type: 'application/zip',
|
||||
});
|
||||
}
|
||||
|
||||
function coverFile(name: string) {
|
||||
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
|
||||
type: 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
function row(zipName: string, patch: Partial<TemplateUploadRow> = {}) {
|
||||
return { ...deriveTemplateUploadRow(zipFile(zipName)), ...patch };
|
||||
}
|
||||
|
||||
describe('sanitizeTemplateId', () => {
|
||||
it('lowercases, keeps whitelisted characters and drops traversal', () => {
|
||||
expect(sanitizeTemplateId('Cocos Empty 2D.zip')).toBe('cocos-empty-2d');
|
||||
expect(sanitizeTemplateId('../../evil.zip')).toBe('evil');
|
||||
expect(sanitizeTemplateId('a..b.zip')).toBe('a.b');
|
||||
expect(sanitizeTemplateId('__hidden__')).toBe('hidden__');
|
||||
expect(sanitizeTemplateId(`${'x'.repeat(90)}.zip`)).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveTemplateUploadRow', () => {
|
||||
it('starts from safe defaults so a batch only needs covers attached', () => {
|
||||
const derived = row('my-template.zip');
|
||||
expect(derived.id).toBe('my-template');
|
||||
expect(derived.title).toBe('my-template');
|
||||
expect(derived.runtime).toBe('html');
|
||||
expect(derived.templateVersion).toBe('0.1.0');
|
||||
expect(derived.entry).toBe('index.html');
|
||||
expect(derived.coverFile).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachCoverFiles', () => {
|
||||
it('matches covers by template id and ignores non-image files', () => {
|
||||
const rows = [row('alpha.zip'), row('beta.zip')];
|
||||
const covers = [
|
||||
coverFile('alpha.png'),
|
||||
coverFile('beta.webp'),
|
||||
coverFile('notes.txt'),
|
||||
];
|
||||
|
||||
const next = attachCoverFiles(rows, covers);
|
||||
|
||||
expect(next[0]?.coverFile?.name).toBe('alpha.png');
|
||||
expect(next[1]?.coverFile?.name).toBe('beta.webp');
|
||||
});
|
||||
|
||||
it('keeps an already matched cover when the new selection has no partner', () => {
|
||||
const rows = [row('alpha.zip', { coverFile: coverFile('alpha.png') })];
|
||||
const next = attachCoverFiles(rows, [coverFile('other.png')]);
|
||||
expect(next[0]?.coverFile?.name).toBe('alpha.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTemplateUploadRows', () => {
|
||||
it('accepts a complete batch', () => {
|
||||
const rows = [
|
||||
row('alpha.zip', { coverFile: coverFile('alpha.png') }),
|
||||
row('beta.zip', { coverFile: coverFile('beta.png'), runtime: 'cocos' }),
|
||||
];
|
||||
expect(validateTemplateUploadRows(rows)).toEqual({});
|
||||
});
|
||||
|
||||
it('reports missing cover, duplicate id and invalid fields per row', () => {
|
||||
const rows = [
|
||||
row('alpha.zip'),
|
||||
row('alpha.zip', { coverFile: coverFile('alpha.png'), key: 'second' }),
|
||||
row('gamma.zip', {
|
||||
coverFile: coverFile('gamma.png'),
|
||||
runtime: 'docker',
|
||||
templateVersion: 'Bad Version',
|
||||
entry: '../escape.js',
|
||||
title: ' ',
|
||||
}),
|
||||
];
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
expect(errors['alpha.zip']).toContain('缺少封面');
|
||||
expect(errors.second).toContain('ID 在本批次内重复');
|
||||
expect(errors['gamma.zip']).toContain('名称必须是 1-80 个字符');
|
||||
});
|
||||
|
||||
it('rejects a batch larger than the server limit', () => {
|
||||
const rows = Array.from(
|
||||
{ length: TEMPLATE_IMPORT_MAX_BATCH + 1 },
|
||||
(_, index) =>
|
||||
row(`template-${index}.zip`, {
|
||||
coverFile: coverFile(`template-${index}.png`),
|
||||
}),
|
||||
);
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
expect(Object.keys(errors)).toHaveLength(TEMPLATE_IMPORT_MAX_BATCH + 1);
|
||||
expect(errors['template-0.zip']).toContain('单批最多上传');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTemplateImportFormData', () => {
|
||||
it('writes manifest field names and attaches zip / cover per row', () => {
|
||||
const rows = [
|
||||
row('alpha.zip', {
|
||||
coverFile: coverFile('alpha.png'),
|
||||
tags: '起步, 起步 2d',
|
||||
title: ' Alpha ',
|
||||
}),
|
||||
row('beta.zip', { coverFile: coverFile('beta.png') }),
|
||||
];
|
||||
|
||||
const form = buildTemplateImportFormData('a'.repeat(64), rows);
|
||||
const manifest = JSON.parse(String(form.get('manifest')));
|
||||
|
||||
expect(manifest.expectedRevision).toBe('a'.repeat(64));
|
||||
expect(manifest.templates).toHaveLength(2);
|
||||
expect(manifest.templates[0]).toMatchObject({
|
||||
id: 'alpha',
|
||||
title: 'Alpha',
|
||||
tags: ['起步', '2d'],
|
||||
zipField: 'zip_0',
|
||||
coverField: 'cover_0',
|
||||
});
|
||||
expect(manifest.templates[1]).toMatchObject({
|
||||
id: 'beta',
|
||||
zipField: 'zip_1',
|
||||
coverField: 'cover_1',
|
||||
});
|
||||
expect((form.get('zip_0') as File).name).toBe('alpha.zip');
|
||||
expect((form.get('cover_1') as File).name).toBe('beta.png');
|
||||
});
|
||||
|
||||
it('parses tags with dedupe and caps them at the server limit', () => {
|
||||
expect(parseTemplateTags(' a, a,b c ')).toEqual(['a', 'b', 'c']);
|
||||
const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join(
|
||||
',',
|
||||
);
|
||||
expect(parseTemplateTags(many)).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user