Compare commits

...

43 Commits

Author SHA1 Message Date
lhk229 cb60632c99 Merge branch 'master' into opt/compile-warning
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 5m9s
Project CI / Backend tests (pull_request) Successful in 4m46s
Project CI / Frontend tests (pull_request) Successful in 1m55s
Project CI / Native shell tests (pull_request) Successful in 5m58s
Project CI / Repository checks (pull_request) Successful in 2m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m30s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m41s
2026-09-24 02:09:50 +08:00
lhk229 dd7c445c8a 记录五条 ts-rs 提示的保留决定
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 / AI game creator shell Rust crates (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 lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
明确四个未知字段属性提示对应的类型
记录类型核查结论及暂不引入依赖补丁的决定
2026-09-23 18:08:14 +00:00
lhk229 7d6fff577d 清理旧策划修订写入入口并记录认证契约冲突
删除无生产调用的用户修订写入函数,保留持久记录兼容与门禁
调整原有门禁测试的历史记录夹具,保持行为断言
将 OAuth 契约冲突与关闭条件记录到 AGC 主方案
警告清单仅保留 AuthBridge 暂缓状态及主方案链接
2026-09-23 17:58:17 +00:00
lhk229 c62a911745 收紧平台与编辑器编译范围并分离构建校验
分离 Codex 构建端元数据校验并保留原有测试
限定 Linux 沙箱与进程清理入口,删除无调用的平台占位实现
按实际 feature 收紧编辑器注册并删除冗余宿主包装
保留 Runner 备用端口算法的 Linux 与测试编译入口
同步警告清单、插件契约及定向验证边界
2026-09-23 17:10:39 +00:00
lhk229 b39cd555ae 清理九条旧包装与失效门禁编译警告
删除无调用的素材生成、Goal 与用户修订查询包装
清理恒假视觉门禁、专属提示词及不可达等待分支
移除 manifest 初始化未用参数并更新全部调用
以循环返回值替代 Direct 回合的冗余初始化,保持重试语义
同步现行行为文档、警告处理记录及未编译验证边界
2026-09-23 16:41:43 +00:00
lhk229 00984be228 清理退役策划链路残留并统一文档口径
删除旧策划生命周期常量与无构造的恢复分支
合并无用锁参数透传,保留现役持锁范围和项目锁归属校验
移除旧身份参数与哨兵包装,保留通用工具规则及项目过滤
明确策划V1与V2均已退役,修正文档与共享记忆
删除四份过期退役计划并更新六条编译警告处理记录
2026-09-23 16:28:02 +00:00
kdletters 87e52860a7 游戏发行入口改为平台同源路径
Project CI / AI game creator shell Rust crates (push) Successful in 1m26s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m11s
Project CI / Backend tests (push) Successful in 5m12s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m26s
Project CI / Native shell tests (push) Successful in 6m31s
Project CI / Frontend tests (push) Successful in 2m20s
Project CI / Repository checks (push) Successful in 2m25s
Project CI / AI game creator shell web tests (push) Successful in 1m21s
- 审核通过时由 api-server 按 gameId 派生 /games/{gameId}/ 相对路径写入公开投影,删除 AppConfig 的发行入口模板字段与读取逻辑
- 删除 deploy/env 两份示例中的 GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE
- 三份 nginx 模板内联同源发行入口 location,把 /games/<gameId>/ 与子资源转发到发行网关并在边缘清空 Cookie
- SPA allowlist 补齐 components、design-system、games、games/detail、games/mine、games/play、games/publish
- 前端 normalizeGameEntryUrl 支持相对路径与同源发行路径,按当前 origin 解析并补尾斜杠,继续兼容历史绝对 URL
- 删除退役的独立来源模板 deploy/nginx/genarrative-release-origin.conf、门禁脚本 scripts/check-release-origin-config.mjs 与其 npm 脚本
- 游戏分发 e2e 脚本改为在公开投影上断言 entryUrl 等于 /games/{gameId}/
- 同步平台主规范、运维主规范、nginx README 与共享决策记录
2026-09-24 00:21:27 +08:00
kdletters efbdf7031e 统一客户端维护态错误弹窗
Project CI / AI game creator shell Rust crates (push) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / Backend tests (push) Successful in 3m54s
Project CI / Frontend tests (push) Successful in 2m5s
Project CI / Native shell tests (push) Successful in 6m4s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m14s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m19s
Project CI / AI game creator shell web tests (push) Successful in 1m23s
Project CI / Repository checks (push) Successful in 1m55s
客户端维护响应统一识别并广播维护事件

AGC 与网页端根部展示维护大弹窗,收口发布、上传、资源换签和登录错误

补充维护态弹窗测试、类型检查与实施计划说明
2026-09-23 23:16:23 +08:00
lhk229 df06ee003a 收紧测试与平台编译边界并清理冗余测试包装
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m10s
Project CI / Backend tests (pull_request) Failing after 17s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m41s
Project CI / Frontend tests (pull_request) Successful in 2m4s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s
Project CI / Native shell tests (pull_request) Successful in 5m22s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m20s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m37s
将十五条重导出诊断对应的测试出口限定编译范围并删除无消费者导出
将Provider交接故障注入结果及传递分支限定为测试编译
删除计数快照和旧回复流ready包装,保留有效测试断言
将项目目录句柄方法限定为Unix平台
更新二十条警告的处理记录,注明未重新编译验证数量
2026-09-23 14:52:14 +00:00
kdletters 093f832ef9 优化后台游戏审核操作弹窗
Project CI / AI game creator shell Rust crates (push) Successful in 1m29s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m2s
Project CI / Backend tests (push) Successful in 3m48s
Project CI / Frontend tests (push) Successful in 2m4s
Project CI / Native shell tests (push) Successful in 5m57s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m15s
Project CI / Repository checks (push) Successful in 2m10s
Project CI / AI game creator shell web tests (push) Successful in 1m35s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m38s
移除游戏审核页的发行地址提示

将拒绝理由与下架原因改为点击按钮后输入

补充审核与安全下架交互测试
2026-09-23 22:50:36 +08:00
lhk229 761cccbbf1 Merge branch 'master' into opt/compile-warning
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m22s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / Backend tests (pull_request) Successful in 4m1s
Project CI / Frontend tests (pull_request) Successful in 2m10s
Project CI / Native shell tests (pull_request) Successful in 6m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m4s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m20s
Project CI / Repository checks (pull_request) Successful in 1m52s
2026-09-23 22:36:36 +08:00
kdletters cc958f3678 后台新增游戏管理页与全量游戏列表接口
Project CI / AI game creator shell Rust crates (push) Successful in 1m23s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m0s
Project CI / Backend tests (push) Successful in 5m45s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m51s
Project CI / Native shell tests (push) Successful in 7m25s
Project CI / Frontend tests (push) Successful in 2m33s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m6s
Project CI / Repository checks (push) Successful in 2m1s
Project CI / AI game creator shell web tests (push) Successful in 1m32s
- SpacetimeDB 新增 list_admin_game_distribution_games_and_return:一个事务内返回全量游戏、版本数与最近 20 个版本,作者名/头像读时联 user_account
- SpacetimeDB 新增 restore_game_distribution_game_and_return:恢复管理员暂停的游戏时重新激活最近一次由管理员暂停撤回的公开版本,作者自行下架的版本不会被恢复
- 后台新增 GET /admin/api/game-distribution/games 与 POST /admin/api/game-distribution/games/{gameId}/restore,并新增 game-management Tab 权限,待审队列仍只属于 editor-showcase
- admin-web 新增 #game-management「游戏管理」页:标题/作者/gameId/状态/版本数/游玩数表格,行内下架、恢复与版本历史弹层,复用现有后台表格与二次确认
- 同步生成 spacetime 绑定并更新后端架构数据契约文档
2026-09-23 21:46:40 +08:00
kdletters cf19241544 游戏发行入口改为服务端按模板派生
- 后台审核页删除「发行入口」输入框与前端 HTTPS 校验,审核请求只提交结论与公开修订号
- api-server 新增 GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE 模板配置,审核通过时读版本取 gameId 并派生该游戏独立来源地址
- 模板缺 {gameId}、生产未配置模板、gameId 非主机安全字符或派生结果非法时审核通过失败关闭,非生产未配置时回落本地回环发行网关
- 同步更新发行来源 nginx 模板与说明、两份 api-server 环境变量样例、平台与运维主规范、发行里程碑实施计划和决策记录
- 真实栈 e2e 脚本不再传 entryUrl,并断言审核结果里的入口为服务端派生值
2026-09-23 21:33:42 +08:00
lhk229 1a4af3506c 清理测试用途代码并减少七十条编译警告
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m10s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m26s
Project CI / Frontend tests (pull_request) Successful in 1m59s
Project CI / Repository checks (pull_request) Failing after 18s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m30s
Project CI / Native shell tests (pull_request) Successful in 5m38s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m38s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m23s
收紧测试辅助代码的编译范围并删除退役实现和冗余包装
逐项核对原测试断言,仅保留现役契约覆盖并调整必要调用
移除未消费的Pending错误载荷,保留持久错误记录和恢复验证
保留四条待澄清契约项,更新警告清单与实施文档
验证一百七十八个定向Rust测试,默认构建警告从一百二十条降至五十条
2026-09-23 13:29:28 +00:00
kdletters e0c324c0ac 修复游戏封面生成素材 ID 回传
Project CI / AI game creator shell Rust crates (push) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m2s
Project CI / Backend tests (push) Successful in 4m7s
Project CI / Frontend tests (push) Successful in 2m10s
Project CI / Native shell tests (push) Successful in 6m12s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m17s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m37s
Project CI / AI game creator shell web tests (push) Successful in 1m34s
Project CI / Repository checks (push) Successful in 2m1s
生成游戏封面请求补充 AGC 客户端来源标记,确保队列 worker 返回 result。

补充封面生成请求断言,锁定平台素材 ID 回填链路。

同步记录缺陷根因与修复约定。
2026-09-23 21:29:18 +08:00
kdletters 985df53a4e Router API Key 分组改为与账号同组的 taonier
Project CI / AI game creator shell Rust crates (push) Successful in 1m23s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / Backend tests (push) Successful in 5m40s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m43s
Project CI / Native shell tests (push) Successful in 7m21s
Project CI / Frontend tests (push) Successful in 2m40s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m45s
Project CI / Repository checks (push) Successful in 1m55s
Project CI / AI game creator shell web tests (push) Successful in 1m25s
- LLM_ROUTER_TOKEN_GROUP 由 default 改为 taonier,Router 用户与它名下固定 Token / API Key 现在同属 taonier 分组
- 保留固定契约归一:创建后无条件 PUT 完整契约(group / unlimited_quota / expired_time),复用与登录恢复路径同样 PUT,存量 default 分组 Token 会在下次 provisioning、登录恢复或显式准备 Key 时纠正
- 同步创建路径与复用路径的注释,保留“分组缺模型会失败为 model_not_found”的排查提示
- 用例同步:2 个用例重命名为 taonier 语义,6 处 group 断言改为 taonier
- 文档同步:外部 OpenAPI 与 APIKey 接入方案写明 Token 与用户同组 taonier 及其前提;决策记录追加 2026-09-23 调整说明
- 验证:cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server external_api_keys(22 passed)、同命令 router 过滤(27 passed)、cargo fmt --all --check、npm run check:encoding、npm run check:doc-index、git diff --check
2026-09-23 20:52:01 +08:00
kdletters 1e0aeb95b0 后台表查询枚举列按 schema 展示变体名,不再只看数值
- 新增按 SpacetimeDB schema 解析枚举展示名的实现:读取 typespace.types 与表的 product_type_ref,为每个「Sum 且变体全为单元变体」的列建立按变体索引排列的展示名,变体名归一到 snake_case
- 支持 Option<枚举> 列:标记 optional,[0, [索引, []]] 出变体名、[1, []] 仍是空值;Option<普通值> 与带载荷的 Sum 继续走通用解码,单变体枚举同样出名字
- 删除只覆盖 profile_recharge_order.kind / status 的硬编码映射 normalize_admin_database_known_enum
- 枚举展示名同时作用于行的 cells 与 raw,关键词搜索、结构化筛选和稳定排序都按展示名生效
- 表查询行解析与行构建改为接收枚举映射,schema 夹具用例覆盖直接枚举、Option 枚举、单变体枚举与未知表
- 同步开发运维文档的表查询口径段、后台 skill 的 SATS 展示参考与决策记录
- 验证:cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin(139 passed / 0 failed / 1 ignored);cargo check -p api-server;cargo fmt --all --check;npm run check:encoding;npm run check:doc-index;git diff --check;本地 dev schema 回放同一算法,85 张表 32 个直接枚举列 + 2 个 Option 枚举列全部解析出展示名、0 残留
2026-09-23 20:52:00 +08:00
suzmii 5b1fc59d63 Merge pull request '运行页签补齐全屏预览与可收起的信息栏' (#497) from feat/run-view-chrome-cleanup into master
Project CI / Backend tests (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Reviewed-on: #497
2026-09-23 20:50:42 +08:00
lhk229 e1b9008852 合并最新master到编译警告清理分支
Project CI / AI game creator shell Rust crates (pull_request) Failing after 1m18s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
Project CI / Backend tests (pull_request) Successful in 4m44s
Project CI / Native shell tests (pull_request) Successful in 5m47s
Project CI / Frontend tests (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m38s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m40s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m57s
Project CI / Repository checks (pull_request) Successful in 2m22s
同步远端master最新变更并保留编译警告清理成果
2026-09-23 12:41:36 +00:00
lhk229 36f1adac61 清理成组未使用实现并更新现役契约
删除旧Home用户对话入口、附件sidecar和无调用包装
保留现役附件输入、内部Home通道及重生成保护
更新过期契约与文档索引,记录30条警告清理和67项测试结果
2026-09-23 12:40:32 +00:00
lhk229 600beb452b 清理局部未使用代码并收紧测试编译边界
删除无消费者的包装、声明和重导出
将Direct执行测试入口限定为测试构建
记录86条复核与33条清理结果,验证85项回归
2026-09-23 12:18:32 +00:00
kdletters 1b05a1d05e 后台充值订单实付口径、发放泥点列、用户累计充值与兑换码单位
Project CI / AI game creator shell Rust smoke (push) Successful in 2m17s
Project CI / Backend tests (push) Successful in 4m59s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m29s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m40s
Project CI / Frontend tests (push) Successful in 1m59s
Project CI / AI game creator shell Rust crates (push) Successful in 9m16s
Project CI / AI game creator shell web tests (push) Successful in 1m41s
Project CI / Repository checks (push) Successful in 4m4s
Project CI / Native shell tests (push) Successful in 7m1s
- 新增 AdminRechargeOrderEntryPayload.paidAmountCents:只有 paid_at 存在的订单才有实付,未支付 / 已关闭 / 已过期固定为 0
- 充值管理列表把「金额 / 泥点」拆成「实付」「发放泥点」两列,未支付行实付显示「未支付」并附订单金额小字;退款面板「订单实付」改读同一字段
- 用户详情充值订单表新增「发放泥点」列,商品列只保留商品名,实付同样按 paidAmountCents 展示
- 用户详情新增 cumulativeRechargedCents:api-server 按 user_id 读取 profile_recharge_order,只累加 paid_at 存在的订单金额(退款不回减),单次上限 500 行,读取失败或命中上限返回 null
- 用户详情身份区新增「累计充值」,读不到时显示「未知」,不用用户详情最多 20 条订单在 BFF 或前端近似重算
- 兑换码奖励单位收口为泥点:输入标签与列表列头改「奖励泥点」,单元格带「泥点」单位,避免被当成元
- 新增后端 2 条累计充值口径单测与前端 2 条用例(未支付不显示实付且发放泥点独立成列、累计充值未知态)
- 同步后端架构数据契约(实付口径 / 累计充值来源与上限 / 兑换码奖励单位)与决策记录
- 验证:cargo check -p api-server;cargo test -p api-server --bin api-server admin(138 passed / 0 failed / 1 ignored);npm run admin-web:typecheck;npx vitest run apps/admin-web/src(220 passed);cargo fmt --all --check、check:encoding、check:doc-index、git diff --check 通过
2026-09-23 20:10:20 +08:00
kdletters 0dbd279dfc 后台 Dashboard 消耗泥点改为对冲退还后的净消耗
Project CI / AI game creator shell Rust crates (push) Successful in 1m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / Backend tests (push) Successful in 5m16s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m42s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m21s
Project CI / Native shell tests (push) Successful in 6m43s
Project CI / Frontend tests (push) Successful in 2m22s
Project CI / Repository checks (push) Successful in 2m45s
Project CI / AI game creator shell web tests (push) Successful in 2m7s
- consumedMudPoints 改为净额:按北京时间业务日先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的丢弃,每日净额非负且区间合计严格等于「消耗 − 退还」
- 退还口径并入 asset_operation_refund 正向流水(生成失败退还、精选审核返还)与 llm_router_consume 正向冲正流水;充值退款追回、余额重置、赠送和 hold 仍不计入
- 契约新增 refundedMudPoints,后端新增「退还泥点」趋势图,后台时段数据并列「退还泥点数」卡,毛消耗可由「消耗 + 退还」核出
- 新增 4 条净额单测(同日对冲 / 回溯抵扣 / 超额归零 / 无退还保原值)与 Dashboard 用例断言
- 同步 Dashboard 运营看板方案指标口径与决策记录
- 验证:cargo test -p api-server --bin api-server admin(136 passed / 0 failed / 1 ignored)、npm run admin-web:typecheck、npx vitest run apps/admin-web/src(218 passed)、cargo fmt --all --check、npm run check:encoding、npm run check:doc-index、git diff --check 全部通过
2026-09-23 19:56:24 +08:00
kdletters eb192eb161 修复渠道配置替换窗口契约导致的系统标题栏回归与登录请求被拒
- 渠道 --config 改为从基线 tauri.conf.json 读取完整 client 窗口对象后展开、只覆盖 title,避免 Tauri JSON Merge Patch 整体替换 app.windows 丢掉 label / decorations / 尺寸
- build-release.test.mjs 新增合并守卫用例:按同一 merge patch 语义复现 Tauri 合并,断言 label=client、decorations=false、1280x800、min 1280x720,且承载 http:default 的 capability 必须包含该 label
- check-config.mjs 增补基线 client 窗口 decorations 必须为 false 的门禁
- 更新 AGC 客户端更新检查与下载技术方案,写明渠道配置必须下发完整窗口对象的约定
- pitfalls.md 记录本次回归的现象、根因、现行口径与验证证据
2026-09-23 19:54:11 +08:00
suzmii c723e0b1bf 合并 master:标题栏弹层契约、发行包上限与策划回复修复等上游变更
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m51s
Project CI / Backend tests (pull_request) Successful in 4m57s
Project CI / Native shell tests (pull_request) Successful in 6m8s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m12s
Project CI / Frontend tests (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m14s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m21s
Project CI / Repository checks (pull_request) Successful in 1m45s
- 合并 origin/master(自绘标题栏作为窗口边框的弹层契约、游戏发行包上限提升到 200 MiB、策划回复重复播放修复 #501、标题栏品牌标签用例修正 f0ef06891 等)到本分支,保持 PR #497 可合并。
- 冲突仍只在 `docs/project-memory/shared-memory/decision-log.md` 顶部新增条目:按「新条目在上」保留双方内容,本分支的运行视窗条目在前。
- 上一轮合并记录的「`WindowChrome` 用例断言渠道产品名 stale」已由上游 f0ef06891 自行修复,本轮合并后该用例恢复通过。
- 上游改动未触及本分支的运行页 chrome 与预览适配文件;合并后重跑 `ai-game-creator-shell:check:web`、编码检查与 `git diff --check`。
2026-09-23 19:37:07 +08:00
lhk229 268f45015c 清理已退役的Direct审计与计时代码
删除旧平行审计和请求计时模块及沿途参数
保留代理透传、模型记录与现役历史链路测试
同步契约和验证记录,消除12条编译警告
2026-09-23 11:36:32 +00:00
suzmii 5e5407e4cf 合并 master:引用粘贴解析与渠道产品名等上游变更
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m21s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 45s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 1m58s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (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 lane 2/2 (pull_request) Has been cancelled
- 合并 origin/master(引用名归一化与粘贴解析 #473 系列、客户端渠道产品名统一 ebb288a6a、#500 等)到本分支,保持 PR #497 可合并。
- 冲突仍只在 `docs/project-memory/shared-memory/decision-log.md` 顶部新增条目:按「新条目在上」保留双方内容,本分支的运行视窗条目在前。
- 上游改动落在 `features/project-workspace` 的引用链路与渠道命名脚本上,未触及本分支的运行页 chrome 与预览适配文件。
- 合并后 `apps/ai-game-creator-shell:check:web` 有 1 条红:`tests/WindowChrome.test.tsx` 仍断言标题栏品牌名为 `陶泥儿 GameAgent`,而 ebb288a6a 把该处改成读取 `APP_NAME`(未注入 `VITE_AGC_PRODUCT_NAME` 时回退 `陶泥儿开发版`),测试未同步。已验证与本分支无关:把这两个文件的合并前版本换回来 5/5 通过,换回合入版本即 1 条红。等上游修,本分支不动。
2026-09-23 19:21:33 +08:00
lhk229 5a86a64e48 更新Direct审计计时退役契约
核查未使用代码的保留依据并更新警告清单
将旧平行审计方案归为历史并同步文档入口
明确项目历史、界面计时与模型使用记录的现役边界
2026-09-23 11:21:01 +00:00
lhk229 9bd4a1734c 修复策划回复重复播放并保留伪流式 (#501)
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / Backend tests (push) Successful in 5m7s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m8s
Project CI / Native shell tests (push) Successful in 6m15s
Project CI / Frontend tests (push) Successful in 2m12s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m27s
Project CI / AI game creator shell web tests (push) Successful in 1m39s
Project CI / Repository checks (push) Successful in 2m20s
按消息 ID 统一策划回复呈现,隔离旧回合收尾并补全忙碌态清理;同步回归测试与文档。已合并 origin/master 并通过定向验证。
2026-09-23 19:19:26 +08:00
lhk229 248003a445 合并主线更新并保留双方排障记录
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
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 / Native shell tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
合并 origin/master 的上传续传、标题栏弹窗及测试更新。

解决排障文档冲突,保留策划回复与标题栏问题记录。
2026-09-23 11:18:46 +00:00
kdletters f0ef068910 修正标题栏品牌标签用例对渠道产品名的断言
Project CI / AI game creator shell Rust smoke (push) Successful in 1m37s
Project CI / AI game creator shell Rust crates (push) Successful in 1m18s
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
- WindowChrome 用例改用 appMetadata 的 APP_NAME(dev 渠道为陶泥儿开发版),不再硬编码旧产品名
2026-09-23 19:07:37 +08:00
kdletters c2c5e1ced5 发行包上限提升到 200 MiB 并支持 AGC 分片续传上传
- 发行包上限 100→200 MiB、展开总量 250→500 MiB,整包路由请求体上限继续从包上限派生;发行静态资源进程内缓存预算提到 256 MiB
- 反代放行量同步放宽到 210 MiB:Nginx 三份模板的 client_max_body_size、Pingora 网关默认值与 env 样例、路由对照矩阵
- platform-oss 新增内部对象追加写 append_internal_object(_with_retry),以 OSS 返回的 next-append-position 作为权威已收字节
- api-server 新增 upload-state / chunk / complete / reset 四条分片路由,抽出共享收口 confirm_validated_package;偏移不符返回 409 与权威偏移,校验失败删除半包并落 upload_failed
- AGC 新增原生上传器 game_package_upload.rs(内容寻址暂存、分片续传、受控重试、进度事件)与 prepare / upload 两条命令,退役整包回传命令
- 渲染进程改为 prepare → 创建游戏 → 创建版本 → 原生分片上传 → 送审,LocalProjectExportPackagePayload 整包类型退役
- 同时修正 live 用例无法指向本地栈的两处基础设施问题:平台基址按传入 URL 选择,桥接层把 jsdom realm 的 Headers / Blob / FormData 降级成 Node 原生值
- 测试:platform-oss 74、api-server game_distribution 23、AGC 原生 4、发布相关前端 20;live 用例补真实栈「中断 → 续传 → 确认」断言(分片偏移序列 [0, 8388608])
- 文档:玩法创作主规范的上传合同、运维与 Pingora 文档、决策记录、发行里程碑口径,以及新增的续传里程碑与实施计划
2026-09-23 19:06:02 +08:00
kdletters c38d07044a 修复 AGC 弹窗打开时自绘标题栏窗口按钮失效
- ThemedModal 的焦点陷阱只放行落在 [data-window-chrome-bar] 内的点击,标题栏拖拽与最小化/最大化/关闭恢复可用,工作区内容点击仍被拦住
- WindowChrome 标题栏加 data-window-chrome-bar 标记,作为这条约定的唯一契约点
- styles.css 明确「全屏弹层一律从标题栏下方开始」,.app-update-overlay 从 inset:0 改为标题栏下方,.game-publish-progress-overlay 显式声明 top
- 新增 tests/windowChromeOverlayContract.test.ts 覆盖 7 个全屏弹层;themedModal / WindowChrome 用例补「标题栏点击放行 + 工作区点击仍被拦」回归;gamePublishFeedback 用例按新口径断言
- pitfalls 记录该静默失效的机制与现行口径
2026-09-23 19:05:33 +08:00
lhk229 efbfc8085d Merge branch 'master' into fix/design-agent-fe
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m19s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 57s
Project CI / Backend tests (pull_request) Successful in 5m11s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m33s
Project CI / Native shell tests (pull_request) Successful in 6m43s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m7s
Project CI / Frontend tests (pull_request) Successful in 3m1s
Project CI / Repository checks (pull_request) Successful in 3m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m37s
2026-09-23 19:04:31 +08:00
lhk229 2c778fb027 补全聊天作用域重置时的忙碌态清理
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
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 lane 1/2 (pull_request) Has been cancelled
重置聊天状态时同步清空 chatAgentBusy,保留旧请求的回合归属检查。

同步更新策划会话的作用域清理约定。
2026-09-23 11:03:03 +00:00
lhk229 018697203f 清理低风险编译警告并修复构建检查脚本
清理冗余导入、可变绑定和赋值,将 AGC 默认编译警告从 231 条降至 195 条
按测试和平台边界限定导入,保留兼容出口与待核查的业务参数
修复预览部署器嵌套 npm 警告和移动端检查脚本的 Windows 启动错误
更新警告清单、开发运维文档与共享开发流程,记录验证结果和剩余项
2026-09-23 10:58:19 +00:00
lhk229 07feed7511 Merge remote-tracking branch 'origin/master' into opt/compile-warning 2026-09-23 10:39:19 +00:00
suzmii 16361bfbe1 修复退出全屏后运行画面不回缩,并收口信息栏开合与文档口径
Project CI / AI game creator shell Rust crates (pull_request) Successful in 59s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m26s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 2m5s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m40s
Project CI / Native shell tests (pull_request) Successful in 5m55s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m24s
- `resolveLocalGamePreviewFitLayout` 增加回退:上报的内容尺寸等于它被接受时的容器尺寸(自适应页面把视口原样报回)时不算内容高水位,直接按容器取画布——修复「全屏预览退出后画布仍保持全屏比例、永久带黑边」;真比容器高的页面(内容 ≠ 视口)仍按原生尺寸缩放显示。
- 该回退的已知残余(固定尺寸页面恰好等于接受时的容器且之后不上报新尺寸会被裁)与「内容尺寸没变就改认新容器」这条更差尝试一起写进代码注释与 decision-log;根治方向记在桥/协议侧。
- `tests/localGamePreviewFrame.test.ts` 抽出 `renderFittedFrame` 夹具,补「退出全屏回到容器尺寸」(改前必红)与「容器缩小后按新内容尺寸重新适配」两条用例。
- 信息栏手动态改为按资源 id 在渲染期派生(去掉 effect 回写),修掉「刚有内容那一帧先画收起态」的抖动,并让 `onClick` 写入的字段与派生读的字段一致。
- AGC 子集删掉恒真的「数值微调面板不存在」断言,保留「卡片不渲染」与「开合行不渲染」两条独立事实。
- 文档同步:PRD §3.4、技术方案与 decision-log 的信息栏判据口径改为「资源选中态」,并记录全屏回归的根因、修法、残余边界与桥协议根治方向;2026-08-23 适配条目补一条指向本次收口。
2026-09-23 18:38:47 +08:00
lhk229 688cf7a971 修复策划回复重复播放并保留伪流式
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m59s
Project CI / Backend tests (pull_request) Successful in 5m1s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m7s
Project CI / Native shell tests (pull_request) Successful in 6m17s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m29s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m42s
Project CI / Repository checks (pull_request) Successful in 2m18s
按消息 ID 合并实时正文与正式消息,保留逐步显示进度
分离请求收尾与动画结束,隔离旧回合事件及异步返回
独立展示工具状态,保留 Provider 重试的正文重置语义
补充重复终态、整块回复、多消息及回合隔离回归测试
同步更新策划展示规范与排障记录
2026-09-23 10:38:01 +00:00
suzmii d7440473af 合并 master:最近项目重试与发布资料交互等 12 个提交
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m1s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m20s
Project CI / Backend tests (pull_request) Successful in 4m38s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m17s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m24s
Project CI / Native shell tests (pull_request) Successful in 5m38s
Project CI / Frontend tests (pull_request) Successful in 1m59s
Project CI / Repository checks (pull_request) Successful in 1m55s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m22s
- 合并 origin/master(`297d7bf1e` 及之前 12 个提交:最近项目检查失败重试、AGC 发布资料与截图交互、模板库筛选表现等)到本分支,保持 PR #497 可合并。
- 冲突仍只在 `docs/project-memory/shared-memory/decision-log.md`:双方都在顶部新增 2026-09-23 条目,按「新条目在上」保留双方内容,本分支条目在前。
- `apps/ai-game-creator-shell/src/styles.css` 为自动合并;逐条核对本分支运行页改动(全屏钮、信息栏开合、数值微调隐藏)的五处选择器仍在,其余为上游更新弹窗与主题调整。
2026-09-23 17:49:44 +08:00
suzmii 8d487685f3 合并 master:上游 18 个提交并保留运行页签 chrome 改动
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
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 / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- 合并 origin/master(斜杠命令退役、筛选控件选中态、埋点后台排序、发版脚本等)到本分支,解决 PR #497 的冲突。
- 冲突只在 `docs/project-memory/shared-memory/decision-log.md`:双方都在文件顶部新增条目,按「新条目在上」保留双方内容,本分支的 2026-09-23 条目在前。
- `apps/ai-game-creator-shell/src/styles.css`、`tests/appSurface/project-development.suite.ts` 与实施计划技术方案为自动合并,逐块核对本分支的运行页改动(全屏钮、信息栏开合、数值微调隐藏)仍完整保留。
2026-09-23 17:41:00 +08:00
suzmii 09fb8bc076 运行页签补齐全屏预览与可收起的信息栏
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m5s
Project CI / AI game creator shell Rust lane 2/2 (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 / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
- 运行画面右下角新增「全屏预览」:新增 `useElementFullscreen`,只把画面那一格(`.game-run-preview`)送进全屏,按钮态只认 `fullscreenchange`,宿主没有 Fullscreen API 时整枚入口不渲染。
- 底部信息栏改为「有内容才存在」:运行页选中资源时自动展开,可手动收起到只剩一行开合按钮,清空选中后整栏不渲染;只有一栏内容时卡片铺满整行。
- 「数值微调」在没有登记表前不渲染区域标题与卡片,随这一栏删除其 label / input 样式声明。
- 新增 `tests/runPreviewFullscreen.test.tsx`;AGC 子集补信息栏开合用例,并把「没有内容仍渲染两张空卡片」的旧断言改成整栏不渲染。
- 同步 PRD §3.4、技术方案运行表现层条目与 project-memory 决策记录。
2026-09-23 17:26:53 +08:00
lhk229 b7ae18e65c 记录主要构建入口编译警告清单
记录各主要构建入口的验证结果、警告分类和未覆盖范围
整理 AGC 的 237 条编译器警告、ts-rs 提示及后续处理建议
登记前端构建警告和移动端检查错误,并更新文档分类索引
2026-09-23 08:19:33 +00:00
258 changed files with 8461 additions and 8232 deletions
@@ -41,3 +41,14 @@
## 注意
不同 enum 的 variant 顺序必须以生成 binding 或 module 源码为准,不能复用其他 enum 的索引映射。
## 通用表查询页的枚举展示(2026-09-23 起)
后台“表查询”(`#tables`)不再逐表硬编码枚举映射,改为按 schema 自动解析:
- api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 schema 的 `typespace.types` 和表的 `product_type_ref`,对每个“`Sum` 且所有变体都是单元变体(`Product.elements` 为空)”的列生成 `列名 -> [按变体索引排列的展示名]`,变体名归一到 snake_case。
- `Option<枚举>` 列单独标记为可空:`[0, [索引, []]]` 出变体名,`[1, []]` 仍是空值。`Option<普通值>` 与带载荷的 Sum 直接跳过,交回通用解码,避免把普通 `Option` 列误标成枚举名。
- 映射同时应用到 `cells``raw`,因此关键词搜索、结构化筛选、稳定排序解析到的都是展示名。
- 单变体枚举也要出名字;变体索引顺序以 schema 为准,不依赖生成 binding 的副本。
因此新增表或新增枚举列无需再改后端映射,只要模块已发布且 schema 可读;如果 schema 读取失败,表查询会以“表不存在”失败,而不是退回展示数字。定向验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin_database`
+81 -2
View File
@@ -8,10 +8,12 @@ import {
getAdminUserDetail,
importAdminAgcTemplates,
listAdminAgcTrackingEvents,
listAdminGameDistributionGames,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
restoreAdminGameDistributionGame,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
updateAdminAccount,
@@ -501,7 +503,6 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键'
{
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
},
);
@@ -521,12 +522,90 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键'
body: JSON.stringify({
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
}),
}),
);
});
test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, data: { games: [] } }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
ok: true,
data: {
game: {
id: 'game/1',
title: '测试游戏',
status: 'published',
publicationRevision: 10,
},
replayed: false,
},
}),
{ status: 200 },
),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
await listAdminGameDistributionGames(
'admin-token',
{ limit: 80 },
controller.signal,
);
await restoreAdminGameDistributionGame(
'admin-token',
' game/1 ',
' game-restore-key-1 ',
{ expectedPublicationRevision: 9 },
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/games?limit=50',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'GET',
signal: controller.signal,
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
}),
}),
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'/admin/api/game-distribution/games/game%2F1/restore',
);
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-restore-key-1',
}),
body: JSON.stringify({ expectedPublicationRevision: 9 }),
}),
);
expect(() =>
restoreAdminGameDistributionGame('admin-token', ' ', 'key', {
expectedPublicationRevision: 9,
}),
).toThrow('缺少游戏 ID');
expect(() =>
restoreAdminGameDistributionGame('admin-token', 'game-1', ' ', {
expectedPublicationRevision: 9,
}),
).toThrow('恢复幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
+43
View File
@@ -32,6 +32,9 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminGameDistributionGameListResponse,
AdminGameDistributionRestoreRequest,
AdminGameDistributionRestoreResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
@@ -1240,6 +1243,46 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
);
}
export function listAdminGameDistributionGames(
token: string,
options: { limit?: number } = {},
signal?: AbortSignal,
) {
const requestedLimit = options.limit ?? 50;
const normalizedLimit = Number.isFinite(requestedLimit)
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50)
: 50;
return request<AdminGameDistributionGameListResponse>(
`/admin/api/game-distribution/games?limit=${normalizedLimit}`,
{ token, signal },
);
}
export function restoreAdminGameDistributionGame(
token: string,
gameId: string,
idempotencyKey: string,
payload: AdminGameDistributionRestoreRequest,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) {
throw new Error('缺少游戏 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('恢复幂等键必须是 1 到 128 个字符');
}
return request<AdminGameDistributionRestoreResponse>(
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/restore`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
/**
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
* 两条审核结论。
+58 -1
View File
@@ -193,7 +193,10 @@ export interface AdminDashboardRangePayload {
export interface AdminDashboardMetricsPayload {
generatedAssets: number;
/** 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 */
consumedMudPoints: number;
/** 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 */
refundedMudPoints: number;
totalRegisteredUsers: number;
newRegisteredUsers: number;
newUserPaymentConversion: AdminDashboardPaymentConversionPayload;
@@ -955,6 +958,8 @@ export interface AdminRechargeOrderEntryPayload {
productTitle: string;
productKind: string;
amountCents: number;
/** 真实支付金额(分):未支付 / 已关闭 / 已过期订单固定为 0,不能拿订单金额当实付。 */
paidAmountCents: number;
status: string;
paymentChannel: string;
paidAtMicros?: number | null;
@@ -994,6 +999,8 @@ export interface AdminUserDetailResponse {
phoneBound: boolean;
wechatBound: boolean;
historicalConsumedPoints: number;
/** 累计充值金额(分):读取失败或命中读取上限时为 null,前端按未知展示。 */
cumulativeRechargedCents?: number | null;
canReconcileConsumption: boolean;
wallet: AdminProfileWalletPayload;
rechargeOrders: AdminRechargeOrderEntryPayload[];
@@ -1103,7 +1110,6 @@ export interface AdminGameDistributionReviewRequest {
decision: 'approve' | 'reject';
expectedPublicationRevision: number;
reviewReason?: string;
entryUrl?: string;
}
export interface AdminGameDistributionReviewResponse {
@@ -1126,6 +1132,57 @@ export interface AdminGameDistributionSuspendResponse {
replayed: boolean;
}
export interface AdminGameDistributionGameVersionEntry {
versionId: string;
gameId: string;
versionNumber: number;
status: string;
reviewReason: string | null;
packageBytes: number;
packageSha256: string;
createdAt: string;
updatedAt: string;
reviewedAt: string | null;
publishedAt: string | null;
entryUrl: string | null;
}
export interface AdminGameDistributionGameEntry {
gameId: string;
title: string;
author: {
id: string;
name: string;
avatarUrl: string | null;
};
status: string;
versionCount: number;
playCount: number;
activeVersionId: string | null;
publicationRevision: number;
createdAt: string;
updatedAt: string;
versions: AdminGameDistributionGameVersionEntry[];
}
export interface AdminGameDistributionGameListResponse {
games: AdminGameDistributionGameEntry[];
}
export interface AdminGameDistributionRestoreRequest {
expectedPublicationRevision: number;
}
export interface AdminGameDistributionRestoreResponse {
game: {
id: string;
title: string;
status: string;
publicationRevision: number;
};
replayed: boolean;
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
+7
View File
@@ -29,6 +29,7 @@ import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGeneration
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -321,6 +322,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-management' ? (
<AdminGameManagementPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
+2
View File
@@ -14,6 +14,7 @@ import {
ReceiptText,
ShieldCheck,
Star,
Swords,
Table2,
TicketCheck,
TicketPercent,
@@ -52,6 +53,7 @@ const routeIcons = {
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
'game-management': Swords,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
@@ -168,6 +168,16 @@ test('后台游戏审核路由可通过导航和 hash 访问', () => {
expect(routeHash('game-distribution')).toBe('#game-distribution');
});
test('后台游戏管理路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'game-management',
label: '游戏管理',
hash: '#game-management',
});
expect(resolveAdminRoute('#game-management')).toBe('game-management');
expect(routeHash('game-management')).toBe('#game-management');
});
test('member 可单独获得游戏审核 Tab 权限', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
+2
View File
@@ -17,6 +17,7 @@ export type AdminRouteId =
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
| 'game-management'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
@@ -60,6 +61,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
@@ -51,6 +51,7 @@ const detail: AdminUserDetailResponse = {
phoneBound: true,
wechatBound: true,
historicalConsumedPoints: 1234,
cumulativeRechargedCents: 128800,
canReconcileConsumption: true,
wallet,
rechargeOrders: [
@@ -62,6 +63,7 @@ const detail: AdminUserDetailResponse = {
productTitle: '60泥点',
productKind: 'points',
amountCents: 600,
paidAmountCents: 600,
status: 'paid',
paymentChannel: 'wechat_native',
paidAtMicros: 1_720_000_000_000_000,
@@ -124,7 +126,11 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy();
expect(screen.getByText('历史花费')).toBeTruthy();
expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy();
expect(screen.getByText('累计充值')).toBeTruthy();
expect(screen.getByText('¥1288.00')).toBeTruthy();
expect(screen.getByText('order-1')).toBeTruthy();
expect(screen.getByRole('columnheader', { name: '实付' })).toBeTruthy();
expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy();
await user.keyboard('{Escape}');
await waitFor(() =>
@@ -133,6 +139,29 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
await waitFor(() => expect(document.activeElement).toBe(trigger));
});
test('累计充值读取不到时展示未知,不用订单列表近似', async () => {
vi.mocked(getAdminUserDetail).mockResolvedValue({
...detail,
cumulativeRechargedCents: null,
});
const user = userEvent.setup();
render(
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
await screen.findByText('陶泥用户');
expect(screen.getByText('累计充值')).toBeTruthy();
expect(screen.getByText('累计充值').nextElementSibling?.textContent).toBe(
'未知',
);
});
test('只有陶泥号时按 publicUserCode 查询用户', async () => {
const user = userEvent.setup();
render(
@@ -364,6 +364,7 @@ export function AdminUserDetailDialog({
<th></th>
<th></th>
<th></th>
<th></th>
<th>退</th>
<th></th>
</tr>
@@ -377,11 +378,13 @@ export function AdminUserDetailDialog({
</span>
<small>{formatMicros(order.createdAtMicros)}</small>
</td>
<td>{order.productTitle || order.productId}</td>
<td>
{order.productTitle || order.productId}
<small> {order.pointsDelta} </small>
{order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
: '未支付'}
</td>
<td>{formatMoney(order.amountCents)}</td>
<td>{order.pointsDelta} </td>
<td>
{formatMoney(order.cumulativeSuccessRefundCents)}
<small> {order.unrecoveredPoints} </small>
@@ -435,6 +438,14 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
<dt></dt>
<dd>{detail.loginMethod || '-'}</dd>
</div>
<div>
<dt></dt>
<dd>
{typeof detail.cumulativeRechargedCents === 'number'
? formatMoney(detail.cumulativeRechargedCents)
: '未知'}
</dd>
</div>
<div>
<dt></dt>
<dd>
@@ -34,6 +34,7 @@ const dashboardResponse: AdminDashboardResponse = {
metrics: {
generatedAssets: 12,
consumedMudPoints: 88,
refundedMudPoints: 24,
totalRegisteredUsers: 1200,
newRegisteredUsers: 16,
newUserPaymentConversion: {
@@ -105,6 +106,8 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
expect(await screen.findByText('总计数据')).toBeTruthy();
expect(screen.getByText('时段数据')).toBeTruthy();
expect(screen.getByText('本日生产素材数')).toBeTruthy();
expect(screen.getByText('本日消耗泥点数')).toBeTruthy();
expect(screen.getByText('本日退还泥点数')).toBeTruthy();
expect(screen.getByText('总注册用户')).toBeTruthy();
expect(screen.getByText('本日新增用户数')).toBeTruthy();
expect(screen.getByText('新增用户转化与留存')).toBeTruthy();
@@ -128,6 +128,12 @@ export function AdminDashboardPage({
value: metrics?.consumedMudPoints ?? 0,
unit: '泥点',
},
{
id: 'refunded-mud-points',
label: `${rangePrefix(granularity)}退还泥点数`,
value: metrics?.refundedMudPoints ?? 0,
unit: '泥点',
},
{
id: 'new-registered-users',
label: `${rangePrefix(granularity)}新增用户数`,
@@ -1,6 +1,12 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import {
@@ -9,10 +15,7 @@ import {
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import {
AdminGameDistributionReviewPage,
resolveGameReleaseEntryUrlError,
} from './AdminGameDistributionReviewPage';
import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage';
vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(
@@ -53,23 +56,7 @@ beforeEach(() => {
});
});
test('发行入口必须是带完整来源的 HTTPS 地址', () => {
expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口');
expect(
resolveGameReleaseEntryUrlError('http://games.test/a/index.html'),
).toBe('发行入口必须以 https:// 开头');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'),
).toBe('发行入口不能包含 query 或 fragment');
expect(
resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'),
).toBe('发行入口不能包含凭据');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html'),
).toBe('');
});
test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => {
test('通过审核只提交当前 publicationRevision 并刷新列表', async () => {
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
version: { ...entry, status: 'published' },
replayed: false,
@@ -83,9 +70,10 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('发行入口'), {
target: { value: 'https://games.test/releases/game_1/index.html' },
});
expect(screen.queryByLabelText('发行入口')).toBeNull();
expect(screen.queryByText('通过后由系统分配发行地址')).toBeNull();
expect(screen.queryByLabelText('拒绝理由')).toBeNull();
expect(screen.queryByLabelText('下架原因')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() =>
@@ -99,7 +87,6 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新
expect(payload).toEqual({
decision: 'approve',
expectedPublicationRevision: 4,
entryUrl: 'https://games.test/releases/game_1/index.html',
});
await waitFor(() =>
expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes(
@@ -108,7 +95,12 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新
);
});
test('缺少拒绝理由时不调用审核接口', async () => {
test('点击拒绝后填写理由再提交审核接口', async () => {
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
version: { ...entry, status: 'rejected', reviewReason: '运行时报错' },
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
@@ -118,12 +110,32 @@ test('缺少拒绝理由时不调用审核接口', async () => {
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
const dialog = await screen.findByRole('dialog');
const reasonInput = within(dialog).getByRole('textbox', {
name: '拒绝理由',
});
expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' }));
expect(await within(dialog).findByText('拒绝审核必须填写理由')).toBeTruthy();
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
fireEvent.change(reasonInput, { target: { value: '运行时报错' } });
fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' }));
await waitFor(() =>
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1),
);
const [, versionId, , payload] =
vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? [];
expect(versionId).toBe('version-1');
expect(payload).toEqual({
decision: 'reject',
expectedPublicationRevision: 4,
reviewReason: '运行时报错',
});
});
test('安全下架需要二次确认并携带公开修订号与原因', async () => {
test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => {
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
game: {
id: 'game_1',
@@ -142,16 +154,21 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '盗用素材' },
});
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
const reasonDialog = await screen.findByRole('dialog');
fireEvent.change(
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
{
target: { value: '盗用素材' },
},
);
fireEvent.click(
within(reasonDialog).getByRole('button', { name: '继续下架' }),
);
// 第一次点击只弹出确认面板,不直接调用后端。
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
expect(await screen.findByRole('dialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await screen.findByText('确认操作');
const confirmDialog = screen.getByRole('dialog');
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
@@ -168,7 +185,24 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async
expect(await screen.findByText(//u)).toBeTruthy();
});
test('取消确认时不下架', async () => {
test('取消理由输入时不做审核操作', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
});
test('取消安全下架确认时不下架', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
@@ -178,8 +212,20 @@ test('取消确认时不下架', async () => {
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '取消' }));
const reasonDialog = await screen.findByRole('dialog');
fireEvent.change(
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
{
target: { value: '盗用素材' },
},
);
fireEvent.click(
within(reasonDialog).getByRole('button', { name: '继续下架' }),
);
await screen.findByText('确认操作');
const confirmDialog = screen.getByRole('dialog');
fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
@@ -1,3 +1,4 @@
import { Modal, TextField } from '@genarrative/shared/components';
import { RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@@ -15,6 +16,11 @@ interface AdminGameDistributionReviewPageProps {
onUnauthorized: (message?: string) => void;
}
interface ReviewReasonPrompt {
decision: 'reject' | 'suspend';
entry: AdminGameDistributionReviewEntry;
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
@@ -47,26 +53,6 @@ function createReviewIdempotencyKey(versionId: string) {
return `game-review-${versionId}-${random}`.slice(0, 128);
}
export function resolveGameReleaseEntryUrlError(value: string) {
const normalized = value.trim();
if (!normalized) return '请填写发行入口';
if (!normalized.startsWith('https://')) {
return '发行入口必须以 https:// 开头';
}
if (normalized.includes('?') || normalized.includes('#')) {
return '发行入口不能包含 query 或 fragment';
}
try {
const parsed = new URL(normalized);
if (parsed.username || parsed.password) {
return '发行入口不能包含凭据';
}
} catch {
return '发行入口不是合法 URL';
}
return '';
}
export function AdminGameDistributionReviewPage({
token,
onUnauthorized,
@@ -78,15 +64,11 @@ export function AdminGameDistributionReviewPage({
const [busyVersionId, setBusyVersionId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [entryUrlByVersion, setEntryUrlByVersion] = useState<
Record<string, string>
>({});
const [reasonByVersion, setReasonByVersion] = useState<
Record<string, string>
>({});
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
const [reasonPrompt, setReasonPrompt] = useState<ReviewReasonPrompt | null>(
null,
);
const [reasonDraft, setReasonDraft] = useState('');
const [reasonError, setReasonError] = useState('');
const [busyGameId, setBusyGameId] = useState('');
const writeConfirm = useAdminWriteConfirm();
@@ -110,16 +92,10 @@ export function AdminGameDistributionReviewPage({
async function submitReview(
entry: AdminGameDistributionReviewEntry,
decision: 'approve' | 'reject',
reason = '',
) {
const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim();
const reason = (reasonByVersion[entry.versionId] ?? '').trim();
if (decision === 'approve') {
const invalid = resolveGameReleaseEntryUrlError(entryUrl);
if (invalid) {
setErrorMessage(invalid);
return;
}
} else if (!reason) {
const trimmedReason = reason.trim();
if (decision === 'reject' && !trimmedReason) {
setErrorMessage('拒绝审核必须填写理由');
return;
}
@@ -135,12 +111,11 @@ export function AdminGameDistributionReviewPage({
? {
decision,
expectedPublicationRevision: entry.publicationRevision,
entryUrl,
}
: {
decision,
expectedPublicationRevision: entry.publicationRevision,
reviewReason: reason,
reviewReason: trimmedReason,
},
);
setStatusMessage(
@@ -160,8 +135,11 @@ export function AdminGameDistributionReviewPage({
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
* 由服务端返回冲突,前端只提示刷新,不静默重试。
*/
async function suspendGame(entry: AdminGameDistributionReviewEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
async function suspendGame(
entry: AdminGameDistributionReviewEntry,
reason: string,
) {
const trimmedReason = reason.trim();
const confirmed = await writeConfirm.confirmWrite({
action: '安全下架游戏',
target: `${entry.gameId}(版本 v${entry.versionNumber}`,
@@ -177,11 +155,10 @@ export function AdminGameDistributionReviewPage({
createSuspendIdempotencyKey(entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
...(reason ? { reason } : {}),
...(trimmedReason ? { reason: trimmedReason } : {}),
},
);
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' }));
await loadReviews();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
@@ -190,6 +167,39 @@ export function AdminGameDistributionReviewPage({
}
}
function openReasonPrompt(
entry: AdminGameDistributionReviewEntry,
decision: ReviewReasonPrompt['decision'],
) {
setReasonDraft('');
setReasonError('');
setReasonPrompt({ decision, entry });
}
function closeReasonPrompt() {
setReasonPrompt(null);
setReasonDraft('');
setReasonError('');
}
function confirmReasonPrompt() {
if (!reasonPrompt) return;
const reason = reasonDraft.trim();
if (reasonPrompt.decision === 'reject' && !reason) {
setReasonError('拒绝审核必须填写理由');
return;
}
const { decision, entry } = reasonPrompt;
closeReasonPrompt();
if (decision === 'reject') {
void submitReview(entry, 'reject', reason);
return;
}
void suspendGame(entry, reason);
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
@@ -268,25 +278,6 @@ export function AdminGameDistributionReviewPage({
<td>{formatTime(entry.createdAt)}</td>
<td>
<div className="admin-action-row">
<div className="admin-field">
<label
htmlFor={`game-release-url-${entry.versionId}`}
>
</label>
<input
id={`game-release-url-${entry.versionId}`}
value={entryUrlByVersion[entry.versionId] ?? ''}
placeholder="https://"
onChange={(event) =>
setEntryUrlByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-primary-button"
@@ -295,55 +286,19 @@ export function AdminGameDistributionReviewPage({
>
</button>
<div className="admin-field">
<label
htmlFor={`game-reject-reason-${entry.versionId}`}
>
</label>
<input
id={`game-reject-reason-${entry.versionId}`}
value={reasonByVersion[entry.versionId] ?? ''}
onChange={(event) =>
setReasonByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-ghost-button"
disabled={busy}
onClick={() => void submitReview(entry, 'reject')}
onClick={() => openReasonPrompt(entry, 'reject')}
>
</button>
<div className="admin-field">
<label
htmlFor={`game-suspend-reason-${entry.versionId}`}
>
</label>
<input
id={`game-suspend-reason-${entry.versionId}`}
value={suspendReasonByGame[entry.gameId] ?? ''}
onChange={(event) =>
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-danger-button"
disabled={busy || busyGameId === entry.gameId}
onClick={() => void suspendGame(entry)}
onClick={() => openReasonPrompt(entry, 'suspend')}
>
{busyGameId === entry.gameId
? '正在下架…'
@@ -359,6 +314,52 @@ export function AdminGameDistributionReviewPage({
</div>
) : null}
</div>
{reasonPrompt ? (
<Modal
open
title={reasonPrompt.decision === 'reject' ? '拒绝审核' : '安全下架'}
description={`${reasonPrompt.entry.gameId} · 版本 v${reasonPrompt.entry.versionNumber}`}
closeLabel="关闭理由输入"
onClose={closeReasonPrompt}
size="sm"
className="genarrative-ui"
footer={
<div className="admin-confirm-actions" style={{ width: '100%' }}>
<button
type="button"
className="admin-secondary-button"
onClick={closeReasonPrompt}
>
</button>
<button
type="button"
className={
reasonPrompt.decision === 'reject'
? 'admin-ghost-button'
: 'admin-danger-button'
}
onClick={confirmReasonPrompt}
>
{reasonPrompt.decision === 'reject' ? '确认拒绝' : '继续下架'}
</button>
</div>
}
>
<TextField
autoFocus
multiline
label={reasonPrompt.decision === 'reject' ? '拒绝理由' : '下架原因'}
value={reasonDraft}
error={reasonError}
rows={4}
onChange={(event) => {
setReasonDraft(event.target.value);
if (reasonError) setReasonError('');
}}
/>
</Modal>
) : null}
{writeConfirm.confirmDialog}
</section>
);
@@ -0,0 +1,265 @@
/* @vitest-environment jsdom */
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
listAdminGameDistributionGames,
restoreAdminGameDistributionGame,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type {
AdminGameDistributionGameEntry,
AdminGameDistributionGameVersionEntry,
} from '../api/adminApiTypes';
import { AdminGameManagementPage } from './AdminGameManagementPage';
vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
listAdminGameDistributionGames: vi.fn(),
restoreAdminGameDistributionGame: vi.fn(),
suspendAdminGameDistributionGame: vi.fn(),
}));
const version: AdminGameDistributionGameVersionEntry = {
versionId: 'version-3',
gameId: 'game_1',
versionNumber: 3,
status: 'published',
reviewReason: '测试原因',
packageBytes: 2048,
packageSha256:
'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T08:00:00Z',
reviewedAt: '2026-09-20T09:00:00Z',
publishedAt: '2026-09-20T10:00:00Z',
entryUrl: 'https://game.example.com/game_1',
};
const publishedGame: AdminGameDistributionGameEntry = {
gameId: 'game_1',
title: '测试游戏',
author: {
id: 'user_1',
name: '作者甲',
avatarUrl: 'https://example.com/avatar.png',
},
status: 'published',
versionCount: 21,
playCount: 345,
activeVersionId: 'version-3',
publicationRevision: 4,
createdAt: '2026-09-18T08:00:00Z',
updatedAt: '2026-09-20T10:00:00Z',
versions: [version],
};
const suspendedGame: AdminGameDistributionGameEntry = {
gameId: 'game_2',
title: '下架游戏',
author: {
id: 'user_2',
name: '作者乙',
avatarUrl: null,
},
status: 'suspended',
versionCount: 2,
playCount: 8,
activeVersionId: null,
publicationRevision: 7,
createdAt: '2026-09-19T08:00:00Z',
updatedAt: '2026-09-21T08:00:00Z',
versions: [],
};
beforeEach(() => {
vi.mocked(listAdminGameDistributionGames)
.mockReset()
.mockResolvedValue({ games: [publishedGame] });
vi.mocked(restoreAdminGameDistributionGame)
.mockReset()
.mockResolvedValue({
game: {
id: suspendedGame.gameId,
title: suspendedGame.title,
status: 'published',
publicationRevision: 8,
},
replayed: false,
});
vi.mocked(suspendAdminGameDistributionGame)
.mockReset()
.mockResolvedValue({
game: {
id: publishedGame.gameId,
title: publishedGame.title,
status: 'suspended',
publicationRevision: 5,
},
replayed: false,
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
test('列表展示作者头像、状态、版本数和游玩数', async () => {
vi.mocked(listAdminGameDistributionGames).mockResolvedValue({
games: [publishedGame, suspendedGame],
});
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const publishedRow = (await screen.findByText('测试游戏')).closest('tr')!;
expect(within(publishedRow).getByText('作者甲')).toBeTruthy();
const avatar = within(publishedRow).getByRole('img', {
name: '作者甲 头像',
});
expect(avatar.getAttribute('src')).toBe('https://example.com/avatar.png');
expect(within(publishedRow).getByText('已公开')).toBeTruthy();
expect(within(publishedRow).getByText('21')).toBeTruthy();
expect(within(publishedRow).getByText('345')).toBeTruthy();
const suspendedRow = (await screen.findByText('下架游戏')).closest('tr')!;
expect(within(suspendedRow).getByText('作者乙')).toBeTruthy();
expect(suspendedRow.querySelector('.admin-user-avatar')?.textContent).toBe(
'作',
);
expect(within(suspendedRow).getByText('已下架')).toBeTruthy();
});
test('恢复按钮只在下架态出现,成功后携带幂等键并刷新列表', async () => {
vi.mocked(listAdminGameDistributionGames)
.mockResolvedValueOnce({ games: [suspendedGame] })
.mockResolvedValueOnce({
games: [{ ...suspendedGame, status: 'published' }],
});
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('下架游戏');
expect(screen.queryByRole('button', { name: '下架' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
expect(restoreAdminGameDistributionGame).not.toHaveBeenCalled();
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(restoreAdminGameDistributionGame).toHaveBeenCalledTimes(1),
);
const [token, gameId, idempotencyKey, payload] =
vi.mocked(restoreAdminGameDistributionGame).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(gameId).toBe('game_2');
expect(String(idempotencyKey)).toContain('game_2');
expect(payload).toEqual({ expectedPublicationRevision: 7 });
await waitFor(() =>
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2),
);
expect(await screen.findByText('游戏《下架游戏》已恢复')).toBeTruthy();
});
test('下架需要原因和二次确认,提交原因与当前公开修订号', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '违规内容' },
});
fireEvent.click(screen.getByRole('button', { name: '下架' }));
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
);
const [token, gameId, idempotencyKey, payload] =
vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(gameId).toBe('game_1');
expect(String(idempotencyKey)).toContain('game_1');
expect(payload).toEqual({
expectedPublicationRevision: 4,
reason: '违规内容',
});
expect(await screen.findByText('游戏《测试游戏》已下架')).toBeTruthy();
});
test('版本历史弹层展示版本条目与统计信息', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.click(screen.getByRole('button', { name: '版本历史' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('共 21 个版本,展示最近 20 个')).toBeTruthy();
expect(within(dialog).getByText('v3')).toBeTruthy();
expect(within(dialog).getByText('published')).toBeTruthy();
expect(within(dialog).getByText('2.0 KiB')).toBeTruthy();
expect(within(dialog).getByText('abcdef012345')).toBeTruthy();
expect(within(dialog).getByText('测试原因')).toBeTruthy();
expect(
within(dialog).getByText('https://game.example.com/game_1'),
).toBeTruthy();
});
test('接口失败显示错误文案', async () => {
vi.mocked(listAdminGameDistributionGames).mockRejectedValue(
new Error('游戏列表读取失败'),
);
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
expect(await screen.findByText('游戏列表读取失败')).toBeTruthy();
});
test('401 交给 onUnauthorized 处理', async () => {
const onUnauthorized = vi.fn();
vi.mocked(listAdminGameDistributionGames).mockRejectedValue(
Object.assign(new Error('未授权'), { status: 401 }),
);
render(
<AdminGameManagementPage
token="admin-token"
onUnauthorized={onUnauthorized}
/>,
);
await waitFor(() =>
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
expect(screen.queryByRole('alert')).toBeNull();
});
@@ -0,0 +1,456 @@
import { RefreshCcw, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
listAdminGameDistributionGames,
restoreAdminGameDistributionGame,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type {
AdminGameDistributionGameEntry,
AdminGameDistributionGameVersionEntry,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminGameManagementPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const GAME_STATUS_META: Record<string, { label: string; className: string }> = {
published: { label: '已公开', className: 'admin-status-ok' },
suspended: { label: '已下架', className: 'admin-status-error' },
unpublished: { label: '未公开', className: 'admin-status-pending' },
};
function gameStatusMeta(status: string) {
return (
GAME_STATUS_META[status] ?? {
label: status || '—',
className: 'admin-status-pending',
}
);
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KiB`;
}
return `${value} B`;
}
function formatTime(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString('zh-CN', { hour12: false });
}
function formatOptionalTime(value: string | null) {
return value ? formatTime(value) : '—';
}
function authorName(entry: AdminGameDistributionGameEntry) {
return entry.author?.name?.trim() || '—';
}
function authorInitial(entry: AdminGameDistributionGameEntry) {
const name = authorName(entry);
return name === '—' ? '—' : (Array.from(name)[0] ?? '—');
}
function createGameActionIdempotencyKey(
action: 'suspend' | 'restore',
gameId: string,
) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const prefix = action === 'suspend' ? 'game-suspend' : 'game-restore';
return `${prefix}-${gameId}-${random}`.slice(0, 128);
}
export function AdminGameManagementPage({
token,
onUnauthorized,
}: AdminGameManagementPageProps) {
const [games, setGames] = useState<AdminGameDistributionGameEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [busyGameId, setBusyGameId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
const [versionGame, setVersionGame] =
useState<AdminGameDistributionGameEntry | null>(null);
const writeConfirm = useAdminWriteConfirm();
const loadGames = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminGameDistributionGames(token, {
limit: 50,
});
setGames(response.games);
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [token, onUnauthorized]);
useEffect(() => {
void loadGames();
}, [loadGames]);
useEffect(() => {
if (!versionGame) return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setVersionGame(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [versionGame]);
async function suspendGame(entry: AdminGameDistributionGameEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
if (!reason) {
setErrorMessage('下架原因不能为空');
setStatusMessage('');
return;
}
const confirmed = await writeConfirm.confirmWrite({
action: '下架游戏',
target: `${entry.title || entry.gameId}${entry.gameId}`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await suspendAdminGameDistributionGame(
token,
entry.gameId,
createGameActionIdempotencyKey('suspend', entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
reason,
},
);
setStatusMessage(`游戏《${entry.title || entry.gameId}》已下架`);
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: '',
}));
await loadGames();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
async function restoreGame(entry: AdminGameDistributionGameEntry) {
const confirmed = await writeConfirm.confirmWrite({
action: '恢复游戏',
target: `${entry.title || entry.gameId}${entry.gameId}`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await restoreAdminGameDistributionGame(
token,
entry.gameId,
createGameActionIdempotencyKey('restore', entry.gameId),
{ expectedPublicationRevision: entry.publicationRevision },
);
setStatusMessage(`游戏《${entry.title || entry.gameId}》已恢复`);
await loadGames();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h1></h1>
<button
type="button"
className="admin-secondary-button"
onClick={() => void loadGames()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" />
</button>
</div>
{errorMessage ? (
<div className="admin-alert admin-alert-warning" role="alert">
{errorMessage}
</div>
) : null}
{statusMessage ? (
<div className="admin-alert admin-alert-success" role="status">
{statusMessage}
</div>
) : null}
<div className="admin-panel">
<div className="admin-panel-heading">
<h2></h2>
<span className="admin-muted-text"> {games.length} </span>
</div>
{isLoading ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && games.length === 0 ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && games.length > 0 ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th>gameId</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{games.map((entry) => {
const status = gameStatusMeta(entry.status);
const isSuspended = entry.status === 'suspended';
const busy = busyGameId === entry.gameId;
return (
<tr key={entry.gameId}>
<td>
<strong>{entry.title?.trim() || '—'}</strong>
</td>
<td>
<div
className="admin-database-user-cell"
style={{ justifyContent: 'flex-start' }}
>
<div
className="admin-user-avatar"
style={{
width: 32,
height: 32,
flex: '0 0 32px',
fontSize: 13,
}}
>
{entry.author?.avatarUrl ? (
<img
alt={`${authorName(entry)} 头像`}
src={entry.author.avatarUrl}
/>
) : (
authorInitial(entry)
)}
</div>
<div>
<span>{authorName(entry)}</span>
<small>{entry.author?.id?.trim() || '—'}</small>
</div>
</div>
</td>
<td>
<code>{entry.gameId}</code>
</td>
<td>
<span className={`admin-status ${status.className}`}>
{status.label}
</span>
</td>
<td>{entry.versionCount}</td>
<td>{entry.playCount}</td>
<td>
<div className="admin-action-row">
{!isSuspended ? (
<>
<div className="admin-field">
<label
htmlFor={`game-suspend-reason-${entry.gameId}`}
>
</label>
<input
id={`game-suspend-reason-${entry.gameId}`}
value={
suspendReasonByGame[entry.gameId] ?? ''
}
onChange={(event) =>
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-danger-button"
disabled={busy}
onClick={() => void suspendGame(entry)}
>
{busy ? '处理中…' : '下架'}
</button>
</>
) : (
<button
type="button"
className="admin-primary-button"
disabled={busy}
onClick={() => void restoreGame(entry)}
>
{busy ? '处理中…' : '恢复'}
</button>
)}
<button
type="button"
className="admin-secondary-button"
disabled={busy}
onClick={() => setVersionGame(entry)}
>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</div>
{versionGame ? (
<div
className="admin-confirm-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setVersionGame(null);
}
}}
>
<section
aria-labelledby="admin-game-version-history-title"
aria-modal="true"
className="admin-detail-panel"
role="dialog"
>
<div className="admin-panel-heading">
<div>
<h2 id="admin-game-version-history-title"></h2>
<span>
{versionGame.title?.trim() || '—'}{versionGame.gameId}
</span>
</div>
<button
aria-label="关闭版本历史"
className="admin-ghost-button"
type="button"
onClick={() => setVersionGame(null)}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<p className="admin-muted-text">
{versionGame.versionCount} 20
</p>
{versionGame.versions.length === 0 ? (
<p className="admin-muted-text"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>SHA</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{versionGame.versions.map((version) => (
<GameVersionRow
key={version.versionId}
version={version}
/>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
) : null}
{writeConfirm.confirmDialog}
</section>
);
}
function GameVersionRow({
version,
}: {
version: AdminGameDistributionGameVersionEntry;
}) {
return (
<tr>
<td>v{version.versionNumber}</td>
<td>{version.status || '—'}</td>
<td>{formatBytes(version.packageBytes)}</td>
<td>
<code>{version.packageSha256?.slice(0, 12) || '—'}</code>
</td>
<td>{formatOptionalTime(version.createdAt)}</td>
<td>{formatOptionalTime(version.reviewedAt)}</td>
<td>{formatOptionalTime(version.publishedAt)}</td>
<td>{version.reviewReason?.trim() || '—'}</td>
<td>
{version.entryUrl ? (
<a href={version.entryUrl} rel="noreferrer" target="_blank">
{version.entryUrl}
</a>
) : (
'—'
)}
</td>
</tr>
);
}
@@ -64,6 +64,7 @@ const baseOrder: AdminRechargeOrderEntryPayload = {
productTitle: '60泥点',
productKind: 'points',
amountCents: 600,
paidAmountCents: 600,
status: 'paid',
paymentChannel: 'wechat_native',
paidAtMicros: 1_720_000_000_000_000,
@@ -129,6 +130,40 @@ beforeEach(() => {
);
});
test('未支付订单不显示实付金额,发放泥点单独成列', async () => {
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
entries: [
{
...baseOrder,
orderId: 'order-pending',
status: 'pending',
paidAtMicros: null,
paidAmountCents: 0,
pointsDelta: 0,
},
{ ...baseOrder, orderId: 'order-paid' },
],
});
renderPage();
expect(
await screen.findByRole('columnheader', { name: '实付' }),
).toBeTruthy();
expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy();
const unpaidRow = (await screen.findByText('order-pending')).closest(
'tr',
) as HTMLElement;
const unpaidCells = within(unpaidRow).getAllByRole('cell');
expect(unpaidCells[3]?.textContent).toContain('未支付');
expect(unpaidCells[4]?.textContent).toBe('0 泥点');
const paidRow = screen.getByText('order-paid').closest('tr') as HTMLElement;
const paidCells = within(paidRow).getAllByRole('cell');
expect(paidCells[3]?.textContent).toBe('¥6.00');
expect(paidCells[4]?.textContent).toBe('60 泥点');
});
test('充值订单查询传递全部筛选字段', async () => {
const user = userEvent.setup();
renderPage();
@@ -658,7 +658,8 @@ export function AdminRechargeOrderPage({
<th></th>
<th></th>
<th></th>
<th> / </th>
<th></th>
<th></th>
<th>退</th>
<th></th>
<th></th>
@@ -715,7 +716,7 @@ export function AdminRechargeOrderPage({
<div className="admin-refund-summary-grid">
<Metric
label="订单实付"
value={formatMoney(refundOrder.amountCents)}
value={formatMoney(refundOrder.paidAmountCents)}
/>
<Metric
label="累计已退"
@@ -1118,8 +1119,13 @@ function RechargeOrderRow({
</small>
</td>
<td>
<strong>{formatMoney(order.amountCents)}</strong>
<small> {order.pointsDelta} </small>
<strong>{formatOrderPaidAmount(order)}</strong>
{order.paidAmountCents > 0 ? null : (
<small> {formatMoney(order.amountCents)}</small>
)}
</td>
<td>
<span>{order.pointsDelta} </span>
</td>
<td>
<span> {formatMoney(order.cumulativeSuccessRefundCents)}</span>
@@ -1312,6 +1318,13 @@ function formatMoney(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}
/** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */
function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) {
return order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
: '未支付';
}
function formatCentsInput(cents: number) {
return (cents / 100).toFixed(2);
}
@@ -217,7 +217,7 @@ export function AdminRedeemCodePage({
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<span></span>
<input
min={1}
step={1}
@@ -319,7 +319,7 @@ export function AdminRedeemCodePage({
<thead>
<tr>
<th>Code</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
@@ -337,7 +337,7 @@ export function AdminRedeemCodePage({
</button>
<small>{redeemModeLabel(entry.mode)}</small>
</td>
<td>{entry.rewardPoints}</td>
<td>{entry.rewardPoints} </td>
<td>
<span
className={`admin-status ${redeemValidityClass(entry)}`}
@@ -373,6 +373,24 @@ export function buildTauriBuildArguments(
];
}
/**
* 基线 client 窗口契约:渠道配置只允许覆盖标题,其余字段必须逐字沿用。
*
* `tauri build --config` 走 JSON Merge Patchtauri-utils 用 `json_patch::merge`):
* 对象递归合并,**数组整体替换**。只下发 `{ title }` 会让
* `label` / `decorations` / 尺寸全部回落到 Tauri 默认值(label=main、
* decorations=true、800x600),结果是原生系统标题栏重新出现,并且按 label
* 绑定的 capability(平台 HTTP 权限等)一起失效。
*/
function readBaseClientWindow() {
const base = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8'));
const clientWindow = base.app?.windows?.[0];
if (!clientWindow || typeof clientWindow.label !== 'string') {
throw new Error('AGC 基线配置缺少 client 主窗口,渠道配置无法安全合并');
}
return clientWindow;
}
/**
* 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道,
* 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录,
@@ -381,13 +399,14 @@ export function buildTauriBuildArguments(
export function createChannelConfig(
channel = resolveReleaseChannel(),
target = defaultTarget(),
baseClientWindow = readBaseClientWindow(),
) {
const { productName, identifier } = resolveChannelInstallIdentity(channel);
return {
productName,
identifier,
app: {
windows: [{ title: productName }],
windows: [{ ...baseClientWindow, title: productName }],
},
plugins: {
updater: {
@@ -1,5 +1,11 @@
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import {
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
@@ -180,7 +186,18 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
productName: `${AGC_PRODUCT_NAME}开发版`,
identifier: AGC_APP_IDENTIFIER,
app: {
windows: [{ title: `${AGC_PRODUCT_NAME}开发版` }],
windows: [
{
label: 'client',
title: `${AGC_PRODUCT_NAME}开发版`,
url: 'index.html',
width: 1280,
height: 800,
decorations: false,
minWidth: 1280,
minHeight: 720,
},
],
},
plugins: {
updater: {
@@ -245,6 +262,78 @@ test('channel install identity is baked into the same build-time config as the e
});
});
/**
* RFC 7386tauri-utils 用 `json_patch::merge`)语义:对象递归合并,数组整体替换。
* 这里按同样语义复现 Tauri CLI 的 `--config` 合并,用来守住"渠道配置不得丢窗口契约"。
*/
function applyJsonMergePatch(base, patch) {
if (Array.isArray(patch) || typeof patch !== 'object' || patch === null) {
return patch;
}
const merged =
typeof base === 'object' && base !== null && !Array.isArray(base)
? { ...base }
: {};
for (const [key, value] of Object.entries(patch)) {
if (value === null) delete merged[key];
else merged[key] = applyJsonMergePatch(merged[key], value);
}
return merged;
}
function readBaseTauriConfig() {
return JSON.parse(
readFileSync(
new URL('../src-tauri/tauri.conf.json', import.meta.url),
'utf8',
),
);
}
test('channel config keeps the client window contract across the Tauri config merge', () => {
const base = readBaseTauriConfig();
const merged = applyJsonMergePatch(base, {
...createChannelConfig('release', windowsTarget),
version: base.version,
});
const [clientWindow] = merged.app.windows;
assert.deepEqual(clientWindow, {
...base.app.windows[0],
title: '陶泥儿 Release',
});
// 原生标题栏、尺寸与默认窗口标签都是回归点:任何一项回落都会让自绘标题栏失效,
// 并让按 label 绑定的 capability(平台 HTTP 权限)不再命中。
assert.equal(clientWindow.label, 'client');
assert.equal(clientWindow.decorations, false);
assert.equal(clientWindow.width, 1280);
assert.equal(clientWindow.height, 800);
assert.equal(clientWindow.minWidth, 1280);
assert.equal(clientWindow.minHeight, 720);
const capabilitiesDirectory = new URL(
'../src-tauri/capabilities/',
import.meta.url,
);
const capabilities = readdirSync(capabilitiesDirectory)
.filter((name) => name.endsWith('.json'))
.map((name) =>
JSON.parse(readFileSync(new URL(name, capabilitiesDirectory), 'utf8')),
);
const httpCapability = capabilities.find((capability) =>
(capability.permissions ?? []).some(
(permission) =>
permission === 'http:default' ||
(typeof permission === 'object' &&
permission?.identifier === 'http:default'),
),
);
assert.ok(httpCapability, '客户端必须保留承载平台 HTTP 权限的 capability');
assert.ok(
(httpCapability.windows ?? []).includes(clientWindow.label),
`平台 HTTP capability 必须绑定 ${clientWindow.label} 窗口,实际:${httpCapability.windows}`,
);
});
test('channel products keep first-install selection working under the channel product name', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-'));
try {
@@ -1567,6 +1567,14 @@ if (
);
}
// 窗口外壳由前端 WindowChrome 自绘:基线配置一旦放开 decorations
// 打包产物会出现系统标题栏与自绘标题栏并存。
if (clientWindow.decorations !== false) {
throw new Error(
'AI game creator shell client window must keep native decorations disabled',
);
}
if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') {
throw new Error(
'AI game creator shell Tauri config must retain the non-launcher fallback devUrl',
@@ -1,5 +1,7 @@
#[path = "build_support/codex_bundle.rs"]
mod codex_bundle;
#[path = "build_support/codex_package_metadata.rs"]
mod codex_package_metadata;
#[path = "build_support/frontend_dist_guard.rs"]
mod frontend_dist_guard;
#[path = "build_support/godot_bundle.rs"]
@@ -64,7 +66,7 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
.parent()
.and_then(|apps_dir| apps_dir.parent())
.expect("AI 游戏创作应用必须位于仓库 apps 目录下");
let package = layout.npm_package;
let package = format!("codex-{}", layout.platform);
let source_candidates = [app_root, repo_root]
.into_iter()
.flat_map(|root| {
@@ -99,7 +101,7 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
&fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
)
.expect("Codex 原生包元数据无效");
codex_bundle::validate_package_metadata(&metadata, target, layout)
codex_package_metadata::validate_package_metadata(&metadata, target, layout)
.unwrap_or_else(|error| panic!("{error}"));
let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
let notice = target_dir.join("NOTICE.md");
@@ -7,7 +7,6 @@ pub const SCHEMA: &str = "genarrative-codex-sidecar.v2";
#[derive(Clone, Copy, Debug)]
pub struct Layout {
pub platform: &'static str,
pub npm_package: &'static str,
pub directory: &'static str,
pub executable: &'static str,
pub files: &'static [&'static str],
@@ -33,7 +32,6 @@ pub fn for_target(target: &str) -> Option<Layout> {
match target {
"x86_64-pc-windows-msvc" => Some(Layout {
platform: "win32-x64",
npm_package: "codex-win32-x64",
directory: "win-x64",
executable: "bin/codex.exe",
files: WINDOWS_FILES,
@@ -44,11 +42,6 @@ pub fn for_target(target: &str) -> Option<Layout> {
} else {
"darwin-x64"
},
npm_package: if target.starts_with("aarch64") {
"codex-darwin-arm64"
} else {
"codex-darwin-x64"
},
directory: if target.starts_with("aarch64") {
"mac-native/darwin-arm64"
} else {
@@ -61,24 +54,6 @@ pub fn for_target(target: &str) -> Option<Layout> {
}
}
pub fn validate_package_metadata(
metadata: &serde_json::Value,
target: &str,
layout: Layout,
) -> Result<(), String> {
if metadata["layoutVersion"] == 1
&& metadata["version"] == VERSION
&& metadata["target"] == target
&& metadata["entrypoint"] == layout.executable
&& metadata["resourcesDir"] == "codex-resources"
&& metadata["pathDir"] == "codex-path"
{
Ok(())
} else {
Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -87,13 +62,11 @@ mod tests {
fn platform_layouts_are_explicit_and_preserve_upstream_components() {
let mac = for_target("aarch64-apple-darwin").unwrap();
assert_eq!(mac.platform, "darwin-arm64");
assert_eq!(mac.npm_package, "codex-darwin-arm64");
assert!(mac.files.contains(&"codex-resources/zsh/bin/zsh"));
assert!(mac.files.contains(&"bin/codex-code-mode-host"));
assert!(!mac.files.iter().any(|file| file.ends_with(".exe")));
let intel = for_target("x86_64-apple-darwin").unwrap();
assert_eq!(intel.platform, "darwin-x64");
assert_eq!(intel.npm_package, "codex-darwin-x64");
assert_eq!(mac.directory, "mac-native/darwin-arm64");
assert_eq!(intel.directory, "mac-native/darwin-x64");
assert_ne!(mac.directory, intel.directory);
@@ -107,34 +80,4 @@ mod tests {
assert!(for_target("aarch64-pc-windows-msvc").is_none());
assert!(for_target("x86_64-unknown-linux-gnu").is_none());
}
#[test]
fn metadata_rejects_version_architecture_and_layout_drift() {
let target = "aarch64-apple-darwin";
let layout = for_target(target).unwrap();
let valid = serde_json::json!({
"layoutVersion": 1,
"version": VERSION,
"target": target,
"entrypoint": "bin/codex",
"resourcesDir": "codex-resources",
"pathDir": "codex-path",
});
assert!(validate_package_metadata(&valid, target, layout).is_ok());
for (key, value) in [
("layoutVersion", serde_json::json!(2)),
("version", serde_json::json!("0.0.0")),
("target", serde_json::json!("x86_64-apple-darwin")),
("entrypoint", serde_json::json!("bin/codex.exe")),
("resourcesDir", serde_json::json!("../private")),
("pathDir", serde_json::json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(
validate_package_metadata(&invalid, target, layout).is_err(),
"{key}"
);
}
}
}
@@ -0,0 +1,57 @@
//! 随包阶段的原生包元数据校验,不进入运行时生产模块。
use super::codex_bundle::{Layout, VERSION};
pub fn validate_package_metadata(
metadata: &serde_json::Value,
target: &str,
layout: Layout,
) -> Result<(), String> {
if metadata["layoutVersion"] == 1
&& metadata["version"] == VERSION
&& metadata["target"] == target
&& metadata["entrypoint"] == layout.executable
&& metadata["resourcesDir"] == "codex-resources"
&& metadata["pathDir"] == "codex-path"
{
Ok(())
} else {
Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}"))
}
}
#[cfg(test)]
mod tests {
use super::super::codex_bundle::for_target;
use super::*;
#[test]
fn metadata_rejects_version_architecture_and_layout_drift() {
let target = "aarch64-apple-darwin";
let layout = for_target(target).unwrap();
let valid = serde_json::json!({
"layoutVersion": 1,
"version": VERSION,
"target": target,
"entrypoint": "bin/codex",
"resourcesDir": "codex-resources",
"pathDir": "codex-path",
});
assert!(validate_package_metadata(&valid, target, layout).is_ok());
for (key, value) in [
("layoutVersion", serde_json::json!(2)),
("version", serde_json::json!("0.0.0")),
("target", serde_json::json!("x86_64-apple-darwin")),
("entrypoint", serde_json::json!("bin/codex.exe")),
("resourcesDir", serde_json::json!("../private")),
("pathDir", serde_json::json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(
validate_package_metadata(&invalid, target, layout).is_err(),
"{key}"
);
}
}
}

Some files were not shown because too many files have changed in this diff Show More