Compare commits

..

68 Commits

Author SHA1 Message Date
k88936 47c07f9d12 迁移节点变换交互测试接口
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m8s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m24s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m34s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m39s
Project CI / Frontend tests (pull_request) Successful in 5m56s
Project CI / AI game creator shell web tests (pull_request) Successful in 8m34s
Project CI / Repository checks (pull_request) Successful in 15m18s
Project CI / Native shell tests (pull_request) Successful in 22m28s
Project CI / Backend tests (pull_request) Successful in 27m38s
将旧单树测试夹具改为 trees 与 logicalSizes 参数

为拖拽和缩放入口补充所属 treeId 并更新树移除语义
2026-09-18 22:33:21 +08:00
k88936 0904184412 校正预览上下文菜单测试
覆盖上下文菜单回调的可选树标识参数

区分编辑器树目标与最终预览的无树标识回调
2026-09-18 22:18:58 +08:00
k88936 aaa2eb482a 更新最终预览框线测试语义
无框线场景不再传入选中节点以匹配选中态高亮规则

保留显式 showFrame 时的选中框与控制点断言
2026-09-18 22:08:53 +08:00
k88936 1679e72deb 修正删除快捷键页面测试
从仍挂载的窗口目标触发全局删除快捷键

按全部同名节点消失断言删除结果
2026-09-18 22:05:16 +08:00
k88936 4844543061 补齐检查提示动画测试名称
让动画 mock 匹配仅重启提示动画的现役筛选逻辑

保留非提示动画不被重启的生产行为
2026-09-18 22:00:58 +08:00
k88936 99132c9fa7 补齐 UI 状态持久化测试偏移字段
让当前 schema 夹具继续验证原有业务约束
2026-09-18 21:41:55 +08:00
k88936 944d2aa3db 更新 UI 编辑器返回交互测试
按现役浮动关闭按钮的可访问名称查询
2026-09-18 21:14:57 +08:00
k88936 7c16a35fda 兼容缺少偏移量的预览运行数据
校验 offset 前安全处理旧状态和测试夹具
2026-09-18 20:14:24 +08:00
k88936 e4369cd82e Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 5m0s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m54s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 7m15s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 6m32s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m56s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m21s
Project CI / Frontend tests (pull_request) Failing after 4m23s
Project CI / Repository checks (pull_request) Failing after 5m22s
Project CI / Native shell tests (pull_request) Successful in 9m10s
Project CI / Backend tests (pull_request) Successful in 9m41s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m32s
# Conflicts:
#	apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
#	docs/project-memory/shared-memory/decision-log.md
2026-09-18 18:44:11 +08:00
k88936 54b9bd4354 同步节点偏移量 TypeScript 绑定
更新 ts-rs 生成的 NodeOffset 类型文件
2026-09-18 17:30:53 +08:00
k88936 365e530b5f 修正测试偏移类型导入
从公开 offset 模块导入 NodeOffset
2026-09-18 17:23:06 +08:00
k88936 d46cdfb282 抽取保存结果弹窗操作底栏
统一关闭与重试按钮的布局和样式
2026-09-18 17:17:45 +08:00
k88936 2142027f85 简化保存结果弹窗内容分支
用显式早返回替换嵌套三元渲染
2026-09-18 17:15:50 +08:00
k88936 8fdf39f37e 清理预览初始适配无效依赖
移除仅通过 ref 读取的画布尺寸依赖
2026-09-18 17:14:16 +08:00
k88936 70c65c37d5 保证拖拽释放提交最新偏移
从指针释放坐标直接计算树位置
2026-09-18 17:12:30 +08:00
k88936 e72637789c 校验预览树偏移坐标
仅将有限偏移量纳入画布布局
2026-09-18 17:11:23 +08:00
k88936 0a102ea1c3 合并预览节点类型导入
复用单一 Node 类型导入声明
2026-09-18 17:10:04 +08:00
k88936 1ab7fa9991 校验预览画布像素比例
避免无效 pixels_per_unit 产生无限尺寸
2026-09-18 17:09:17 +08:00
k88936 6d02a0c29f 移除无读取的代码生成错误状态
删除失效的 generateError 状态及写入路径
2026-09-18 17:04:10 +08:00
k88936 a6c3db60fb 收窄编辑器操作生命周期接口
移除未被调用的 setRunning 与 reset 暴露
2026-09-18 16:57:42 +08:00
k88936 c921ae3b16 简化编辑器概览分支渲染
用显式条件替换嵌套三元表达式
2026-09-18 16:52:37 +08:00
k88936 31c084fce6 保留保存失败的底层错误信息
将实际异常传递到保存状态和重试提示
2026-09-18 16:48:53 +08:00
k88936 1e7a3f17ff 避免返回确认与失败弹窗重叠
返回路径保存失败时沿用确认弹窗错误状态
2026-09-18 16:45:43 +08:00
k88936 3ce2977e32 固定节点偏移量 TypeScript 元组导出
为二维数组字段补充 ts-rs 元组覆盖
2026-09-18 16:43:26 +08:00
k88936 f4e9414d63 限定状态提示动画重启动范围
仅重启动 UI 编辑器关注动画而不影响过渡效果
2026-09-18 16:41:12 +08:00
k88936 2d8b51885a 优化切分概览卡片布局
将窄侧栏概览调整为两列网格
2026-09-18 16:39:39 +08:00
k88936 91f534ed66 优化识别概览卡片布局
将窄侧栏概览调整为两列网格
2026-09-18 16:38:25 +08:00
k88936 f455d3edb2 清理未使用的界面图校验码
移除不会被发出的 missing-image 类型
2026-09-18 16:37:48 +08:00
k88936 bec225436f 简化状态节点轮询索引逻辑
用显式分支替换嵌套三元表达式
2026-09-18 16:35:25 +08:00
k88936 ce309a3b28 忽略未定义的节点元数据补丁
保留部分更新对未提供字段的原有语义
2026-09-18 16:33:37 +08:00
k88936 abd95a6231 消除复制路径未处理拒绝
复制失败仅保留界面错误状态
2026-09-18 16:31:04 +08:00
k88936 08c3b89b67 修复跨树节点选择同步界面图
选择节点时同步激活所属设计图
2026-09-18 16:28:49 +08:00
k88936 3371337346 修复树节点回调未使用参数
为跨树选择回调参数添加下划线前缀
2026-09-18 16:25:54 +08:00
k88936 899a591cda 区分语义建议工具调用缺失错误
恢复缺失工具调用的独立日志阶段和提示
2026-09-18 16:23:27 +08:00
k88936 0988c4907a 区分识别工具调用缺失错误
恢复识别工具缺失的独立日志阶段和提示
2026-09-18 16:20:49 +08:00
k88936 d370648867 区分合并工具调用缺失错误
恢复缺失工具调用的独立日志阶段和提示
2026-09-18 16:13:58 +08:00
k88936 4f4fb0873c 修复切分命令测试节点构造
补齐 NodeOffset 默认值以恢复 Rust 测试构建
2026-09-18 16:09:03 +08:00
k88936 6b8a106414 修复合并命令测试节点构造
补齐 NodeOffset 默认值以恢复 Rust 测试构建
2026-09-18 16:08:53 +08:00
k88936 55a8513de4 更新UI编辑器深模块架构文档
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 4m16s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 4m23s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 4m24s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 4m26s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m28s
Project CI / Frontend tests (pull_request) Failing after 3m30s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m33s
Project CI / Native shell tests (pull_request) Successful in 9m17s
记录状态迁移、几何、不变量与操作生命周期 seam

同步结构化 LLM 适配器边界与验证结果
2026-09-18 10:24:03 +08:00
k88936 d986dbaeb0 收敛UI编辑器结构化LLM动作适配器
统一必需 tool-call 定位与有界 JSON 参数解析

让语义建议、结构识别、组件绑定和多树合并复用稳定机械

保留各操作的 prompt、schema、领域校验与 materializer
2026-09-18 10:23:35 +08:00
k88936 16c6b9198e 深化UI编辑器前端状态与几何模块
新增 React-free 语义状态迁移 seam

收敛 State 级节点几何与保存前不变量校验

统一四类异步编辑器操作生命周期

补充状态迁移和不变量测试
2026-09-18 10:23:04 +08:00
k88936 4df4708ba1 Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 3m0s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 3m4s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 3m7s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 3m9s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m41s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m32s
Project CI / Frontend tests (pull_request) Failing after 4m47s
Project CI / Repository checks (pull_request) Failing after 4m39s
Project CI / Native shell tests (pull_request) Successful in 7m14s
Project CI / Backend tests (pull_request) Successful in 8m12s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m54s
# Conflicts:
#	docs/project-memory/shared-memory/pitfalls.md
2026-09-16 14:57:26 +08:00
k88936 1e992bcdf8 扩大未知 JavaScript 异常上报原则
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
明确所有未解决和非契约化 JS 异常必须继续抛出

说明用户 fallback 不得替代全局 error report 链路
2026-09-14 19:47:08 +08:00
k88936 8368aa262c 补充剪贴板异常上报约定
记录 UI fallback 后仍需抛出原始原生错误

明确未知异常必须进入 error report 链路
2026-09-14 19:43:08 +08:00
k88936 e9ddbf16da 重构预览网格步长计算
提取预览网格自适应步长到独立模块

补充缩放网格计算与背景样式测试

同步预览画布缩放交互文档
2026-09-14 19:40:13 +08:00
k88936 22ac1f4c0b 修复 UI 编辑器剪贴板写入权限
为主窗口补充 clipboard-manager 写文本权限

保留手动复制提示并继续抛出原始 Tauri 剪贴板错误
2026-09-14 19:39:34 +08:00
k88936 9910a0eec0 优化 UI 编辑器复制反馈布局
将复制失败提示与操作按钮分离排列
2026-09-14 19:06:15 +08:00
k88936 70981b9ca9 完善 UI 编辑器结果弹窗覆盖
增加普通保存成功与保存后生成失败的场景断言

验证路径弹窗和失败重试按钮的可见行为
2026-09-14 19:01:44 +08:00
k88936 8a0d5600b3 拆分 UI 编辑器路径复制按钮
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m27s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m23s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m5s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m8s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m15s
Project CI / Frontend tests (pull_request) Failing after 3m22s
Project CI / Repository checks (pull_request) Successful in 3m38s
Project CI / Native shell tests (pull_request) Successful in 6m42s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m12s
Project CI / Backend tests (pull_request) Successful in 8m24s
将剪贴板状态与写入逻辑提取为独立小组件

保持保存结果弹窗只负责结果布局与动作编排
2026-09-14 18:59:51 +08:00
k88936 184d88dbb8 修复多树预览受左侧选中图片限制
移除 activeImageId 对预览拖动和缩放手势的门控

按树 wrapper 提供几何上下文并允许任意树操作

保留 activeImageId 仅用于左侧图片与图片级检查
2026-09-14 18:59:51 +08:00
k88936 791794c0a6 记录 UI 编辑器保存结果弹窗约定
补充保存与代码生成结果弹窗及相对路径复制行为

记录复制失败、重试和保存并返回边界
2026-09-14 18:59:51 +08:00
k88936 9cfb47d945 补充 UI 编辑器保存结果测试
覆盖保存和生成成功弹窗与项目相对路径复制

覆盖保存失败、冲突和剪贴板失败反馈
2026-09-14 18:59:51 +08:00
k88936 27859d3e19 接入 UI 编辑器保存结果弹窗
让普通保存和保存并生成流程返回结构化结果

将成功与失败反馈接入独立弹窗并保留保存并返回直返行为
2026-09-14 18:59:51 +08:00
k88936 43f3780a38 新增 UI 编辑器保存结果弹窗
新增保存成功、代码生成成功和失败状态弹窗

提供项目相对路径复制按钮及复制失败提示
2026-09-14 18:59:51 +08:00
k88936 a1f9149c27 UI编辑器移除预览模式残留类型
完成三开关替代 render mode 的类型清理
2026-09-14 18:59:51 +08:00
k88936 f0bba18841 UI编辑器收口多树上下文菜单目标
预览菜单携带所属树 ID

避免非当前图片树的结构操作写错目标
2026-09-14 18:59:51 +08:00
k88936 f679cfa179 UI编辑器完善多树创建与文档契约
统一识别和合并结果的 createTree 流程

按选中节点所属树驱动 Inspector 编辑

补充多树预览与树级偏移权威文档
2026-09-14 18:59:50 +08:00
k88936 f68ffcfecd UI编辑器补充合并树偏移构造
为合并产出的节点补齐 NodeOffset
2026-09-14 18:59:50 +08:00
k88936 ec94a4fe00 UI编辑器重构多树预览与独立开关
预览画布同时渲染全部界面树并支持树级拖动

拆分显示框线、原图和组件开关

预览节点选择与左侧当前图片解耦
2026-09-14 18:59:50 +08:00
k88936 46f8592375 UI编辑器补充树级偏移模型
新增 Node offset min/max 数据结构

统一新树默认横向布局入口

增加树级偏移状态更新操作与实施计划
2026-09-14 18:59:50 +08:00
k88936 cdf2882302 补齐自动切分概览字体属性
向 SeparationOverview 传入 fonts 数据

修复 UI 编辑器 TS2741 类型错误
2026-09-14 18:59:50 +08:00
k88936 d156113e71 修复 UI 编辑器待办循环高亮动画
修复受控树选中回调清除 Inspector 高亮的问题

循环游标同时使用界面树与节点 ID

补充多节点循环和动画重启回归测试
2026-09-14 18:59:50 +08:00
k88936 a8f2ac17be 注释掉 InspectorSidebar 中的 LayoutEditor 组件以暂时禁用布局编辑功能 2026-09-14 18:59:50 +08:00
k88936 43ac9a5761 补充 UI 编辑器交互回归测试
从实际节点事件目标验证删除快捷键

验证 portal 右键菜单启用后会执行操作并关闭
2026-09-14 18:59:50 +08:00
k88936 e992e0b35c 压缩 UI 编辑器概览布局
将识别概览指标改为四列横向排列

将素材切分概览改为四列网格以减少纵向高度
2026-09-14 18:59:50 +08:00
k88936 e389b54a3a 修复 UI 预览画布交互
为无限视口添加跟随平移和缩放的点阵背景

恢复画布快捷缩放与初始适配比例控制

保留节点平移选择并屏蔽浏览器原生拖拽选择
2026-09-14 18:59:50 +08:00
k88936 84c780a6f8 重构 UI 编辑器全屏外壳
通过 portal 将资源编辑器挂载到 body 并覆盖标题栏以下区域

移除外层 FocusTrap,保留内部确认对话框的模态行为

统一左右浮动面板的顶部与底部留白
2026-09-14 18:59:50 +08:00
k88936 03f900fbe5 布局重构:
- 预览改为全区域背景层。
  - 左右面板、顶部流程条改为覆盖层,可遮挡预览。
  - 左右面板开合状态仅保存在当前 React 会话。
  - 增加左右面板“收起/展开”入口。
  - 编辑器关闭入口改为单独的 ×,继续通过 onBack 注入关闭逻辑。
  - 保留全局顶部栏与现有快捷键、业务状态、保存流程。
  - 底层页面仍由现有父级路由控制,不新增 URL 状态。
2026-09-14 18:59:50 +08:00
878 changed files with 25811 additions and 108574 deletions
-2
View File
@@ -4,5 +4,3 @@
src/components/AdventurePanel.tsx
src/data/customWorldCharacterLoadout.ts
dist_check_monster_position/**
# 固定上游UTF-8测试刻意包含U+FFFDupstream-integrity.test.mjs逐字节验证来源hash。
apps/ai-game-creator-shell/src-tauri/vendor/codex-utils-path-uri/src/api_path_string_tests.rs
-4
View File
@@ -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.
-3
View File
@@ -23,6 +23,3 @@
*.meta text
*.anim text
*.controller text
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
packages/shared/src/contracts/generated/** linguist-generated=true
+78 -51
View File
@@ -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
-8
View File
@@ -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
View File
@@ -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"
}
+19 -16
View File
@@ -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` facadeHTTP/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(
-108
View File
@@ -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 },
);
}
-55
View File
@@ -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' });
});
-14
View File
@@ -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>
);
}
-3
View File
@@ -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);
});
-4
View File
@@ -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
View File
@@ -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('名称须为 180 个字符')).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