diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 1299cdbe..80803c1e 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -23,6 +23,7 @@ - 影响范围:`src/services/payment/paymentPlatform.ts`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`miniprogram/pages/wechat-pay/`、`server-rs/crates/api-server/src/runtime_profile.rs`、`server-rs/crates/shared-contracts/src/runtime.rs`、`packages/shared/src/contracts/runtime.ts`、微信登录态存储。 - 验证方式:泥点和会员商品在小程序运行态都请求 `wechat_mp_virtual`;小程序页能按 payload 调用 `wx.requestVirtualPayment` / `wx.requestPayment`;`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 与支付相关前端测试通过。 - 关联文档:`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 + ## 2026-05-30 Linux 本地 dev 端口段按系统级注册表分配 - 背景:同一台 Linux 开发机上有多个用户同时跑 `npm run dev` 时,单纯靠各自 `GENARRATIVE_DEV_PORT_RANGE` 容易撞段,且同一用户并发起两个 dev 会话时也会把相同端口段重复拿走。 @@ -104,6 +105,7 @@ - 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 - 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应断言任务卡显示 `1 / 1`、领取后显示已完成,且新用户账号也没有 `次级入口` / `填邀请码` 常驻按钮;`npm run typecheck`、`npm run check:encoding` 通过。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 + ## 2026-05-26 生成页总进度圆弧逆时针回调 5 度 - 背景:创作生成页的总进度圆弧在 `160deg` 位置仍需轻微向左微调,用户要求向左逆时针回调 `5deg`。 @@ -213,11 +215,19 @@ ## 2026-05-23 拼图生成页按后端真实进度推进阶段 - 背景:拼图生成页原先会按本地耗时自动推进步骤,容易在后端真实生成尚未完成时跳到后续阶段,导致页面状态和会话进度脱节。 -- 决策:拼图生成页的跨步骤推进只认后端会话 `progressPercent` 的真实里程碑,当前步骤内部再用本地耗时假进度平滑展示;总进度初始必须为 `0%`,之后按 `0-88`、`88-94`、`94-96`、`96-98` 的真实里程碑区间平滑推进。只要当前步骤生成内容未完成,就必须停留在当前步骤。页面只展示当前步骤标题和进度,不展示步骤详细描述。`生成拼图首图` 单独按 4 分钟估算,完整 AI 重绘路径约 448 秒;上传图且关闭 AI 重绘路径跳过首图生成,仍约 208 秒。 +- 决策:拼图生成页的跨步骤推进只认后端会话 `progressPercent` 的真实里程碑,当前步骤内部再用本地耗时假进度平滑展示;`88/94/96` 只切换当前步骤,不直接作为总进度地板。总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`。恢复持久化生成中草稿时,展示态 `startedAtMs` 使用后端 session `updatedAt` 或作品摘要 `updatedAt`,保证已耗时不因重新进入页面清零。只要当前步骤生成内容未完成,就必须停留在当前步骤。页面只展示当前步骤标题和进度,不展示步骤详细描述。`生成拼图首图` 单独按 4 分钟估算,完整 AI 重绘路径约 448 秒;上传图且关闭 AI 重绘路径跳过首图生成,仍约 208 秒。 - 影响范围:`src/services/miniGameDraftGenerationProgress.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/CustomWorldGenerationView.tsx`、拼图生成页相关测试与玩法链路文档。 - 验证方式:拼图生成页恢复、轮询和测试都应以 `puzzleProgressPercent` 驱动阶段推进;`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts src/components/CustomWorldGenerationView.test.tsx`、`npm run typecheck`、`npm run check:encoding` 通过。 - 关联文档:`docs/【玩法创作】拼图生成页进度口径-2026-05-23.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-02 生成失败草稿必须留在作品架并覆盖生成中摘要 + +- 背景:生成页收到失败回包后会进入重试态,但返回草稿 Tab 时,后端作品摘要可能仍短暂保持 `generationStatus=generating`,导致用户看到“生成中”;连续触发多个拼图生成时,失败后如果清掉 pending 条目,还会少显示新增草稿。后台失败如果只写局部生成页错误,用户离开生成页后也收不到通知。 +- 决策:平台壳在生成失败时必须同时标记草稿 notice 和 pending 作品架条目为 `failed`,不得删除 pending 条目。失败 notice 要保存错误消息并在用户离开生成页后触发带来源的 `PlatformErrorDialog`;作品架本地失败 notice 要覆盖持久化生成中摘要,失败草稿仍显示为草稿卡但不显示“生成中”。点击失败草稿必须优先恢复失败 / 重试页,不能按持久化 `generating` 重新启动生成;拼图契约已允许 `generationStatus=failed`,pending 拼图和后端失败回写都按 session 独立落失败态,跳一跳 / 木鱼 / 抓大鹅等也直接映射为 `failed` 或对应失败态。 +- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/custom-world-home/CustomWorldCreationHub.tsx`、玩法链路文档和失败态交互测试。 +- 验证方式:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`;失败后返回草稿 Tab 应看到对应新增草稿,且没有“生成中”标记;后台失败应弹出错误来源,点击失败草稿应进入失败 / 重试页。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。 + ## 2026-05-23 所有玩法生成页统一圆环主视觉 - 背景:多个玩法生成页分别展示横向总进度条、步骤列表或三槽位列表,和最新参考图里的陶泥儿圆环等待态不一致,也让移动端信息密度偏高。 @@ -333,7 +343,6 @@ - 验证方式:执行 `cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/services/match3dSpritesheetParser.test.ts src/services/match3dGeneratedModelCache.test.ts`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - ## 2026-05-18 Rust 手写模块入口统一不用 mod.rs - 背景:Rust 目录模块同时存在 `mod.rs` 与同名 `.rs` 两种入口形式,前次拆分已让 `spacetime-client/src/mapper.rs` 采用同名入口;继续新增 `mod.rs` 会让文件定位和评审口径不一致。 @@ -572,7 +581,7 @@ - 验证方式:草稿页作品卡与分类页列表视觉口径保持一致;`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/design/MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md`、`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。 -2026-05-14 补充:草稿页作品卡不再用“草稿 / 已发布”文字标识状态,改为图标化 UI 状态点;作品封面直接铺到卡片右半区并从右向左渐隐;已发布作品右上角常驻分享图标;草稿长按弹出删除面板,已发布长按弹出分享和删除面板。 +2026-05-14 补充:草稿页作品卡不再用“草稿 / 已发布”文字标识状态,改为图标化 UI 状态点;作品封面直接铺到卡片右半区并从右向左渐隐;已发布作品右上角常驻分享图标;草稿长按弹出删除面板,已发布长按弹出分享和删除面板。2026-06-02 追加:作品卡片右上角不再放删除按钮;删除只通过左滑、键盘展开或长按 / 右键展开的右侧操作区出现,避免与卡片主点击和分享入口抢占标题区。 ## 2026-05-13 认证运行期同步直接导入正式认证表 @@ -759,6 +768,7 @@ - 验证方式:执行 `npx vitest run src/services/useMocapInput.test.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts`、`npx eslint ...`、`npm run typecheck`、`npm run check:encoding`,并确认 `http://127.0.0.1:8876/stream` WebSocket 可握手、`http://127.0.0.1:3000/child-motion-demo` 可访问。 ## 2026-05-18 寓教于乐频道补充热身关入口 + - 背景:用户希望在发现页的寓教于乐板块里直接看到热身关入口,而不是只依赖独立直达路由。 - 决策:`child-motion-demo` 作为寓教于乐频道的独立卡片展示,点击后直接进入 `/child-motion-demo`;该入口与 `宝贝爱画` 并列,仍复用现有独立热身关路由,不新增新的创作模板或运行态壳层。 - 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -1171,13 +1181,14 @@ - 影响范围:`module-auth`、`api-server` 作品作者解析、`AppState` 启动初始化、历史孤儿作品离线回填脚本与相关文档。 - 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`、`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。 - 关联文档:`server-rs/crates/module-auth/src/domain.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/api-server/src/work_author.rs`、`scripts/rebind-orphan-work-owners.mjs`。 + ## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径 - 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。 - 决策:新增 `GET /api/creation/wooden-fish/works` 作为当前用户木鱼作品架事实源,返回 `WoodenFishWorksResponse.items` 摘要;平台壳在发布成功后必须同时刷新作品架和公开广场列表。 - 影响范围:`server-rs/crates/api-server/src/wooden_fish.rs`、`server-rs/crates/api-server/src/modules/wooden_fish.rs`、`src/services/wooden-fish/woodenFishClient.ts`、`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`。 - 验证方式:发布一个木鱼作品后,草稿 Tab 的已发布筛选应立刻出现 `WF-*` 作品卡,推荐 / 最新流也应立即刷新出公开卡片。 - - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。 ## 2026-05-27 认证快照完全去文件化并仅保留行级备查 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 99c0eb8c..b92baeef 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -158,7 +158,6 @@ - 验证:`PlatformEntryFlowShellImpl.tsx` 中不应再出现四个旧工作台的入口渲染分支,创作 Tab 与 `/creation/` 仍能正常进入对应工作台。 - 关联:`src/components/unified-creation/UnifiedCreationWorkspace.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - ## Jenkinsfile 开头不能带 UTF-8 BOM - 现象:`Genarrative-Stdb-Module-Publish` 在 `Pipeline script from SCM` 读取 `jenkins/Jenkinsfile.production-stdb-module-publish` 后,流水线还未进入任何 stage 就失败,报 `java.lang.NoSuchMethodError: No such DSL method 'pipeline'`,堆栈位置是 `WorkflowScript.run(WorkflowScript:1)`。 @@ -362,7 +361,6 @@ - 验证:`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` 应指向 `/var/lib/genarrative/tracking-outbox`;重启后当前 PID 不再出现 `Permission denied (os error 13)`。 - 关联:`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 - ## 外部 API 失败没法追溯先查 external_api_call_failure - 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。 @@ -562,10 +560,18 @@ - 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。 - 原因:重置/修改密码会更新 `password_hash`、`password_login_enabled` 和 `token_version`,如果 API 层只更新本地 `InMemoryAuthStore`,没有调用 `sync_auth_store_snapshot_to_spacetime()`,`api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态。 -- 处理:`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后必须同步认证快照。2026-05-27 起,启动恢复只允许从 SpacetimeDB 正式认证表恢复;`auth_store_snapshot` 只保留行级记录,不再写 `default` 聚合单行,也不再把本地文件 `auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 当作恢复源。若启动时连不上 SpacetimeDB,`api-server` 等待启动恢复超时后进入依赖不可用模式,所有请求返回 `503 SERVICE_UNAVAILABLE`,`details.reason = "spacetime_startup_unavailable"`。 +- 处理:`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后必须同步认证快照。2026-05-27 起,启动恢复只允许从 SpacetimeDB 正式认证表恢复;`auth_store_snapshot` 只保留行级记录,不再写 `default` 聚合单行,也不再把本地文件 `auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 当作恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动时连不上 SpacetimeDB,`api-server` 等待启动恢复超时后进入依赖不可用模式,所有请求返回 `503 SERVICE_UNAVAILABLE`,`details.reason = "spacetime_startup_unavailable"`。 - 验证:执行 `cargo test -p module-auth password --manifest-path server-rs/Cargo.toml` 与 `cargo test -p api-server password --manifest-path server-rs/Cargo.toml`;手测时重设密码后旧密码应失败,新密码应成功,重启后仍应保持。 - 关联:`server-rs/crates/api-server/src/password_management.rs`、`server-rs/crates/api-server/src/state.rs`、`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`。 +## 密码登录失败且短信登录提示手机号已存在先查孤儿手机号索引 + +- 现象:老账号用密码登录提示“手机号或密码错误”,改用短信验证码登录又提示“手机号已存在 / 已注册”,用户卡在既不能登录也不能重新创建的状态。 +- 原因:历史版本或停服务时认证同步不完整,可能在 SpacetimeDB `auth_identity(provider=phone)` 或 `module-auth` 快照里留下 `phone_to_user_id` 映射,但对应 `user_account` / `users_by_username` 用户行已经不存在。密码登录按手机号索引找不到真实用户,短信登录尝试创建新用户时又被孤儿手机号索引挡住。 +- 处理:`export_auth_store_snapshot_from_tables` 导出时必须过滤没有 `user_account` 的 phone / wechat identity、union 索引和 refresh session;`module-auth` 从 JSON 快照恢复时也必须二次丢弃指向不存在用户的索引。运行时创建手机号用户前若发现手机号映射指向不存在的用户,应删除孤儿映射后继续创建,避免死锁态继续扩散。 +- 验证:`cargo test -p module-auth snapshot_json_drops_orphan_phone_index_before_phone_login --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth phone --manifest-path server-rs/Cargo.toml`、`cargo test -p spacetime-module auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server phone_login_reuses_existing_user_for_same_phone_number --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/spacetime-module/src/auth/procedures.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 认证本地文件快照已废弃,旧 procedure 也已删 - 现象:有些旧代码和生成 bindings 里还会残留 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot`,或者把 `auth-store.json` 误当成认证恢复源。 @@ -1542,14 +1548,22 @@ - 验证:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`。 - 关联:`src/index.css`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/rpgEntryWorldPresentation.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## 生成中草稿刷新后不要复用旧 updatedAt 当展示起点 +## 生成中草稿恢复要按后端时间戳计时 -- 现象:拼图或抓大鹅草稿生成中刷新网页后,作品架卡片能显示等待遮罩,但进入生成页时总进度首帧直接跳到 80%+,看起来像已经跑了一大半。 -- 原因:前端只把持久化 `generationStatus=generating` 当作恢复生成页的条件,但恢复展示时仍沿用了作品摘要 `updatedAt` 作为伪 `startedAtMs`;同时拼图总进度又把后端 `progressPercent` 直接当作 floor,导致 `86%` 之类未到首个里程碑的会话一进页就抬到 80%+。 -- 处理:恢复生成中的草稿时,展示起点改用“进入生成页的当前时间”;`updatedAt` 只保留给作品架排序和摘要,不再参与生成页假进度起算。拼图总进度还要忽略 `88` 以下的后端进度 floor,拼图保留后端里程碑推进,抓大鹅等非拼图玩法则从 `0%` 平滑起步,避免刚进页就看到 4% / 88% / 80%+。 -- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`、`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts -t "match3d draft generation starts total progress from zero"`。 +- 现象:拼图或抓大鹅草稿生成中刷新网页后,进入生成页的“已耗时”从 `0 秒` 重新开始;另一类旧问题是后端 `progressPercent=88` 时总进度首帧直接跳到 `88%`。 +- 原因:生成页恢复曾把展示态 `startedAtMs` 重置为进入页面的当前时间,导致计时不跟随后端真实生成时刻;拼图总进度也曾把后端里程碑当作百分比地板,导致步骤刚切换就抬高总进度。 +- 处理:恢复生成中的草稿时,展示起点使用后端 session `updatedAt` 或作品摘要 `updatedAt`;`88/94/96` 只切换当前步骤,不直接作为总进度地板。总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`。 +- 验证:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`、`node node_modules/vitest/vitest.mjs run src/services/miniGameDraftGenerationProgress.test.ts`。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`src/services/miniGameDraftGenerationProgress.ts`、`docs/【玩法创作】拼图生成页进度口径-2026-05-23.md`。 +## 生成失败草稿回到作品架不能继续显示生成中 + +- 现象:拼图生成页已经收到 VectorEngine 图片编辑失败并进入重试态,但用户返回草稿 Tab 后,同一草稿仍显示“生成中”;连续触发多个拼图生成时,失败后还可能只剩一条新增草稿,或者只看到标题为“第1关”的半成品空壳;抓大鹅后台失败时也可能没有任何通知,点击草稿又像重新开始生成。 +- 原因:前端失败 notice 只更新生成页局部状态,pending 作品架条目在失败时被清掉或被非 `generating` 状态误映射为 `ready`;后端作品摘要也可能短暂仍是 `generationStatus=generating`。如果失败消息没有写入 notice,用户离开生成页后不会弹出 `PlatformErrorDialog`;如果打开草稿只看持久化 `generating`,就会绕过失败态恢复。 +- 处理:失败时按 session 保留 pending 作品架条目并标记 `failed`,失败 notice 保存错误消息并触发带来源的 `PlatformErrorDialog`;拼图契约没有 `failed` 枚举,pending 拼图映射为 `idle`,同时用本地失败 notice 覆盖持久化生成中状态和旧的“正在生成”摘要。点击失败草稿应优先用 notice / 后端 session / fallback payload 组装失败生成页,不能重新从 0 秒启动新进度;拼图失败半成品没有有效 `workTitle` 时,作品架标题回退为“拼图草稿”。 +- 验证:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/custom-world-home/creationWorkShelf.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 汪汪声浪草稿试玩不要写正式 run - 现象:如果草稿结果页试玩和发布后 runtime 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 0cf69286..41e555e1 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -242,10 +242,12 @@ npm run check:server-rs-ddd - Rust 结构体:`AuthStoreSnapshot` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` -认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。 +认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。 `auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON。认证同步入口收到 `module-auth` 整份快照后必须拆成行级记录写入同一张表,当前行键前缀包括:`meta/next_user_id`、`user/`、`phone/`、`session/`、`session_hash/`、`wechat/`、`union/`。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json` 与 `export_auth_store_snapshot_from_tables` 两个认证快照过程;旧 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。 +导出认证快照时,`auth_identity` 与 `refresh_session` 只能引用仍存在于 `user_account` 的用户;孤儿手机号 identity、微信 identity、union 索引或 refresh session 必须被过滤,不能恢复成 `module-auth` 内存态里的 `phone_to_user_id` 死索引。`module-auth` 从 JSON 快照恢复时也要二次清理这些孤儿索引,避免历史坏快照导致密码登录提示错误、短信登录又提示手机号已存在。 + ### `bark_battle_draft_config` - Rust 结构体:`BarkBattleDraftConfigRow` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index d367bf19..cf5e893f 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -14,7 +14,7 @@ 创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 -平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner;表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。 +平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner;表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。生成任务在用户离开生成页后异步失败时,也必须通过同一弹窗通知用户,并把失败消息写入该 session 的草稿 notice,供草稿页和失败重试页恢复使用。 生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。 @@ -47,10 +47,11 @@ 3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作在作品卡上也要直接开放独立删除入口,左滑或长按仅作为辅助操作层。 4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。 5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。 -6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算。 -7. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。 -8. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 -9. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。 +6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 优先使用后端 session 的 `updatedAt`,没有 session 时再使用作品摘要 `updatedAt`,不得因重新进入页面从 0 秒重新计时。 +7. 生成失败必须按 session 独立记录,不能用一个失败打断或覆盖同玩法的其它生成任务。失败 notice 需要保存错误消息并覆盖作品架本地状态:即使后端摘要暂时仍是 `generationStatus=generating` 或只写出半成品投影,草稿卡也不得继续显示“生成中”,点击后必须进入失败 / 重试生成页,不能重新创建一轮生成;拼图这类失败半成品若没有有效 `workTitle`,作品架标题回退为“拼图草稿”,不暴露“第1关”空壳。 +8. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。 +9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 +10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。 发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 @@ -95,9 +96,9 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 图像输入复用 `CreativeImageInputPanel`。 - 结果页每关画面编辑复用 `CreativeImageInputPanel`;入口页和关卡画面只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`。结果页删除独立“素材配置”Tab,不再提供单独 UI 背景生成入口。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在正式图自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。用户在本次编辑中上传或选择历史图后,该图优先占据主图卡片,可删除、切换 AI 重绘,也可关闭 AI 重绘直用;仅有正式图预览时,画面描述框仍可上传多张参考图。关卡详情弹窗应使用加宽面板,关卡名称、画面图和画面描述合并在同一个纵向列表中,名称输入和画面编辑模块外层不再包独立 `platform-subpanel`;画面图卡仍必须保留稳定最小高度,避免弹窗内 `flex-1` 布局坍缩后只剩标题、描述输入和操作按钮。 - 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。 -- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进,总进度按 `0-88`、`88-94`、`94-96`、`96-98` 的真实里程碑区间平滑推进。任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。 -- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。 -- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次图片生成调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须使用进入生成页的当前时间作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。 +- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进。`88/94/96` 只负责切换当前步骤,不作为总进度地板;总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`。任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。 +- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。生成失败后,同一浏览器会话内的失败 notice 必须覆盖后端可能仍短暂返回的 `generationStatus=generating` 摘要,作品架保留对应草稿卡但不再显示“生成中”,点击后回到失败 / 重试状态。 +- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次图片生成调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须使用后端 session `updatedAt` 或作品摘要 `updatedAt` 作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。 - 前端创作、结果页、生成页和错误提示不展示 GPT / Gemini 等具体模型名称;如需在内部保留模型路由,UI 只使用“标准模式”“创意模式”等产品化名称。 - 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。 - 拼图参考图 AI 重绘走 VectorEngine `/v1/images/edits`;无参考图时走 `/v1/images/generations`。两者模型都使用 `gpt-image-2`,参考图由后端作为 multipart `image` part 传入编辑接口。 diff --git a/docs/【玩法创作】拼图生成页进度口径-2026-05-23.md b/docs/【玩法创作】拼图生成页进度口径-2026-05-23.md index bc0ae2a0..c5314d86 100644 --- a/docs/【玩法创作】拼图生成页进度口径-2026-05-23.md +++ b/docs/【玩法创作】拼图生成页进度口径-2026-05-23.md @@ -1,6 +1,6 @@ # 拼图生成页进度口径 -更新时间:`2026-05-24` +更新时间:`2026-06-02` ## 目标 @@ -8,14 +8,14 @@ ## 落地口径 -- 总进度和当前步骤内百分比可以按已耗时平滑增长,但进入生成页的初始帧必须从 `0%` 开始,非完成态最多停在 `98%`。 -- 未收到首个真实里程碑前,页面仍停留在当前步骤,总进度在 `0-88` 区间内平滑推进;收到 `88/94/96` 里程碑后,分别在 `88-94`、`94-96`、`96-98` 区间内推进,避免步骤不跳时总进度也停死。 -- 后端 `progressPercent` 低于 `88` 只作为当前会话状态记录,不得把生成页阶段推到首个图片里程碑;低于首个里程碑时页面仍按当前视图进入时间从 `0%` 平滑展示。 +- 总进度和当前步骤内百分比可以按已耗时平滑增长;新发起的生成初始帧从 `0%` 开始,恢复持久化生成中草稿时必须按后端 session / 作品摘要时间戳推导已耗时,不能每次重新从 `0 秒` 起算。非完成态最多停在 `98%`。 +- 后端 `progressPercent` 的 `88/94/96` 只用于切换当前真实步骤,不得直接作为总进度地板;总进度应按已完成步骤权重加当前步骤内假进度推导,避免恢复或轮询后瞬间跳到 `88%`。 +- 后端 `progressPercent` 低于 `88` 只作为当前会话状态记录,不得把生成页阶段推到首个图片里程碑,也不得抬高首帧总进度。 - 步骤状态以真实阶段为准:`phase` / 后端会话进度 / 最终完成或失败回包才允许跨步。 -- 拼图生成页恢复持久化 `generationStatus=generating` 草稿时,展示进度使用“进入生成页的当前时间”作为 `startedAtMs`;不得再用作品摘要 `updatedAt` 推导展示起点,避免刷新后首帧直接跳到 `80%+`。 -- 拼图和抓大鹅等生成页从作品架 / 刷新恢复进入时,前端应把展示态生成状态重基准到进入页面的当前时间;后台 session 的 `progressPercent` 与历史里程碑只保留为状态事实,不得直接作为首帧总进度。 +- 拼图和抓大鹅等生成页从作品架 / 刷新恢复进入时,前端应优先使用后端 session `updatedAt` 或作品摘要 `updatedAt` 作为展示态 `startedAtMs`,保证已耗时与后端生成时间对齐;后台 session 的 `progressPercent` 只负责真实步骤推进,不直接决定总进度百分比。 +- 生成失败时,生成页冻结为失败 / 重试状态;同一浏览器会话内返回草稿 Tab 时,失败草稿必须继续出现在作品架,且本地失败 notice 要覆盖后端仍可能短暂返回的 `generationStatus=generating` 摘要,不能继续显示“生成中”。 - 当前步骤未完成时,后续步骤保持待处理;即使预计时间耗尽,也只能让当前步骤内部进度接近或达到上限,不能自动完成后续步骤。 -- 抓大鹅等非拼图小游戏的生成页也遵守初始帧 `0%`:没有后端资产计数时,当前步骤内假进度按玩法预计等待总时长从 `0` 平滑推进,不使用固定 `0.5` 这类常量起步。 +- 抓大鹅等非拼图小游戏的生成页也遵守同一恢复口径:没有后端资产计数时,当前步骤内假进度按玩法预计等待总时长平滑推进,不使用固定 `0.5` 这类常量起步,也不在未完成时显示 `100%`。 - 步骤卡片只展示标题和进度,不展示详细描述。 - 生成拼图首图步骤按 4 分钟预估;完整 AI 重绘路径总预计时长为 448 秒,上传图且关闭 AI 重绘时跳过首图生成,仍为 208 秒。 @@ -25,5 +25,6 @@ - `src/services/miniGameDraftGenerationProgress.test.ts` 覆盖后端 `progressPercent < 88` 时不会抬高进入生成页的初始总进度。 - `src/services/miniGameDraftGenerationProgress.test.ts` 覆盖抓大鹅等非拼图生成页初始总进度为 `0%`。 - `src/components/CustomWorldGenerationView.test.tsx` 覆盖步骤详情不在生成页渲染。 -- `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"` 覆盖刷新后继续生成中拼图 / 抓大鹅草稿不会继承旧 `updatedAt` 导致总进度首帧过高。 +- `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"` 覆盖刷新后继续生成中拼图 / 抓大鹅草稿按后端时间戳恢复,且不会因后端里程碑直接跳到 `88%` 或 `100%`。 +- `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle generations"` 覆盖失败后的 pending 拼图草稿仍留在作品架,并且不再显示“生成中”。 - 文档主图谱的拼图章节同步保留该口径。 diff --git a/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md index 7236ab89..91b4980c 100644 --- a/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md +++ b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md @@ -1,6 +1,6 @@ # 生成页圆环布局口径 -更新时间:`2026-05-30` +更新时间:`2026-06-02` ## 目标 @@ -17,7 +17,7 @@ - 在窄屏下,预计等待 / 已耗时信息卡放到圆环下方两列排布;`sm` 及以上视口再回到圆环左右悬浮,避免左右悬浮卡和圆环共同超过视口宽度。 - 总进度标题和百分比数字必须显式高于 SVG 圆环层级渲染,避免被圆环边缘压住;圆环本身只做背景层,不抢文字层。 - 总进度标题和百分比数字要比圆环再上移一点,当前内容区上边距以 `pt-[2%]` 为准,桌面端可进一步微调到 `sm:pt-[1.5%]`,确保数字不与进度条弧线重合。 -- 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须重置“展示态 startedAtMs”为进入生成页的当前时间;后端 `progressPercent` 只用于后续真实步骤推进,不得参与首帧总进度展示,避免恢复生成页首帧直接显示 `80%+`。 +- 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须按后端 session `updatedAt` 或作品摘要 `updatedAt` 恢复展示态 `startedAtMs`,保证“已耗时”不因重新进入页面而清零;后端 `progressPercent` 只用于真实步骤推进,不得直接作为总进度地板,避免恢复生成页首帧直接显示 `88%` 或 `100%`。 - 生成页只展示半透明“当前步骤”单卡,卡片内只保留步骤名称、步骤状态、步骤进度条和轻量加载指示;“当前步骤”标签使用 `10px-11px`,步骤名称使用 `14px-15px`,状态使用 `11px-12px`,不再渲染步骤列表或步骤详情。 - 当前作品信息放在圆角信息卡中,标题固定使用 `13px`;有结构化字段时以两列信息块展示,例如“题材 / 素材数量”,无结构化字段时才展示纯文本设定。 - 汪汪声浪生成页 `BarkBattleGeneratingView` 也必须对齐同一垂直布局,不再继续展示三行槽位列表或左右分栏抢占主视觉。 diff --git a/packages/shared/src/contracts/puzzleAgentDraft.ts b/packages/shared/src/contracts/puzzleAgentDraft.ts index 38fa22fb..3b8cdbaf 100644 --- a/packages/shared/src/contracts/puzzleAgentDraft.ts +++ b/packages/shared/src/contracts/puzzleAgentDraft.ts @@ -62,7 +62,7 @@ export interface PuzzleDraftLevel { selectedCandidateId: string | null; coverImageSrc: string | null; coverAssetId: string | null; - generationStatus: 'idle' | 'generating' | 'ready'; + generationStatus: 'idle' | 'generating' | 'ready' | 'failed'; } export interface PuzzleResultDraft { @@ -78,7 +78,7 @@ export interface PuzzleResultDraft { selectedCandidateId: string | null; coverImageSrc: string | null; coverAssetId: string | null; - generationStatus: 'idle' | 'generating' | 'ready'; + generationStatus: 'idle' | 'generating' | 'ready' | 'failed'; levels?: PuzzleDraftLevel[]; formDraft?: { workTitle?: string; diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index 816ba472..94c3781a 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -40,6 +40,15 @@ pub async fn password_entry( state.password_entry_service().execute(input).await } .map_err(map_password_entry_error)?; + let session_client = resolve_session_client_context(&headers); + let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; if result.created { crate::registration_reward::grant_new_user_registration_wallet_reward( &state, @@ -48,8 +57,6 @@ pub async fn password_entry( ) .await; } - let session_client = resolve_session_client_context(&headers); - let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; record_daily_login_tracking_event_after_auth_success( &state, &request_context, @@ -57,13 +64,6 @@ pub async fn password_entry( AuthLoginMethod::Password, ) .await; - state - .sync_auth_store_snapshot_to_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("同步认证快照失败:{error}")) - })?; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index 9d305c68..94e5177e 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -100,13 +100,6 @@ pub async fn reset_password( &session_client, module_auth::AuthLoginMethod::Password, )?; - record_daily_login_tracking_event_after_auth_success( - &state, - &request_context, - &result.user.id, - module_auth::AuthLoginMethod::Password, - ) - .await; state .sync_auth_store_snapshot_to_spacetime() .await @@ -114,6 +107,13 @@ pub async fn reset_password( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_message(format!("同步认证快照失败:{error}")) })?; + record_daily_login_tracking_event_after_auth_success( + &state, + &request_context, + &result.user.id, + module_auth::AuthLoginMethod::Password, + ) + .await; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 31e64afa..1b8788ca 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -151,6 +151,20 @@ pub async fn phone_login( } }; let created = result.created; + let session_client = resolve_session_client_context(&headers); + let signed_session = create_auth_session( + &state, + &result.user, + &session_client, + AuthLoginMethod::Phone, + )?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; if created { crate::registration_reward::grant_new_user_registration_wallet_reward( &state, @@ -170,13 +184,6 @@ pub async fn phone_login( } else { None }; - let session_client = resolve_session_client_context(&headers); - let signed_session = create_auth_session( - &state, - &result.user, - &session_client, - AuthLoginMethod::Phone, - )?; record_daily_login_tracking_event_after_auth_success( &state, &request_context, @@ -184,13 +191,6 @@ pub async fn phone_login( AuthLoginMethod::Phone, ) .await; - state - .sync_auth_store_snapshot_to_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("同步认证快照失败:{error}")) - })?; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index 1f3b53db..276a29f5 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -2,8 +2,8 @@ use super::*; pub(crate) fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { - title: None, - work_description: None, + title: payload.work_title.as_deref(), + work_description: payload.work_description.as_deref(), picture_description: payload .picture_description .as_deref() @@ -32,8 +32,8 @@ pub(crate) async fn save_puzzle_form_payload_before_compile( now: i64, ) -> Result { let seed_text = build_puzzle_form_seed_text_from_parts( - None, - None, + payload.work_title.as_deref(), + payload.work_description.as_deref(), payload .picture_description .as_deref() diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index ab594f07..afd6f3cf 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -725,8 +725,8 @@ pub async fn execute_puzzle_agent_action( } "save_puzzle_form_draft" => { let seed_text = build_puzzle_form_seed_text_from_parts( - None, - None, + payload.work_title.as_deref(), + payload.work_description.as_deref(), payload .picture_description .as_deref() diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index d4bca634..86512e7d 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -384,6 +384,28 @@ fn puzzle_compile_error_preserves_vector_engine_unavailable_status() { assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); } +#[test] +fn puzzle_form_seed_text_includes_work_metadata() { + let payload = CreatePuzzleAgentSessionRequest { + seed_text: Some("旧 seed 会被画面描述兜底覆盖。".to_string()), + work_title: Some("雨夜猫街".to_string()), + work_description: Some("123".to_string()), + picture_description: Some("一只猫在雨夜灯牌下回头。".to_string()), + reference_image_src: None, + reference_image_srcs: Vec::new(), + reference_image_asset_object_id: None, + reference_image_asset_object_ids: Vec::new(), + image_model: None, + ai_redraw: Some(true), + }; + + let seed_text = build_puzzle_form_seed_text(&payload); + + assert!(seed_text.contains("作品名称:雨夜猫街")); + assert!(seed_text.contains("作品描述:123")); + assert!(seed_text.contains("画面描述:一只猫在雨夜灯牌下回头。")); +} + #[tokio::test] async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() { let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index 9276ce1b..2c45a948 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -56,13 +56,6 @@ pub async fn refresh_session( Some(&rotated.session.issued_by_provider), Some(&rotated.session.client_info), )?; - record_daily_login_tracking_event_after_auth_success( - &state, - &request_context, - &rotated.user.id, - rotated.session.issued_by_provider.clone(), - ) - .await; state .sync_auth_store_snapshot_to_spacetime() .await @@ -70,6 +63,13 @@ pub async fn refresh_session( AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) .with_message(format!("同步认证快照失败:{error}")) })?; + record_daily_login_tracking_event_after_auth_success( + &state, + &request_context, + &rotated.user.id, + rotated.session.issued_by_provider.clone(), + ) + .await; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index c0c61378..89a80331 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -591,7 +591,7 @@ impl AppState { ) .map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?; // 当前进程内 auth_store 是认证请求的即时工作集;SpacetimeDB 正式认证表用于跨进程恢复。 - // 远端数据库挂起或网络异常时,只降级后续恢复能力,不能让已成功的登录/刷新/退出回滚为失败。 + // 认证变更必须在返回客户端前写入 SpacetimeDB,避免只在本进程内成功、重启后丢失账号或会话。 #[cfg(not(test))] if let Err(error) = self .spacetime_client @@ -600,9 +600,9 @@ impl AppState { { warn!( error = %error, - "认证快照导入 SpacetimeDB 正式表失败,当前认证流程继续" + "认证快照导入 SpacetimeDB 正式表失败,当前认证流程中止" ); - return Ok(()); + return Err(error); } #[cfg(not(test))] Ok(()) diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index d7dfc858..f2766959 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -145,13 +145,6 @@ pub async fn handle_wechat_callback( &session_client, AuthLoginMethod::Wechat, )?; - record_daily_login_tracking_event_after_auth_success( - &state, - &request_context, - &result.user.id, - AuthLoginMethod::Wechat, - ) - .await; state .sync_auth_store_snapshot_to_spacetime() .await @@ -159,6 +152,13 @@ pub async fn handle_wechat_callback( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_message(format!("同步认证快照失败:{error}")) })?; + record_daily_login_tracking_event_after_auth_success( + &state, + &request_context, + &result.user.id, + AuthLoginMethod::Wechat, + ) + .await; let mut response = Redirect::to(&build_auth_result_redirect_url( &redirect_path, &[ @@ -241,6 +241,20 @@ pub async fn bind_wechat_phone( .await .map_err(map_wechat_bind_phone_error)? }; + let session_client = resolve_session_client_context(&headers); + let signed_session = create_auth_session( + &state, + &result.user, + &session_client, + AuthLoginMethod::Wechat, + )?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步认证快照失败:{error}")) + })?; if result.activated_new_user { crate::registration_reward::grant_new_user_registration_wallet_reward( &state, @@ -249,13 +263,6 @@ pub async fn bind_wechat_phone( ) .await; } - let session_client = resolve_session_client_context(&headers); - let signed_session = create_auth_session( - &state, - &result.user, - &session_client, - AuthLoginMethod::Wechat, - )?; record_daily_login_tracking_event_after_auth_success( &state, &request_context, @@ -263,13 +270,6 @@ pub async fn bind_wechat_phone( AuthLoginMethod::Wechat, ) .await; - state - .sync_auth_store_snapshot_to_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("同步认证快照失败:{error}")) - })?; let mut response_headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 66f31450..3b0e5677 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -11,7 +11,7 @@ pub use errors::*; pub use events::*; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, sync::{Arc, Mutex}, }; @@ -918,16 +918,47 @@ impl Default for InMemoryAuthStoreState { impl InMemoryAuthStoreState { fn from_persistent_snapshot(snapshot: PersistentAuthStoreSnapshot) -> Self { + let existing_user_ids = snapshot + .users_by_username + .values() + .map(|stored| stored.user.id.clone()) + .collect::>(); + let phone_to_user_id = snapshot + .phone_to_user_id + .into_iter() + .filter(|(_, user_id)| existing_user_ids.contains(user_id)) + .collect(); + let sessions_by_id = snapshot + .sessions_by_id + .into_iter() + .filter(|(_, stored)| existing_user_ids.contains(&stored.session.user_id)) + .collect::>(); + let session_id_by_refresh_token_hash = snapshot + .session_id_by_refresh_token_hash + .into_iter() + .filter(|(_, session_id)| sessions_by_id.contains_key(session_id)) + .collect(); + let wechat_identity_by_provider_uid = snapshot + .wechat_identity_by_provider_uid + .into_iter() + .filter(|(_, identity)| existing_user_ids.contains(&identity.user_id)) + .collect(); + let user_id_by_provider_union_id = snapshot + .user_id_by_provider_union_id + .into_iter() + .filter(|(_, user_id)| existing_user_ids.contains(user_id)) + .collect(); + Self { next_user_id: snapshot.next_user_id, users_by_username: snapshot.users_by_username, - phone_to_user_id: snapshot.phone_to_user_id, - sessions_by_id: snapshot.sessions_by_id, - session_id_by_refresh_token_hash: snapshot.session_id_by_refresh_token_hash, + phone_to_user_id, + sessions_by_id, + session_id_by_refresh_token_hash, phone_codes_by_key: HashMap::new(), wechat_states_by_token: HashMap::new(), - wechat_identity_by_provider_uid: snapshot.wechat_identity_by_provider_uid, - user_id_by_provider_union_id: snapshot.user_id_by_provider_union_id, + wechat_identity_by_provider_uid, + user_id_by_provider_union_id, } } @@ -1159,10 +1190,17 @@ impl InMemoryAuthStore { .inner .lock() .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; - if state.phone_to_user_id.contains_key(&phone_number.e164) { - return Err(PhoneAuthError::Store( - "手机号已存在,无法重复创建账号".to_string(), - )); + if let Some(existing_user_id) = state.phone_to_user_id.get(&phone_number.e164).cloned() { + let existing_user_exists = state + .users_by_username + .values() + .any(|stored_user| stored_user.user.id == existing_user_id); + if existing_user_exists { + return Err(PhoneAuthError::Store( + "手机号已存在,无法重复创建账号".to_string(), + )); + } + state.phone_to_user_id.remove(&phone_number.e164); } let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { @@ -1213,8 +1251,15 @@ impl InMemoryAuthStore { .inner .lock() .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; - if state.phone_to_user_id.contains_key(&phone_number.e164) { - return Err(PasswordEntryError::InvalidCredentials); + if let Some(existing_user_id) = state.phone_to_user_id.get(&phone_number.e164).cloned() { + let existing_user_exists = state + .users_by_username + .values() + .any(|stored_user| stored_user.user.id == existing_user_id); + if existing_user_exists { + return Err(PasswordEntryError::InvalidCredentials); + } + state.phone_to_user_id.remove(&phone_number.e164); } let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { @@ -2629,6 +2674,54 @@ mod tests { assert_eq!(rotated.user.id, user.id); } + #[tokio::test] + async fn snapshot_json_drops_orphan_phone_index_before_phone_login() { + let snapshot = PersistentAuthStoreSnapshot { + next_user_id: 9, + users_by_username: HashMap::new(), + phone_to_user_id: HashMap::from([( + "+8613800138032".to_string(), + "user_missing_phone_owner".to_string(), + )]), + sessions_by_id: HashMap::new(), + session_id_by_refresh_token_hash: HashMap::new(), + wechat_identity_by_provider_uid: HashMap::new(), + user_id_by_provider_union_id: HashMap::new(), + }; + let snapshot_json = serde_json::to_string(&snapshot).expect("snapshot should serialize"); + let restored_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) + .expect("snapshot json should restore"); + let phone_service = build_phone_service(restored_store); + let now = OffsetDateTime::now_utc(); + + phone_service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138032".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone code should send"); + let result = phone_service + .login( + PhoneLoginInput { + phone_number: "13800138032".to_string(), + verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("orphan phone index should not block phone login"); + + assert!(result.created); + assert_eq!( + result.user.phone_number_masked.as_deref(), + Some("138****8032") + ); + } + #[tokio::test] async fn password_entry_rejects_email_or_username_identifier() { let service = build_password_service(build_store()); diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index 8ae86d70..5f93eb01 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -454,6 +454,10 @@ fn export_auth_store_snapshot_from_tables_tx( .meta_id() .find(&AUTH_STORE_PROJECTION_META_ID.to_string()) .map(|row| row.updated_at.to_micros_since_unix_epoch()); + let valid_user_ids = users + .iter() + .map(|user| user.user_id.clone()) + .collect::>(); let mut phone_identity_by_user_id = std::collections::HashMap::new(); let mut phone_to_user_id = std::collections::HashMap::new(); @@ -461,6 +465,10 @@ fn export_auth_store_snapshot_from_tables_tx( let mut user_id_by_provider_union_id = std::collections::HashMap::new(); for identity in identities { + if !valid_user_ids.contains(&identity.user_id) { + continue; + } + match identity.provider.as_str() { "phone" => { let phone_number = identity @@ -529,6 +537,10 @@ fn export_auth_store_snapshot_from_tables_tx( let mut sessions_by_id = std::collections::HashMap::new(); let mut session_id_by_refresh_token_hash = std::collections::HashMap::new(); for session in sessions { + if !valid_user_ids.contains(&session.user_id) { + continue; + } + let client_info = serde_json::from_str::(&session.client_info_json) .map_err(|error| format!("refresh session 客户端信息 JSON 解析失败:{error}"))?; session_id_by_refresh_token_hash.insert( @@ -693,10 +705,9 @@ mod tests { #[test] fn auth_store_snapshot_user_row_key_is_stable_after_username_change() { - let mut before = sample_snapshot(); + let before = sample_snapshot(); let mut after = sample_snapshot(); - after.users_by_username.clear(); - let mut renamed_user = before + let mut renamed_user = after .users_by_username .remove("phone_42") .expect("sample user exists"); diff --git a/server-rs/crates/spacetime-module/src/custom_world.rs b/server-rs/crates/spacetime-module/src/custom_world.rs index d17c6910..6e88121e 100644 --- a/server-rs/crates/spacetime-module/src/custom_world.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -5521,6 +5521,7 @@ mod tests { deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), + visible: true, } } diff --git a/server-rs/crates/spacetime-module/src/wooden_fish.rs b/server-rs/crates/spacetime-module/src/wooden_fish.rs index d4dc56de..dbfc0693 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish.rs @@ -1415,6 +1415,7 @@ mod tests { height: 1536, })), back_button_asset_json: None, + visible: true, } } } diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 1d0d9680..94a559da 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -560,7 +560,7 @@ test('creation hub shows RPG public work code from published library entry', () expect(screen.queryByText('CW-00000001')).toBeNull(); }); -test('creation hub exposes persisted draft delete action directly on the card', () => { +test('creation hub keeps persisted draft delete action off the card header', () => { const { container } = render( { @@ -641,6 +641,75 @@ test('creation hub reveals persisted draft delete action from keyboard', async ( expect(screen.queryByRole('button', { name: '分享' })).toBeNull(); }); +test('creation hub reveals persisted draft delete action from long press menu', () => { + const { container } = render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onDeletePublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + />, + ); + + const card = screen.getByRole('button', { name: /继续完善《潮雾列岛》/u }); + fireEvent.contextMenu(card); + + expect( + container.querySelector('.creation-work-card-shell--actions-visible'), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); +}); + +test('creation hub gives every deletable work card a side delete action', () => { + const { container } = render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onDeletePublished={() => {}} + onDeleteBabyObjectMatch={() => {}} + onDeletePuzzle={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + />, + ); + + expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); + expect( + container.querySelectorAll('.creation-work-card__swipe-underlay'), + ).toHaveLength(3); +}); + test('creation hub shows delete action for baby object match drafts', async () => { const user = userEvent.setup(); const onDeleteBabyObjectMatch = vi.fn(); @@ -719,7 +788,7 @@ test('creation hub works-only tab filters bark battle draft and published works' expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(barkBattlePublishedItem); }); -test('creation hub published work delete action is directly visible', async () => { +test('creation hub published work delete action stays in revealed side actions', async () => { const user = userEvent.setup(); const onDeletePuzzle = vi.fn(); const onOpenPuzzleDetail = vi.fn(); @@ -759,9 +828,11 @@ test('creation hub published work delete action is directly visible', async () = />, ); - expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); + screen.getByRole('button', { name: /查看详情《待删拼图》/u }).focus(); + await user.keyboard('{ArrowLeft}'); await user.click(screen.getByRole('button', { name: '删除' })); expect(onDeletePuzzle).toHaveBeenCalledWith( @@ -770,7 +841,7 @@ test('creation hub published work delete action is directly visible', async () = expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); }); -test('creation hub exposes work delete action directly on card', async () => { +test('creation hub reveals draft work delete action from keyboard', async () => { const user = userEvent.setup(); const onDeletePuzzle = vi.fn(); const onOpenPuzzleDetail = vi.fn(); @@ -810,6 +881,10 @@ test('creation hub exposes work delete action directly on card', async () => { />, ); + expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); + + screen.getByRole('button', { name: /继续创作《直接删除拼图》/u }).focus(); + await user.keyboard('{ArrowLeft}'); await user.click(screen.getByRole('button', { name: '删除' })); expect(onDeletePuzzle).toHaveBeenCalledWith( @@ -858,7 +933,9 @@ test('creation hub keeps swipe delete action available', async () => { />, ); - const card = screen.getByRole('button', { name: /查看详情《左滑删除拼图》/u }); + const card = screen.getByRole('button', { + name: /查看详情《左滑删除拼图》/u, + }); fireEvent.touchStart(card, { touches: [{ clientX: 180, clientY: 20 }], }); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 857f9f48..106934ab 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -22,6 +22,7 @@ import { buildCreationWorkShelfItems, type CreationWorkShelfItem, type CreationWorkShelfMetricId, + type CreationWorkShelfRuntimeState, } from './creationWorkShelf'; import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard'; import { CustomWorldWorkCard } from './CustomWorldWorkCard'; @@ -66,7 +67,9 @@ type CustomWorldCreationHubProps = { onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void; onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null; woodenFishItems?: WoodenFishWorkSummaryResponse[]; - onOpenWoodenFishDetail?: ((item: WoodenFishWorkSummaryResponse) => void) | null; + onOpenWoodenFishDetail?: + | ((item: WoodenFishWorkSummaryResponse) => void) + | null; onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null; puzzleItems?: PuzzleWorkSummary[]; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; @@ -84,7 +87,7 @@ type CustomWorldCreationHubProps = { onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null; getWorkState?: ( item: CreationWorkShelfItem, - ) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null; + ) => CreationWorkShelfRuntimeState | null; onOpenShelfItem?: (item: CreationWorkShelfItem) => void; mode?: 'full' | 'start-only' | 'works-only'; }; diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 00f38323..2f3cc588 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -1,5 +1,6 @@ import { BadgeCheck, + CircleAlert, Clock3, Loader2, Share2, @@ -439,11 +440,8 @@ export function CustomWorldWorkCard({ return; } - updateSwipeOffset( - gesture, - event.clientX, - event.clientY, - () => event.preventDefault(), + updateSwipeOffset(gesture, event.clientX, event.clientY, () => + event.preventDefault(), ); }; @@ -473,9 +471,7 @@ export function CustomWorldWorkCard({ } }; - const beginTouchSwipeGesture = ( - event: ReactTouchEvent, - ) => { + const beginTouchSwipeGesture = (event: ReactTouchEvent) => { if (swipeRevealWidth <= 0) { return; } @@ -494,20 +490,15 @@ export function CustomWorldWorkCard({ }; }; - const updateTouchSwipeGesture = ( - event: ReactTouchEvent, - ) => { + const updateTouchSwipeGesture = (event: ReactTouchEvent) => { const gesture = swipeGestureRef.current; const touch = event.touches[0]; if (!gesture || gesture.pointerId !== -1 || !touch) { return; } - updateSwipeOffset( - gesture, - touch.clientX, - touch.clientY, - () => event.preventDefault(), + updateSwipeOffset(gesture, touch.clientX, touch.clientY, () => + event.preventDefault(), ); }; @@ -676,8 +667,8 @@ export function CustomWorldWorkCard({ {displayTitle} -
- {canUseShareAction ? ( + {canUseShareAction ? ( +
- ) : null} - {onDelete ? ( - - ) : null} -
+
+ ) : null}
@@ -762,6 +723,16 @@ export function CustomWorldWorkCard({ {item.summary}
+ {item.hasGenerationFailure ? ( +
+
+ ) : null} + {isPublished ? (
{item.pointIncentive ? ( diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index 180e1e7a..e0866d6d 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -93,7 +93,9 @@ test('buildCreationWorkShelfItems maps wooden fish items with WF public code', ( expect(items[0]?.sharePath).toContain('/works/detail?work=WF-12345678'); expect(items[0]?.openActionLabel).toBe('查看详情'); expect(items[0]?.badges.some((badge) => badge.label === '敲木鱼')).toBe(true); - expect(items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value).toBe(9); + expect( + items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value, + ).toBe(9); expect(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork); }); @@ -211,9 +213,9 @@ test('buildCreationWorkShelfItems keeps separate bark battle draft and published expect(items.find((item) => item.status === 'published')?.id).toBe( 'BB-PUB00001', ); - expect(items.find((item) => item.status === 'published')?.publicWorkCode).toBe( - 'BB-PUB00001', - ); + expect( + items.find((item) => item.status === 'published')?.publicWorkCode, + ).toBe('BB-PUB00001'); }); test('buildCreationWorkShelfItems falls back to deterministic RPG public work code when library entry is missing', () => { @@ -303,10 +305,9 @@ test('buildCreationWorkShelfItems gives bark battle draft cover from character o expect(items.find((item) => item.id === 'BB-COVER001')?.coverImageSrc).toBe( '/draft-player-cover.png', ); - expect(items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs).toEqual([ - '/draft-player-cover.png', - '/draft-opponent-cover.png', - ]); + expect( + items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs, + ).toEqual(['/draft-player-cover.png', '/draft-opponent-cover.png']); expect(items.find((item) => item.id === 'BB-COVER002')?.coverImageSrc).toBe( '/creation-type-references/bark-battle.webp', ); @@ -457,14 +458,76 @@ test('buildCreationWorkShelfItems restores persisted generation state for puzzle ], }); - expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe( - true, - ); + expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(true); expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe( true, ); }); +test('buildCreationWorkShelfItems lets failure notice override persisted generating copy', () => { + const items = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [ + { + workId: 'puzzle:failed-generating', + profileId: 'puzzle-profile-failed-generating', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-failed-generating', + authorDisplayName: '测试作者', + levelName: '失败拼图', + summary: '正在生成拼图草稿。', + themeTags: [], + coverImageSrc: null, + publicationStatus: 'draft', + updatedAt: '2026-05-08T00:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + }, + ], + getItemState: (item) => + item.kind === 'puzzle' + ? { + isGenerating: false, + suppressPersistedGenerating: true, + summaryOverride: '拼图草稿生成失败,可重新打开处理。', + } + : null, + }); + + expect(items[0]?.isGenerating).toBe(false); + expect(items[0]?.summary).toBe('拼图草稿生成失败,可重新打开处理。'); +}); + +test('persisted failed puzzle draft is not treated as generating', () => { + const items = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [ + { + workId: 'puzzle:failed', + profileId: 'puzzle-profile-failed', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-failed', + authorDisplayName: '测试作者', + levelName: '失败拼图', + summary: '服务端已回写失败。', + themeTags: [], + coverImageSrc: null, + publicationStatus: 'draft', + updatedAt: '2026-05-08T00:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'failed', + }, + ], + }); + + expect(items[0]?.isGenerating).toBeFalsy(); + expect(items[0]?.summary).toBe('服务端已回写失败。'); +}); + test('buildCreationWorkShelfItems maps baby object match local drafts', () => { const onOpenBabyObjectMatchDetail = vi.fn(); const onDeleteBabyObjectMatch = vi.fn(); @@ -1088,7 +1151,6 @@ test('bark battle draft generating state only follows pending assets', () => { ).toBe(false); }); - test('CustomWorldWorkCard hides author on shelf draft and published cards', () => { const buildItem = ( status: CreationWorkShelfItem['status'], @@ -1110,7 +1172,11 @@ test('CustomWorldWorkCard hides author on shelf draft and published cards', () = canDelete: false, canShare: false, badges: [ - { id: 'status', label: status === 'draft' ? '草稿' : '已发布', tone: 'neutral' }, + { + id: 'status', + label: status === 'draft' ? '草稿' : '已发布', + tone: 'neutral', + }, { id: 'type', label: '汪汪', tone: 'neutral' }, ], metrics: [], diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 81300a53..70c03d55 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -125,6 +125,8 @@ export type CreationWorkShelfItem = { kind: CreationWorkShelfKind; status: CreationWorkShelfStatus; isGenerating?: boolean; + hasGenerationFailure?: boolean; + generationFailureSummary?: string; hasUnreadUpdate?: boolean; title: string; summary: string; @@ -145,6 +147,16 @@ export type CreationWorkShelfItem = { source: CreationWorkShelfSource; }; +export type CreationWorkShelfRuntimeState = { + isGenerating?: boolean; + hasGenerationFailure?: boolean; + generationFailureSummary?: string; + hasUnreadUpdate?: boolean; + suppressPersistedGenerating?: boolean; + titleOverride?: string; + summaryOverride?: string; +}; + export function buildCreationWorkShelfItems(params: { rpgItems: CustomWorldWorkSummary[]; rpgLibraryEntries?: CustomWorldLibraryEntry[]; @@ -191,7 +203,7 @@ export function buildCreationWorkShelfItems(params: { onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void; getItemState?: ( item: CreationWorkShelfItem, - ) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null; + ) => CreationWorkShelfRuntimeState | null; }) { const { rpgItems, @@ -307,18 +319,24 @@ export function buildCreationWorkShelfItems(params: { .map((item) => { const state = getItemState?.(item); const persistedIsGenerating = isPersistedCreationWorkGenerating(item); - return state + const isGenerating = Boolean( + state?.isGenerating || + (!state?.suppressPersistedGenerating && persistedIsGenerating), + ); + return state || isGenerating ? { ...item, - isGenerating: Boolean(state.isGenerating || persistedIsGenerating), - hasUnreadUpdate: state.hasUnreadUpdate, + title: state?.titleOverride ?? item.title, + summary: state?.summaryOverride ?? item.summary, + isGenerating, + hasGenerationFailure: + state?.hasGenerationFailure ?? item.hasGenerationFailure, + generationFailureSummary: + state?.generationFailureSummary ?? + item.generationFailureSummary, + hasUnreadUpdate: state?.hasUnreadUpdate, } - : persistedIsGenerating - ? { - ...item, - isGenerating: true, - } - : item; + : item; }) .sort( (left, right) => @@ -327,7 +345,6 @@ export function buildCreationWorkShelfItems(params: { ); } - function mergeBarkBattleShelfSourceItems( items: readonly BarkBattleWorkSummary[], ): BarkBattleWorkSummary[] { @@ -376,8 +393,8 @@ function mapRpgWorkToShelfItem( : null; const publicWorkCode = item.status === 'published' - ? (libraryEntry?.publicWorkCode?.trim() || - (item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null)) + ? libraryEntry?.publicWorkCode?.trim() || + (item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null) : null; const badges: CreationWorkShelfBadge[] = [ buildStatusBadge(item.status), @@ -843,7 +860,9 @@ function mapWoodenFishWorkToShelfItem( ): CreationWorkShelfItem { const status = item.publicationStatus === 'published' ? 'published' : 'draft'; const publicWorkCode = - status === 'published' ? buildWoodenFishPublicWorkCode(item.profileId) : null; + status === 'published' + ? buildWoodenFishPublicWorkCode(item.profileId) + : null; const title = item.workTitle.trim() || '敲木鱼'; const summary = item.workDescription.trim() || (status === 'draft' ? '未填写作品描述' : ''); @@ -884,10 +903,7 @@ function mapWoodenFishWorkToShelfItem( }; } - -function resolveAuthorDisplayName( - ...sources: Array -) { +function resolveAuthorDisplayName(...sources: Array) { for (const source of sources) { const authorDisplayName = source && @@ -961,7 +977,8 @@ export function resolvePuzzleLevelCoverImageSrc( const fallbackCandidateImageSrc = normalizeCoverImageSrc( level.candidates[level.candidates.length - 1]?.imageSrc, ); - const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc; + const candidateImageSrc = + selectedCandidateImageSrc || fallbackCandidateImageSrc; if ( candidateImageSrc && @@ -984,7 +1001,9 @@ function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) { const topLevelContainerImageSrc = normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) || - normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey); + normalizeCoverImageSrc( + item.generatedBackgroundAsset?.containerImageObjectKey, + ); if (topLevelContainerImageSrc) { return topLevelContainerImageSrc; } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 1df46027..ba1edf6a 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -77,6 +77,7 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, + ProfileDashboardSummary, ProfilePlayedWorkSummary, ProfilePlayStatsResponse, ProfileSaveArchiveResumeResponse, @@ -447,11 +448,12 @@ type AgentResultPublishGateView = { blockers: string[]; publishReady: boolean; }; -type DraftGenerationNoticeStatus = 'generating' | 'ready'; +type DraftGenerationNoticeStatus = 'generating' | 'ready' | 'failed'; type DraftGenerationNotice = { status: DraftGenerationNoticeStatus; seen: boolean; completedAtMs?: number; + message?: string; }; type DraftGenerationNoticeMap = Record; type CreationWorkShelfKind = CreationWorkShelfItem['kind']; @@ -459,6 +461,8 @@ type PendingDraftShelfState = { status: DraftGenerationNoticeStatus; seen: boolean; updatedAt: string; + title?: string; + summary?: string; }; type PendingDraftShelfMap = Partial< Record< @@ -1960,6 +1964,39 @@ function buildJumpHopCreationUrlState(params: { }; } +function buildJumpHopPendingSession( + item: JumpHopWorkSummaryResponse, +): JumpHopSessionSnapshotResponse { + const sessionId = + normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId; + return { + sessionId, + ownerUserId: item.ownerUserId, + status: item.generationStatus, + draft: { + templateId: 'jump-hop', + templateName: '跳一跳', + profileId: item.profileId, + workTitle: item.workTitle, + workDescription: item.workDescription, + themeTags: item.themeTags, + difficulty: item.difficulty, + stylePreset: item.stylePreset, + characterPrompt: '', + tilePrompt: '', + endMoodPrompt: null, + characterAsset: null, + tileAtlasAsset: null, + tileAssets: [], + path: null, + coverComposite: item.coverImageSrc, + generationStatus: item.generationStatus, + }, + createdAt: item.updatedAt, + updatedAt: item.updatedAt, + }; +} + function buildWoodenFishCreationUrlState(params: { session?: WoodenFishSessionSnapshotResponse | null; work?: WoodenFishWorkProfileResponse | null; @@ -2069,15 +2106,37 @@ function normalizeDraftNoticeId(id: string | null | undefined) { return id?.trim() || null; } +function normalizePendingDraftShelfLookupId( + kind: Exclude, + id: string | null | undefined, +) { + const normalizedId = normalizeDraftNoticeId(id); + if (!normalizedId) { + return null; + } + + const noticePrefix = `${kind}:`; + if (!normalizedId.startsWith(noticePrefix)) { + return normalizedId; + } + + return normalizeDraftNoticeId(normalizedId.slice(noticePrefix.length)); +} + function createPendingDraftShelfState( status: DraftGenerationNoticeStatus, seen = false, updatedAt = new Date().toISOString(), + metadata?: { title?: string | null; summary?: string | null }, ): PendingDraftShelfState { + const title = metadata?.title?.trim(); + const summary = metadata?.summary?.trim(); return { status, seen, updatedAt, + ...(title ? { title } : {}), + ...(summary ? { summary } : {}), }; } @@ -2096,6 +2155,46 @@ function formatPlatformTaskCompletionSource(label: string, id?: string | null) { return normalizedId ? `${label} ${normalizedId}` : label; } +function isBackgroundGenerationStillRunningMessage(message: string) { + return /仍在后台处理|后台仍在处理|仍在生成|后台生成/u.test(message); +} + +function buildDraftFailedShelfSummary(kind: CreationWorkShelfKind) { + switch (kind) { + case 'puzzle': + return '拼图草稿生成失败,可重新打开处理。'; + case 'match3d': + return '玩法素材生成失败,可重新打开处理。'; + case 'big-fish': + return '草稿生成失败,可重新打开处理。'; + case 'square-hole': + return '挑战素材生成失败,可重新打开处理。'; + case 'jump-hop': + return '跳一跳玩法草稿生成失败,可重新打开处理。'; + case 'wooden-fish': + return '敲木鱼草稿生成失败,可重新打开处理。'; + case 'visual-novel': + return '视觉小说草稿生成失败,可重新打开处理。'; + case 'bark-battle': + return '声浪竞技素材生成失败,可重新打开处理。'; + case 'baby-object-match': + return '宝贝识物草稿生成失败,可重新打开处理。'; + default: + return '草稿生成失败,可重新打开处理。'; + } +} + +function isDraftShelfSummaryPlaceholder(value: string | null | undefined) { + const normalized = value?.trim(); + if (!normalized) { + return true; + } + + return /^(正在生成|.*生成失败,可重新打开处理。$|未填写作品描述$)/u.test( + normalized, + ); +} + function buildPlatformErrorDialogDismissKey( error: (PlatformErrorDialogPayload & { key: string }) | null, ) { @@ -2165,32 +2264,119 @@ function buildDraftCompletionDialogSource( function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, metadata?: MiniGameDraftGenerationState['metadata'], + startedAtMs = Date.now(), ): MiniGameDraftGenerationState { return { - ...createMiniGameDraftGenerationState(kind), + ...createMiniGameDraftGenerationState(kind, startedAtMs), ...(metadata ? { metadata } : {}), }; } +function createFailedMiniGameDraftGenerationStateForRestoredDraft( + kind: MiniGameDraftGenerationKind, + updatedAt: string | null | undefined, + error: string, + metadata?: MiniGameDraftGenerationState['metadata'], +): MiniGameDraftGenerationState { + return resolveFinishedMiniGameDraftGenerationState( + createMiniGameDraftGenerationStateForRestoredDraft( + kind, + metadata, + resolveMiniGameDraftGenerationStartedAtMs(updatedAt), + ), + 'failed', + { error }, + ); +} + +function buildPuzzleFormPayloadFromWork( + item: PuzzleWorkSummary, +): CreatePuzzleAgentSessionRequest { + const pictureDescription = + item.workDescription?.trim() || + item.summary?.trim() || + item.levels?.[0]?.pictureDescription?.trim() || + item.levelName?.trim() || + item.workTitle?.trim() || + ''; + + return { + seedText: pictureDescription, + workTitle: item.workTitle?.trim() || item.levelName?.trim() || undefined, + workDescription: item.workDescription?.trim() || item.summary?.trim(), + pictureDescription, + referenceImageSrc: null, + referenceImageSrcs: [], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], + imageModel: null, + aiRedraw: true, + }; +} + +function parseOptionalFiniteNumber(value: string | number | null | undefined) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined; + } + + const normalizedValue = value?.trim(); + if (!normalizedValue) { + return undefined; + } + + const parsedValue = Number(normalizedValue); + return Number.isFinite(parsedValue) ? parsedValue : undefined; +} + +function buildMatch3DFormPayloadFromSession( + session: Match3DAgentSessionSnapshot, +): CreateMatch3DSessionRequest { + const themeText = + session.config?.themeText?.trim() || + session.draft?.themeText?.trim() || + session.anchorPack.theme.value.trim() || + ''; + + return { + seedText: themeText, + themeText, + referenceImageSrc: + session.config?.referenceImageSrc ?? session.draft?.referenceImageSrc ?? null, + clearCount: + session.config?.clearCount ?? + session.draft?.clearCount ?? + parseOptionalFiniteNumber(session.anchorPack.clearCount.value) ?? + undefined, + difficulty: + session.config?.difficulty ?? + session.draft?.difficulty ?? + parseOptionalFiniteNumber(session.anchorPack.difficulty.value) ?? + undefined, + assetStyleId: session.config?.assetStyleId ?? null, + assetStyleLabel: session.config?.assetStyleLabel ?? null, + assetStylePrompt: session.config?.assetStylePrompt ?? null, + generateClickSound: session.config?.generateClickSound, + }; +} + +function buildMatch3DFormPayloadFromWork( + item: Match3DWorkSummary, +): CreateMatch3DSessionRequest { + const themeText = item.themeText?.trim() || item.gameName?.trim() || ''; + return { + seedText: themeText, + themeText, + referenceImageSrc: item.referenceImageSrc ?? null, + clearCount: item.clearCount, + difficulty: item.difficulty, + }; +} + function rebaseMiniGameDraftGenerationStateForDisplay( state: MiniGameDraftGenerationState, ): MiniGameDraftGenerationState { - const rebasedStartedAtMs = Date.now(); - - if (state.kind === 'puzzle') { - const puzzleAiRedraw = state.metadata?.puzzleAiRedraw; - return { - ...state, - startedAtMs: rebasedStartedAtMs, - finishedAtMs: undefined, - metadata: - typeof puzzleAiRedraw === 'boolean' ? { puzzleAiRedraw } : undefined, - }; - } - return { ...state, - startedAtMs: rebasedStartedAtMs, finishedAtMs: undefined, }; } @@ -2458,6 +2644,11 @@ function isPersistedDraftGenerating(value: string | null | undefined) { return value?.trim() === 'generating'; } +function isPersistedDraftFailed(value: string | null | undefined) { + const normalized = value?.trim(); + return normalized === 'failed' || normalized === 'partial_failed'; +} + function resolveProfileWalletBalance( dashboard: { walletBalance?: number | null } | null | undefined, ) { @@ -2467,6 +2658,51 @@ function resolveProfileWalletBalance( : 0; } +function adjustProfileDashboardWalletBalance( + dashboard: ProfileDashboardSummary | null, + delta: number, +): ProfileDashboardSummary | null { + if (!dashboard || !Number.isFinite(delta) || delta === 0) { + return dashboard; + } + + return { + ...dashboard, + walletBalance: Math.max( + 0, + resolveProfileWalletBalance(dashboard) + Math.trunc(delta), + ), + updatedAt: new Date().toISOString(), + }; +} + +function reconcileProfileWalletLocalDeltaWithServerDashboard( + previousDashboard: ProfileDashboardSummary | null, + latestDashboard: ProfileDashboardSummary | null, + localDelta: number, +) { + if ( + !previousDashboard || + !latestDashboard || + !Number.isFinite(localDelta) || + localDelta === 0 + ) { + return Number.isFinite(localDelta) ? Math.trunc(localDelta) : 0; + } + + const previousBalance = resolveProfileWalletBalance(previousDashboard); + const latestBalance = resolveProfileWalletBalance(latestDashboard); + const normalizedDelta = Math.trunc(localDelta); + + if (normalizedDelta < 0) { + const reflectedDebit = Math.max(0, previousBalance - latestBalance); + return Math.min(0, normalizedDelta + reflectedDebit); + } + + const reflectedCredit = Math.max(0, latestBalance - previousBalance); + return Math.max(0, normalizedDelta - reflectedCredit); +} + function buildPendingBigFishWorks( pending: Record | undefined, existingItems: readonly BigFishWorkSummary[], @@ -2479,27 +2715,32 @@ function buildPendingBigFishWorks( .filter(([sessionId]) => existingItems.every((item) => item.sourceSessionId !== sessionId), ) - .map(([sessionId, state]) => ({ - workId: `big-fish-work-${sessionId}`, - sourceSessionId: sessionId, - ownerUserId: '', - authorDisplayName: '', - title: '大鱼吃小鱼草稿', - subtitle: '草稿生成中', - summary: '正在生成玩法草稿。', - coverImageSrc: null, - status: 'draft', - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - levelCount: 0, - levelMainImageReadyCount: 0, - levelMotionReadyCount: 0, - backgroundReady: false, - playCount: 0, - remixCount: 0, - likeCount: 0, - })); + .map(([sessionId, state]) => { + const isFailed = state.status === 'failed'; + return { + workId: `big-fish-work-${sessionId}`, + sourceSessionId: sessionId, + ownerUserId: '', + authorDisplayName: '', + title: '大鱼吃小鱼草稿', + subtitle: isFailed ? '生成失败待重试' : '草稿生成中', + summary: isFailed + ? '草稿生成失败,可重新打开处理。' + : '正在生成玩法草稿。', + coverImageSrc: null, + status: 'draft', + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + levelCount: 0, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + playCount: 0, + remixCount: 0, + likeCount: 0, + }; + }); } function buildPendingJumpHopWorks( @@ -2514,25 +2755,36 @@ function buildPendingJumpHopWorks( .filter(([sessionId]) => existingItems.every((item) => item.sourceSessionId !== sessionId), ) - .map(([sessionId, state]) => ({ - runtimeKind: 'jump-hop', - workId: `jump-hop-work-${sessionId}`, - profileId: `jump-hop-profile-${sessionId}`, - ownerUserId: '', - sourceSessionId: sessionId, - workTitle: '跳一跳草稿', - workDescription: '正在生成跳一跳玩法草稿。', - themeTags: [], - difficulty: 'standard', - stylePreset: 'minimal-blocks', - coverImageSrc: null, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - generationStatus: state.status === 'generating' ? 'generating' : 'ready', - })); + .map(([sessionId, state]) => { + const generationStatus = + state.status === 'failed' + ? 'failed' + : state.status === 'generating' + ? 'generating' + : 'ready'; + return { + runtimeKind: 'jump-hop', + workId: `jump-hop-work-${sessionId}`, + profileId: `jump-hop-profile-${sessionId}`, + ownerUserId: '', + sourceSessionId: sessionId, + workTitle: '跳一跳草稿', + workDescription: + state.status === 'failed' + ? '跳一跳玩法草稿生成失败,可重新打开处理。' + : '正在生成跳一跳玩法草稿。', + themeTags: [], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus, + }; + }); } function buildPendingWoodenFishWorks( @@ -2547,23 +2799,34 @@ function buildPendingWoodenFishWorks( .filter(([sessionId]) => existingItems.every((item) => item.sourceSessionId !== sessionId), ) - .map(([sessionId, state]) => ({ - runtimeKind: 'wooden-fish', - workId: `wooden-fish-work-${sessionId}`, - profileId: sessionId, - ownerUserId: '', - sourceSessionId: sessionId, - workTitle: '敲木鱼草稿', - workDescription: '正在生成敲木鱼草稿。', - themeTags: ['敲木鱼'], - coverImageSrc: null, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - generationStatus: state.status === 'generating' ? 'generating' : 'ready', - })); + .map(([sessionId, state]) => { + const generationStatus = + state.status === 'failed' + ? 'failed' + : state.status === 'generating' + ? 'generating' + : 'ready'; + return { + runtimeKind: 'wooden-fish', + workId: `wooden-fish-work-${sessionId}`, + profileId: sessionId, + ownerUserId: '', + sourceSessionId: sessionId, + workTitle: '敲木鱼草稿', + workDescription: + state.status === 'failed' + ? '敲木鱼草稿生成失败,可重新打开处理。' + : '正在生成敲木鱼草稿。', + themeTags: ['敲木鱼'], + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus, + }; + }); } function buildPendingMatch3DWorks( @@ -2578,27 +2841,39 @@ function buildPendingMatch3DWorks( .filter(([sessionId]) => existingItems.every((item) => item.sourceSessionId !== sessionId), ) - .map(([sessionId, state]) => ({ - workId: `match3d-work-${sessionId}`, - profileId: sessionId, - ownerUserId: '', - sourceSessionId: sessionId, - gameName: '抓大鹅草稿', - themeText: '', - summary: '正在生成玩法素材。', - tags: [], - coverImageSrc: null, - referenceImageSrc: null, - clearCount: 0, - difficulty: 0, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - generationStatus: state.status === 'generating' ? 'generating' : 'ready', - generatedItemAssets: [], - })); + .map(([sessionId, state]) => { + const themeText = state.summary?.trim() || state.title?.trim() || ''; + const fallbackSummary = + state.status === 'failed' + ? '玩法素材生成失败,可重新打开处理。' + : '正在生成玩法素材。'; + return { + workId: `match3d-work-${sessionId}`, + profileId: sessionId, + ownerUserId: '', + sourceSessionId: sessionId, + gameName: '抓大鹅草稿', + themeText, + summary: themeText || fallbackSummary, + tags: [], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 0, + difficulty: 0, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus: + state.status === 'failed' + ? 'failed' + : state.status === 'generating' + ? 'generating' + : 'ready', + generatedItemAssets: [], + }; + }); } function buildPendingSquareHoleWorks( @@ -2621,7 +2896,10 @@ function buildPendingSquareHoleWorks( gameName: '方洞挑战草稿', themeText: '', twistRule: '', - summary: '正在生成挑战素材。', + summary: + state.status === 'failed' + ? '挑战素材生成失败,可重新打开处理。' + : '正在生成挑战素材。', tags: [], coverImageSrc: null, backgroundPrompt: '', @@ -2653,6 +2931,12 @@ function buildPendingPuzzleWorks( .map(([sessionId, state]) => { const profileId = buildPuzzleResultProfileId(sessionId) ?? `puzzle-profile-${sessionId}`; + const title = state.title?.trim() || '拼图草稿'; + const summary = + state.summary?.trim() || + (state.status === 'failed' + ? '拼图草稿生成失败,可重新打开处理。' + : '正在生成拼图草稿。'); return { workId: buildPuzzleResultWorkId(sessionId) ?? `puzzle-work-${sessionId}`, @@ -2660,10 +2944,10 @@ function buildPendingPuzzleWorks( ownerUserId: '', sourceSessionId: sessionId, authorDisplayName: '', - workTitle: '拼图草稿', - workDescription: '正在生成拼图草稿。', - levelName: '拼图草稿', - summary: '正在生成拼图草稿。', + workTitle: title, + workDescription: summary, + levelName: title, + summary, themeTags: [], coverImageSrc: null, coverAssetId: null, @@ -2675,7 +2959,11 @@ function buildPendingPuzzleWorks( likeCount: 0, publishReady: false, generationStatus: - state.status === 'generating' ? 'generating' : 'ready', + state.status === 'generating' + ? 'generating' + : state.status === 'failed' + ? 'failed' + : 'ready', levels: [], }; }); @@ -2698,7 +2986,10 @@ function buildPendingVisualNovelWorks( profileId, ownerUserId: '', title: '视觉小说草稿', - description: '正在生成视觉小说草稿。', + description: + state.status === 'failed' + ? '视觉小说草稿生成失败,可重新打开处理。' + : '正在生成视觉小说草稿。', coverImageSrc: null, tags: [], publishStatus: 'draft', @@ -2727,7 +3018,10 @@ function buildPendingBarkBattleWorks( ownerUserId: '', authorDisplayName: '', title: '汪汪声浪草稿', - summary: '正在生成声浪竞技素材。', + summary: + state.status === 'failed' + ? '声浪竞技素材生成失败,可重新打开处理。' + : '正在生成声浪竞技素材。', themeDescription: '', playerImageDescription: '', opponentImageDescription: '', @@ -2738,7 +3032,11 @@ function buildPendingBarkBattleWorks( difficultyPreset: 'normal', status: 'draft', generationStatus: - state.status === 'generating' ? 'pending_assets' : 'ready', + state.status === 'generating' + ? 'pending_assets' + : state.status === 'failed' + ? 'partial_failed' + : 'ready', publishReady: false, playCount: 0, updatedAt: state.updatedAt, @@ -2751,10 +3049,14 @@ function buildPuzzleCompileActionFromFormPayload( ): PuzzleAgentActionRequest { const pictureDescription = payload?.pictureDescription?.trim() || payload?.seedText?.trim(); + const workTitle = payload?.workTitle?.trim(); + const workDescription = payload?.workDescription?.trim() || pictureDescription; return { action: 'compile_puzzle_draft', promptText: pictureDescription, + ...(workTitle ? { workTitle } : {}), + ...(workDescription ? { workDescription } : {}), ...(pictureDescription ? { pictureDescription } : {}), referenceImageSrc: payload?.referenceImageSrc || null, referenceImageSrcs: payload?.referenceImageSrcs ?? [], @@ -2776,9 +3078,18 @@ function buildPuzzleFormPayloadFromSession( session.anchorPack.visualSubject.value.trim() || session.seedText?.trim() || ''; + const workTitle = + formDraft?.workTitle?.trim() || session.draft?.workTitle?.trim(); + const workDescription = + formDraft?.workDescription?.trim() || + session.draft?.workDescription?.trim() || + session.draft?.summary?.trim() || + pictureDescription; return { seedText: pictureDescription, + ...(workTitle ? { workTitle } : {}), + ...(workDescription ? { workDescription } : {}), pictureDescription, referenceImageSrc: null, referenceImageSrcs: [], @@ -2789,6 +3100,29 @@ function buildPuzzleFormPayloadFromSession( }; } +function buildPendingPuzzleDraftMetadata( + payload: CreatePuzzleAgentSessionRequest | null | undefined, +) { + const title = payload?.workTitle?.trim(); + const summary = + payload?.workDescription?.trim() || + payload?.pictureDescription?.trim() || + payload?.seedText?.trim(); + return { + ...(title ? { title } : {}), + ...(summary ? { summary } : {}), + }; +} + +function buildPendingMatch3DDraftMetadata( + payload: CreateMatch3DSessionRequest | null | undefined, +) { + const themeText = payload?.themeText?.trim() || payload?.seedText?.trim(); + return { + ...(themeText ? { title: themeText, summary: themeText } : {}), + }; +} + function buildPuzzleFormPayloadFromAction( payload: PuzzleAgentActionRequest, ): CreatePuzzleAgentSessionRequest | null { @@ -3531,6 +3865,9 @@ export function PlatformEntryFlowShellImpl({ useState({}); const [pendingDraftShelfItems, setPendingDraftShelfItems] = useState({}); + const profileWalletLocalDeltaRef = useRef(0); + const lastProfileDashboardSnapshotRef = + useRef(null); const [ pendingPlatformTaskCompletionDialog, setPendingPlatformTaskCompletionDialog, @@ -3541,6 +3878,16 @@ export function PlatformEntryFlowShellImpl({ }) | null >(null); + const [ + pendingPlatformTaskFailureDialog, + setPendingPlatformTaskFailureDialog, + ] = useState< + | (PlatformErrorDialogPayload & { + key: string; + failedAtMs: number; + }) + | null + >(null); const [profileTaskRefreshKey, setProfileTaskRefreshKey] = useState(0); const [initialCreationUrlState] = useState(() => readCreationUrlState()); const handledInitialCreationUrlStateRef = useRef(false); @@ -3569,6 +3916,7 @@ export function PlatformEntryFlowShellImpl({ id: string | null | undefined, status: DraftGenerationNoticeStatus, seen = false, + metadata?: { title?: string | null; summary?: string | null }, ) => { const normalizedId = normalizeDraftNoticeId(id); if (!normalizedId) { @@ -3577,10 +3925,18 @@ export function PlatformEntryFlowShellImpl({ setPendingDraftShelfItems((current) => ({ ...current, - [kind]: { - ...(current[kind] ?? {}), - [normalizedId]: createPendingDraftShelfState(status, seen), - }, + [kind]: (() => { + const currentItems = current[kind] ?? {}; + return { + ...currentItems, + [normalizedId]: createPendingDraftShelfState( + status, + seen, + new Date().toISOString(), + metadata ?? currentItems[normalizedId], + ), + }; + })(), })); }, [], @@ -3613,20 +3969,35 @@ export function PlatformEntryFlowShellImpl({ ); const updateDraftGenerationNotices = useCallback( - (keys: string[], status: DraftGenerationNoticeStatus, seen = false) => { + ( + keys: string[], + status: DraftGenerationNoticeStatus, + seen = false, + message?: string | null, + ) => { const uniqueKeys = Array.from(new Set(keys.filter(Boolean))); if (uniqueKeys.length === 0) { return; } const completedAtMs = status === 'ready' ? Date.now() : undefined; + const normalizedMessage = message?.trim(); setDraftGenerationNotices((current) => { const next = { ...current }; for (const key of uniqueKeys) { next[key] = completedAtMs === undefined - ? { status, seen } - : { status, seen, completedAtMs }; + ? { + status, + seen, + ...(normalizedMessage ? { message: normalizedMessage } : {}), + } + : { + status, + seen, + completedAtMs, + ...(normalizedMessage ? { message: normalizedMessage } : {}), + }; } return next; }); @@ -3666,15 +4037,38 @@ export function PlatformEntryFlowShellImpl({ }, [draftGenerationNotices], ); + const getPendingDraftShelfState = useCallback( + (kind: Exclude, keys: string[]) => { + const entries = pendingDraftShelfItems[kind]; + if (!entries) { + return null; + } + + for (const key of keys) { + const normalizedKey = normalizePendingDraftShelfLookupId(kind, key); + const pending = normalizedKey ? entries[normalizedKey] : null; + if (pending) { + return pending; + } + } + return null; + }, + [pendingDraftShelfItems], + ); const markDraftGenerating = useCallback( (kind: CreationWorkShelfKind, ids: Array) => { setPendingPlatformTaskCompletionDialog(null); + setPendingPlatformTaskFailureDialog(null); updateDraftGenerationNotices( collectDraftNoticeKeys(kind, ids), 'generating', ); }, - [setPendingPlatformTaskCompletionDialog, updateDraftGenerationNotices], + [ + setPendingPlatformTaskCompletionDialog, + setPendingPlatformTaskFailureDialog, + updateDraftGenerationNotices, + ], ); const markDraftReady = useCallback( ( @@ -3682,6 +4076,7 @@ export function PlatformEntryFlowShellImpl({ ids: Array, viewedImmediately: boolean, ) => { + setPendingPlatformTaskFailureDialog(null); updateDraftGenerationNotices( collectDraftNoticeKeys(kind, ids), 'ready', @@ -3696,17 +4091,54 @@ export function PlatformEntryFlowShellImpl({ completedAtMs, }); }, - [setPendingPlatformTaskCompletionDialog, updateDraftGenerationNotices], + [ + setPendingPlatformTaskCompletionDialog, + setPendingPlatformTaskFailureDialog, + updateDraftGenerationNotices, + ], + ); + const markDraftFailed = useCallback( + ( + kind: CreationWorkShelfKind, + ids: Array, + errorMessage?: string | null, + showFailureDialog = true, + ) => { + setPendingPlatformTaskCompletionDialog(null); + const noticeKeys = collectDraftNoticeKeys(kind, ids); + updateDraftGenerationNotices(noticeKeys, 'failed', false, errorMessage); + const normalizedErrorMessage = errorMessage?.trim(); + if (normalizedErrorMessage && showFailureDialog) { + const failedAtMs = Date.now(); + setPendingPlatformTaskFailureDialog({ + key: `draft-failure:${kind}:${noticeKeys.join('|')}:${failedAtMs}`, + source: buildDraftCompletionDialogSource(kind, ids), + message: normalizedErrorMessage, + failedAtMs, + }); + } + }, + [ + setPendingPlatformTaskCompletionDialog, + setPendingPlatformTaskFailureDialog, + updateDraftGenerationNotices, + ], ); const markPendingDraftGenerating = useCallback( ( kind: Exclude, id: string | null | undefined, + metadata?: { title?: string | null; summary?: string | null }, ) => { setPendingPlatformTaskCompletionDialog(null); - updatePendingDraftShelfItem(kind, id, 'generating'); + setPendingPlatformTaskFailureDialog(null); + updatePendingDraftShelfItem(kind, id, 'generating', false, metadata); }, - [setPendingPlatformTaskCompletionDialog, updatePendingDraftShelfItem], + [ + setPendingPlatformTaskCompletionDialog, + setPendingPlatformTaskFailureDialog, + updatePendingDraftShelfItem, + ], ); const markPendingDraftReady = useCallback( ( @@ -3718,6 +4150,15 @@ export function PlatformEntryFlowShellImpl({ }, [updatePendingDraftShelfItem], ); + const markPendingDraftFailed = useCallback( + ( + kind: Exclude, + id: string | null | undefined, + ) => { + updatePendingDraftShelfItem(kind, id, 'failed', false); + }, + [updatePendingDraftShelfItem], + ); const getMatch3DBackgroundCompileTask = useCallback( (sessionId: string | null | undefined) => { const normalizedSessionId = normalizeDraftNoticeId(sessionId); @@ -3758,13 +4199,60 @@ export function PlatformEntryFlowShellImpl({ }; }, []); + useEffect(() => { + profileWalletLocalDeltaRef.current = 0; + lastProfileDashboardSnapshotRef.current = null; + }, [authUi?.user?.id]); + + const getPlatformProfileDashboardWithLocalWalletDelta = useCallback( + async (options?: Parameters[0]) => { + const latestDashboard = await getPlatformProfileDashboard(options); + const reconciledDelta = + reconcileProfileWalletLocalDeltaWithServerDashboard( + lastProfileDashboardSnapshotRef.current, + latestDashboard, + profileWalletLocalDeltaRef.current, + ); + lastProfileDashboardSnapshotRef.current = latestDashboard; + profileWalletLocalDeltaRef.current = reconciledDelta; + return adjustProfileDashboardWalletBalance( + latestDashboard, + reconciledDelta, + ); + }, + [], + ); + const platformBootstrap = usePlatformEntryBootstrap({ user: authUi?.user, canAccessProtectedData: authUi?.canAccessProtectedData, - getProfileDashboard: getPlatformProfileDashboard, + getProfileDashboard: getPlatformProfileDashboardWithLocalWalletDelta, handleContinueGame, hasInitialAgentSession, }); + const { + canReadProtectedData: canRefreshPlatformDashboard, + refreshProfileDashboard, + } = platformBootstrap; + const refreshPlatformDashboardSilently = useCallback(() => { + if (!canRefreshPlatformDashboard) { + return; + } + void refreshProfileDashboard().catch(() => undefined); + }, [canRefreshPlatformDashboard, refreshProfileDashboard]); + const adjustProfileWalletBalanceLocally = useCallback( + (delta: number) => { + if (!Number.isFinite(delta) || delta === 0) { + return; + } + + profileWalletLocalDeltaRef.current += Math.trunc(delta); + platformBootstrap.setProfileDashboard((current) => + adjustProfileDashboardWalletBalance(current, delta), + ); + }, + [platformBootstrap], + ); const entryNavigation = usePlatformEntryNavigation({ setSelectionStage, setSelectedDetailEntry, @@ -3850,6 +4338,14 @@ export function PlatformEntryFlowShellImpl({ }, [draftGenerationNotices], ); + const isDraftNoticeFailed = useCallback( + (kind: CreationWorkShelfKind, ids: Array) => { + return collectDraftNoticeKeys(kind, ids).some( + (key) => draftGenerationNotices[key]?.status === 'failed', + ); + }, + [draftGenerationNotices], + ); const isDraftNoticeReadyUnread = useCallback( (kind: CreationWorkShelfKind, ids: Array) => { return collectDraftNoticeKeys(kind, ids).some((key) => { @@ -3862,7 +4358,7 @@ export function PlatformEntryFlowShellImpl({ const ensureEnoughDraftGenerationPointsFromServer = useCallback( async (pointsCost: number) => { try { - const latestDashboard = await getPlatformProfileDashboard( + const latestDashboard = await getPlatformProfileDashboardWithLocalWalletDelta( RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS, ); platformBootstrap.setProfileDashboard(latestDashboard); @@ -3885,7 +4381,7 @@ export function PlatformEntryFlowShellImpl({ return false; } }, - [platformBootstrap], + [getPlatformProfileDashboardWithLocalWalletDelta, platformBootstrap], ); const resolveBigFishErrorMessage = useCallback( @@ -4700,9 +5196,52 @@ export function PlatformEntryFlowShellImpl({ ); const getCreationWorkShelfState = useCallback( (item: CreationWorkShelfItem) => { - const notice = getDraftGenerationNotice( - getGenerationNoticeShelfKeys(item), - ); + const noticeKeys = getGenerationNoticeShelfKeys(item); + const notice = getDraftGenerationNotice(noticeKeys); + if (notice?.status === 'failed') { + const failedSummary = buildDraftFailedShelfSummary(item.source.kind); + const pending = + item.source.kind === 'rpg' + ? null + : getPendingDraftShelfState(item.source.kind, noticeKeys); + const pendingSummary = pending?.summary?.trim(); + return { + isGenerating: false, + hasGenerationFailure: true, + generationFailureSummary: failedSummary, + hasUnreadUpdate: false, + suppressPersistedGenerating: true, + titleOverride: + item.source.kind === 'puzzle' && + item.status === 'draft' && + !item.source.item.workTitle?.trim() + ? '拼图草稿' + : undefined, + summaryOverride: isDraftShelfSummaryPlaceholder(item.summary) + ? (pendingSummary ?? failedSummary) + : undefined, + }; + } + if ( + item.source.kind === 'puzzle' && + isPersistedDraftFailed(item.source.item.generationStatus) + ) { + const failedSummary = buildDraftFailedShelfSummary('puzzle'); + return { + isGenerating: false, + hasGenerationFailure: true, + generationFailureSummary: failedSummary, + hasUnreadUpdate: false, + suppressPersistedGenerating: true, + titleOverride: + item.status === 'draft' && !item.source.item.workTitle?.trim() + ? '拼图草稿' + : undefined, + summaryOverride: isDraftShelfSummaryPlaceholder(item.summary) + ? failedSummary + : undefined, + }; + } const isNoticeGenerating = notice?.status === 'generating' && (item.source.kind !== 'puzzle' || @@ -4712,7 +5251,7 @@ export function PlatformEntryFlowShellImpl({ hasUnreadUpdate: notice?.status === 'ready' && !notice.seen, }; }, - [getDraftGenerationNotice], + [getDraftGenerationNotice, getPendingDraftShelfState], ); const visibleDraftNoticeKeys = useMemo( () => [ @@ -4831,9 +5370,17 @@ export function PlatformEntryFlowShellImpl({ ? puzzleGenerationState : selectionStage === 'match3d-generating' ? match3dGenerationState - : selectionStage === 'baby-object-match-generating' - ? babyObjectMatchGenerationState - : null; + : selectionStage === 'big-fish-generating' + ? bigFishGenerationState + : selectionStage === 'square-hole-generating' + ? squareHoleGenerationState + : selectionStage === 'jump-hop-generating' + ? jumpHopGenerationState + : selectionStage === 'wooden-fish-generating' + ? woodenFishGenerationState + : selectionStage === 'baby-object-match-generating' + ? babyObjectMatchGenerationState + : null; const shouldTickProgress = selectionStage === 'visual-novel-generating' ? visualNovelGenerationStartedAtMs != null && @@ -4855,11 +5402,15 @@ export function PlatformEntryFlowShellImpl({ return () => window.clearInterval(timerId); }, [ babyObjectMatchGenerationState, + bigFishGenerationState, + jumpHopGenerationState, match3dGenerationState, puzzleGenerationState, selectionStage, + squareHoleGenerationState, visualNovelGenerationPhase, visualNovelGenerationStartedAtMs, + woodenFishGenerationState, ]); const runProtectedAction = useCallback( @@ -5147,7 +5698,7 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('big-fish-generating'); setBigFishGenerationState(createMiniGameDraftGenerationState('big-fish')); }, - onActionError: ({ payload, errorMessage }) => { + onActionError: ({ payload, errorMessage, session }) => { if (payload.action !== 'big_fish_compile_draft') { return; } @@ -5158,6 +5709,13 @@ export function PlatformEntryFlowShellImpl({ }) : current, ); + markPendingDraftFailed('big-fish', session.sessionId); + markDraftFailed( + 'big-fish', + [`big-fish-work-${session.sessionId}`, session.sessionId], + errorMessage, + ); + void refreshBigFishShelf(); }, }); @@ -5299,6 +5857,29 @@ export function PlatformEntryFlowShellImpl({ if (payload.action !== 'match3d_compile_draft') { return; } + if (isBackgroundGenerationStillRunningMessage(errorMessage)) { + try { + const { session: latestSession } = + await match3dCreationClient.getSession(session.sessionId); + setSession(latestSession); + const profileId = + latestSession.draft?.profileId ?? latestSession.publishedProfileId; + if (profileId) { + const { item } = await getMatch3DWorkDetail(profileId); + setMatch3DProfile(normalizeMatch3DWorkForRuntimeUi(item)); + } + markPendingDraftGenerating('match3d', latestSession.sessionId); + markDraftGenerating('match3d', [ + latestSession.draft?.profileId, + latestSession.publishedProfileId, + latestSession.sessionId, + ]); + await refreshMatch3DShelf().catch(() => undefined); + return; + } catch { + await refreshMatch3DShelf().catch(() => undefined); + } + } setMatch3DGenerationState((current) => current ? resolveFinishedMiniGameDraftGenerationState(current, 'failed', { @@ -5306,6 +5887,12 @@ export function PlatformEntryFlowShellImpl({ }) : current, ); + markPendingDraftFailed('match3d', session.sessionId); + markDraftFailed( + 'match3d', + [session.draft?.profileId, session.publishedProfileId, session.sessionId], + errorMessage, + ); try { const { session: latestSession } = await match3dCreationClient.getSession(session.sessionId); @@ -5498,6 +6085,17 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleProfile( buildSquareHoleProfileFromSession(response.session), ); + markPendingDraftFailed('square-hole', response.session.sessionId); + markDraftFailed( + 'square-hole', + [ + response.session.draft?.profileId, + response.session.publishedProfileId, + response.session.sessionId, + ], + errorMessage, + ); + void refreshSquareHoleShelf().catch(() => undefined); if (shouldOpenResult) { setSelectionStage('square-hole-result'); } @@ -5556,7 +6154,7 @@ export function PlatformEntryFlowShellImpl({ } } }, - onActionError: ({ payload, errorMessage }) => { + onActionError: ({ payload, errorMessage, session }) => { if ( payload.action === 'square_hole_compile_draft' || payload.action === 'square_hole_generate_visual_assets' @@ -5571,6 +6169,13 @@ export function PlatformEntryFlowShellImpl({ if (selectionStageRef.current === 'square-hole-generating') { setSelectionStage('square-hole-generating'); } + markPendingDraftFailed('square-hole', session.sessionId); + markDraftFailed( + 'square-hole', + [session.draft?.profileId, session.publishedProfileId, session.sessionId], + errorMessage, + ); + void refreshSquareHoleShelf().catch(() => undefined); } }, }); @@ -5630,8 +6235,12 @@ export function PlatformEntryFlowShellImpl({ if ( payload.action === 'publish_puzzle_work' || - payload.action === 'generate_puzzle_tags' + payload.action === 'generate_puzzle_tags' || + payload.action === 'compile_puzzle_draft' || + payload.action === 'generate_puzzle_images' || + payload.action === 'generate_puzzle_ui_background' ) { + refreshPlatformDashboardSilently(); await Promise.allSettled([ refreshPuzzleShelf(), refreshPuzzleGallery(), @@ -5750,7 +6359,13 @@ export function PlatformEntryFlowShellImpl({ session.publishedProfileId, buildPuzzleResultProfileId(session.sessionId), ]); - markPendingDraftGenerating('puzzle', session.sessionId); + markPendingDraftGenerating( + 'puzzle', + session.sessionId, + buildPendingPuzzleDraftMetadata( + formPayload ?? buildPuzzleFormPayloadFromSession(session), + ), + ); selectionStageRef.current = 'puzzle-generating'; activePuzzleGenerationSessionIdRef.current = session.sessionId; setSelectionStage('puzzle-generating'); @@ -5773,6 +6388,7 @@ export function PlatformEntryFlowShellImpl({ if (payload.action !== 'compile_puzzle_draft') { return; } + refreshPlatformDashboardSilently(); const formPayload = buildPuzzleFormPayloadFromAction(payload) ?? puzzleBackgroundCompileTasks[session.sessionId]?.payload ?? @@ -5804,6 +6420,18 @@ export function PlatformEntryFlowShellImpl({ error: errorMessage, }, })); + markPendingDraftFailed('puzzle', session.sessionId); + markDraftFailed( + 'puzzle', + [ + session.sessionId, + buildPuzzleResultWorkId(session.sessionId), + session.publishedProfileId, + buildPuzzleResultProfileId(session.sessionId), + ], + errorMessage, + ); + void refreshPuzzleShelf(); setPuzzleGenerationState((current) => current ? failedGenerationState : current, ); @@ -6121,6 +6749,11 @@ export function PlatformEntryFlowShellImpl({ source: string; message: string | null | undefined; }> = [ + { + key: pendingPlatformTaskFailureDialog?.key ?? 'draft-failure', + source: pendingPlatformTaskFailureDialog?.source ?? '创作草稿', + message: pendingPlatformTaskFailureDialog?.message, + }, { key: 'creation-entry-config', source: '创作入口配置', @@ -6308,6 +6941,7 @@ export function PlatformEntryFlowShellImpl({ match3dGenerationViewSession?.sessionId, match3dRun?.runId, match3dSession?.sessionId, + pendingPlatformTaskFailureDialog, platformBootstrap.platformError, publicWorkDetailError, puzzleCreationError, @@ -6384,6 +7018,10 @@ export function PlatformEntryFlowShellImpl({ platformBootstrap.setPlatformError(null); return; } + if (currentPlatformErrorDialog.key.startsWith('draft-failure:')) { + setPendingPlatformTaskFailureDialog(null); + return; + } if (currentPlatformErrorDialog.key === 'rpg-creation-type') { sessionController.setCreationTypeError(null); return; @@ -6458,6 +7096,7 @@ export function PlatformEntryFlowShellImpl({ sessionController, setBigFishError, setMatch3DError, + setPendingPlatformTaskFailureDialog, setPuzzleError, setSquareHoleError, setVisualNovelError, @@ -6690,6 +7329,14 @@ export function PlatformEntryFlowShellImpl({ const openPuzzleWorkspace = useCallback(() => { markCreationFlowReturnToCreate(); + setPuzzleSession(null); + setPuzzleFormDraftPayload(null); + setPuzzleOperation(null); + setPuzzleGenerationState(null); + setPuzzleRun(null); + setSelectedPuzzleDetail(null); + setPuzzleRuntimeAuthMode('default'); + activePuzzleGenerationSessionIdRef.current = null; enterCreateTab(); setShowCreationTypeModal(false); setPuzzleCreationError(null); @@ -6697,6 +7344,7 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('puzzle-agent-workspace'); }, [ enterCreateTab, + setPuzzleSession, setPuzzleCreationError, setPuzzleError, setSelectionStage, @@ -6819,9 +7467,10 @@ export function PlatformEntryFlowShellImpl({ setPuzzleFormDraftPayload(payload); setPuzzleCreationError(null); setPuzzleError(null); + const shouldConsumePuzzleDraftPoints = payload.aiRedraw !== false; if ( - payload.aiRedraw !== false && + shouldConsumePuzzleDraftPoints && !(await preflightPuzzleDraftGeneration()) ) { return; @@ -6862,7 +7511,14 @@ export function PlatformEntryFlowShellImpl({ nextSession.publishedProfileId, buildPuzzleResultProfileId(nextSession.sessionId), ]); - markPendingDraftGenerating('puzzle', nextSession.sessionId); + markPendingDraftGenerating( + 'puzzle', + nextSession.sessionId, + buildPendingPuzzleDraftMetadata(payload), + ); + if (shouldConsumePuzzleDraftPoints) { + adjustProfileWalletBalanceLocally(-PUZZLE_DRAFT_GENERATION_POINT_COST); + } selectionStageRef.current = 'puzzle-generating'; activePuzzleGenerationSessionIdRef.current = nextSession.sessionId; setSelectionStage('puzzle-generating'); @@ -6969,12 +7625,16 @@ export function PlatformEntryFlowShellImpl({ if (recovered) { return; } + if (shouldConsumePuzzleDraftPoints) { + adjustProfileWalletBalanceLocally(PUZZLE_DRAFT_GENERATION_POINT_COST); + } const failedGenerationState = resolveFinishedMiniGameDraftGenerationState( generationState, 'failed', { error: errorMessage }, ); + const openFailure = isViewingPuzzleGeneration(nextSession.sessionId); setPuzzleBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -6984,15 +7644,33 @@ export function PlatformEntryFlowShellImpl({ error: errorMessage, }, })); - if (isViewingPuzzleGeneration(nextSession.sessionId)) { + markPendingDraftFailed('puzzle', nextSession.sessionId); + markDraftFailed( + 'puzzle', + [ + nextSession.sessionId, + buildPuzzleResultWorkId(nextSession.sessionId), + nextSession.publishedProfileId, + buildPuzzleResultProfileId(nextSession.sessionId), + ], + errorMessage, + !openFailure, + ); + void refreshPuzzleShelf(); + if (openFailure) { setPuzzleError(errorMessage); setPuzzleGenerationState(failedGenerationState); } + } finally { + refreshPlatformDashboardSilently(); } }, [ + adjustProfileWalletBalanceLocally, markDraftGenerating, + markDraftFailed, markDraftReady, + markPendingDraftFailed, markPendingDraftGenerating, markPendingDraftReady, isViewingPuzzleGeneration, @@ -7000,6 +7678,7 @@ export function PlatformEntryFlowShellImpl({ puzzleFlow, refreshPuzzleShelf, recoverCompletedPuzzleDraftGeneration, + refreshPlatformDashboardSilently, resolvePuzzleErrorMessage, setPuzzleError, setSelectionStage, @@ -7051,7 +7730,12 @@ export function PlatformEntryFlowShellImpl({ nextSession.publishedProfileId, nextSession.sessionId, ]); - markPendingDraftGenerating('match3d', nextSession.sessionId); + markPendingDraftGenerating( + 'match3d', + nextSession.sessionId, + buildPendingMatch3DDraftMetadata(payload), + ); + adjustProfileWalletBalanceLocally(-MATCH3D_DRAFT_GENERATION_POINT_COST); selectionStageRef.current = 'match3d-generating'; activeMatch3DGenerationSessionIdRef.current = nextSession.sessionId; setSelectionStage('match3d-generating'); @@ -7157,12 +7841,51 @@ export function PlatformEntryFlowShellImpl({ error, '执行抓大鹅操作失败。', ); + if (isBackgroundGenerationStillRunningMessage(errorMessage)) { + try { + const { session: latestSession } = + await match3dCreationClient.getSession(nextSession.sessionId); + markPendingDraftGenerating('match3d', latestSession.sessionId); + markDraftGenerating('match3d', [ + latestSession.draft?.profileId, + latestSession.publishedProfileId, + latestSession.sessionId, + ]); + setMatch3DBackgroundCompileTasks((current) => ({ + ...current, + [nextSession.sessionId]: { + session: latestSession, + payload, + generationState, + error: errorMessage, + }, + })); + if (isViewingMatch3DGeneration(nextSession.sessionId)) { + setMatch3DError(errorMessage); + setMatch3DSession(latestSession); + setMatch3DGenerationState(generationState); + const profileId = + latestSession.draft?.profileId ?? + latestSession.publishedProfileId; + if (profileId) { + const { item } = await getMatch3DWorkDetail(profileId); + setMatch3DProfile(normalizeMatch3DWorkForRuntimeUi(item)); + } + } + await refreshMatch3DShelf().catch(() => undefined); + return; + } catch { + await refreshMatch3DShelf().catch(() => undefined); + } + } + adjustProfileWalletBalanceLocally(MATCH3D_DRAFT_GENERATION_POINT_COST); const failedGenerationState = resolveFinishedMiniGameDraftGenerationState( generationState, 'failed', { error: errorMessage }, ); + const openFailure = isViewingMatch3DGeneration(nextSession.sessionId); setMatch3DBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -7172,13 +7895,34 @@ export function PlatformEntryFlowShellImpl({ error: errorMessage, }, })); - if (isViewingMatch3DGeneration(nextSession.sessionId)) { + markPendingDraftFailed('match3d', nextSession.sessionId); + markDraftFailed( + 'match3d', + [ + nextSession.draft?.profileId, + nextSession.publishedProfileId, + nextSession.sessionId, + ], + errorMessage, + !openFailure, + ); + if (openFailure) { setMatch3DError(errorMessage); setMatch3DGenerationState(failedGenerationState); } try { const { session: latestSession } = await match3dCreationClient.getSession(nextSession.sessionId); + markDraftFailed( + 'match3d', + [ + latestSession.draft?.profileId, + latestSession.publishedProfileId, + latestSession.sessionId, + ], + errorMessage, + !openFailure, + ); setMatch3DBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -7188,7 +7932,7 @@ export function PlatformEntryFlowShellImpl({ error: errorMessage, }, })); - if (isViewingMatch3DGeneration(nextSession.sessionId)) { + if (openFailure) { setMatch3DSession(latestSession); const profileId = latestSession.draft?.profileId ?? @@ -7202,17 +7946,23 @@ export function PlatformEntryFlowShellImpl({ } catch { await refreshMatch3DShelf().catch(() => undefined); } + } finally { + refreshPlatformDashboardSilently(); } }, [ + adjustProfileWalletBalanceLocally, match3dRuntimeAdapter, isViewingMatch3DGeneration, markDraftGenerating, + markDraftFailed, markDraftReady, + markPendingDraftFailed, markPendingDraftGenerating, markPendingDraftReady, preflightMatch3DDraftGeneration, refreshMatch3DShelf, + refreshPlatformDashboardSilently, resolveMatch3DErrorMessage, setIsStreamingMatch3DReply, setMatch3DError, @@ -7381,6 +8131,9 @@ export function PlatformEntryFlowShellImpl({ const response = await executePuzzleAgentAction(session.sessionId, { action: 'save_puzzle_form_draft', promptText: payload.pictureDescription ?? null, + workTitle: payload.workTitle, + workDescription: + payload.workDescription ?? payload.pictureDescription ?? '', pictureDescription: payload.pictureDescription ?? '', referenceImageSrc: payload.referenceImageSrc ?? null, referenceImageSrcs: payload.referenceImageSrcs ?? [], @@ -7502,6 +8255,7 @@ export function PlatformEntryFlowShellImpl({ setDraftGenerationNotices({}); setPendingDraftShelfItems({}); setPendingPlatformTaskCompletionDialog(null); + setPendingPlatformTaskFailureDialog(null); resetRpgSessionViewState(); setRpgGeneratedCustomWorldProfile(null); setRpgCustomWorldError(null); @@ -8699,7 +9453,10 @@ export function PlatformEntryFlowShellImpl({ created: JumpHopSessionResponse, payload?: JumpHopWorkspaceCreateRequest, ) => { - const generationState = createMiniGameDraftGenerationState('jump-hop'); + const generationState = createMiniGameDraftGenerationState( + 'jump-hop', + resolveMiniGameDraftGenerationStartedAtMs(created.session.updatedAt), + ); setJumpHopError(null); setJumpHopSession(created.session); writeCreationUrlState( @@ -8710,6 +9467,11 @@ export function PlatformEntryFlowShellImpl({ setJumpHopGenerationState(generationState); setIsJumpHopBusy(true); setSelectionStage('jump-hop-generating'); + markDraftGenerating('jump-hop', [ + created.session.sessionId, + created.session.draft?.profileId, + ]); + markPendingDraftGenerating('jump-hop', created.session.sessionId); try { const response = await jumpHopClient.executeAction( @@ -8777,10 +9539,22 @@ export function PlatformEntryFlowShellImpl({ { error: errorMessage }, ), ); + markPendingDraftFailed('jump-hop', created.session.sessionId); + markDraftFailed( + 'jump-hop', + [created.session.sessionId, created.session.draft?.profileId], + errorMessage, + ); + void refreshJumpHopShelf().catch(() => undefined); try { const latest = await jumpHopClient.getSession( created.session.sessionId, ); + markDraftFailed( + 'jump-hop', + [latest.session.sessionId, latest.session.draft?.profileId], + errorMessage, + ); setJumpHopSession(latest.session); setJumpHopWork(null); writeCreationUrlState( @@ -8797,7 +9571,15 @@ export function PlatformEntryFlowShellImpl({ setIsJumpHopBusy(false); } }, - [createReadyJumpHopGenerationState, setSelectionStage], + [ + createReadyJumpHopGenerationState, + markDraftFailed, + markDraftGenerating, + markPendingDraftFailed, + markPendingDraftGenerating, + refreshJumpHopShelf, + setSelectionStage, + ], ); const retryJumpHopDraftGeneration = useCallback(() => { @@ -9158,10 +9940,33 @@ export function PlatformEntryFlowShellImpl({ { error: errorMessage }, ), ); + markPendingDraftFailed('wooden-fish', created.session.sessionId); + markDraftFailed( + 'wooden-fish', + [created.session.sessionId, created.session.draft?.profileId], + errorMessage, + ); + setWoodenFishWorks((current) => + current.map((item) => + item.sourceSessionId === created.session.sessionId + ? { + ...item, + generationStatus: 'failed', + updatedAt: new Date().toISOString(), + } + : item, + ), + ); + void refreshWoodenFishShelf().catch(() => undefined); try { const latest = await woodenFishClient.getSession( created.session.sessionId, ); + markDraftFailed( + 'wooden-fish', + [latest.session.sessionId, latest.session.draft?.profileId], + errorMessage, + ); setWoodenFishSession(latest.session); setWoodenFishWork(null); writeCreationUrlState( @@ -9180,8 +9985,10 @@ export function PlatformEntryFlowShellImpl({ }, [ createReadyWoodenFishGenerationState, + markDraftFailed, markDraftGenerating, markDraftReady, + markPendingDraftFailed, markPendingDraftGenerating, markPendingDraftReady, refreshWoodenFishShelf, @@ -9479,9 +10286,22 @@ export function PlatformEntryFlowShellImpl({ puzzleFlow.setSession(response.session); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '执行拼图操作失败。')); + } finally { + if ( + payload.action === 'generate_puzzle_images' || + payload.action === 'generate_puzzle_ui_background' + ) { + refreshPlatformDashboardSilently(); + } } }, - [puzzleFlow, puzzleSession, resolvePuzzleErrorMessage, setPuzzleError], + [ + puzzleFlow, + puzzleSession, + refreshPlatformDashboardSilently, + resolvePuzzleErrorMessage, + setPuzzleError, + ], ); const retryPuzzleDraftGeneration = useCallback(() => { @@ -11913,13 +12733,10 @@ export function PlatformEntryFlowShellImpl({ const openJumpHopDraft = useCallback( async (item: JumpHopWorkSummaryResponse) => { - markDraftNoticeSeen( - collectDraftNoticeKeys('jump-hop', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ); + const noticeIds = [item.workId, item.profileId, item.sourceSessionId]; + const hasFailedNotice = isDraftNoticeFailed('jump-hop', noticeIds); + const sessionId = normalizeCreationUrlValue(item.sourceSessionId); + markDraftNoticeSeen(collectDraftNoticeKeys('jump-hop', noticeIds)); if (item.publicationStatus === 'published') { void openJumpHopPublicWorkDetail(item.profileId); @@ -11929,6 +12746,37 @@ export function PlatformEntryFlowShellImpl({ setJumpHopError(null); setPublicWorkDetailError(null); setIsJumpHopBusy(true); + if ( + hasFailedNotice && + sessionId === jumpHopSession?.sessionId && + jumpHopGenerationState?.phase === 'failed' + ) { + enterCreateTab(); + setSelectionStage('jump-hop-generating'); + setIsJumpHopBusy(false); + return; + } + + if (item.generationStatus === 'generating' && !hasFailedNotice) { + const pendingSession = buildJumpHopPendingSession(item); + setJumpHopSession(pendingSession); + setJumpHopRun(null); + setJumpHopWork(null); + setJumpHopGenerationState( + createMiniGameDraftGenerationState( + 'jump-hop', + resolveMiniGameDraftGenerationStartedAtMs(item.updatedAt), + ), + ); + writeCreationUrlState( + buildJumpHopCreationUrlState({ session: pendingSession }), + ); + enterCreateTab(); + setSelectionStage('jump-hop-generating'); + setIsJumpHopBusy(false); + return; + } + try { const detail = await jumpHopClient.getWorkDetail(item.profileId); setJumpHopSession(null); @@ -11947,9 +12795,13 @@ export function PlatformEntryFlowShellImpl({ }, [ enterCreateTab, + isDraftNoticeFailed, + jumpHopGenerationState?.phase, + jumpHopSession?.sessionId, markDraftNoticeSeen, - openPublicWorkDetail, + openJumpHopPublicWorkDetail, setSelectionStage, + writeCreationUrlState, ], ); @@ -11977,13 +12829,11 @@ export function PlatformEntryFlowShellImpl({ const openWoodenFishDraft = useCallback( async (item: WoodenFishWorkSummaryResponse) => { - markDraftNoticeSeen( - collectDraftNoticeKeys('wooden-fish', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ); + const noticeIds = [item.workId, item.profileId, item.sourceSessionId]; + const hasFailedNotice = isDraftNoticeFailed('wooden-fish', noticeIds); + const sessionId = + normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId; + markDraftNoticeSeen(collectDraftNoticeKeys('wooden-fish', noticeIds)); if (item.publicationStatus === 'published') { void openWoodenFishPublicWorkDetail(item.profileId); @@ -11993,11 +12843,28 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishError(null); setPublicWorkDetailError(null); setIsWoodenFishBusy(true); - if (item.generationStatus === 'generating') { + if ( + hasFailedNotice && + sessionId === woodenFishSession?.sessionId && + woodenFishGenerationState?.phase === 'failed' + ) { + enterCreateTab(); + setSelectionStage('wooden-fish-generating'); + setIsWoodenFishBusy(false); + return; + } + + if (item.generationStatus === 'generating' && !hasFailedNotice) { const pendingSession = buildWoodenFishPendingSession(item); setWoodenFishSession(pendingSession); setWoodenFishRun(null); setWoodenFishWork(null); + setWoodenFishGenerationState( + createMiniGameDraftGenerationState( + 'wooden-fish', + resolveMiniGameDraftGenerationStartedAtMs(item.updatedAt), + ), + ); writeCreationUrlState( buildWoodenFishCreationUrlState({ session: pendingSession }), ); @@ -12029,15 +12896,20 @@ export function PlatformEntryFlowShellImpl({ resolveRpgCreationErrorMessage(error, '读取敲木鱼草稿失败。'), ); enterCreateTab(); - setSelectionStage('wooden-fish-generating'); + setSelectionStage( + hasFailedNotice ? 'wooden-fish-workspace' : 'wooden-fish-generating', + ); } finally { setIsWoodenFishBusy(false); } }, [ enterCreateTab, + isDraftNoticeFailed, markDraftNoticeSeen, openWoodenFishPublicWorkDetail, + woodenFishGenerationState?.phase, + woodenFishSession?.sessionId, writeCreationUrlState, setSelectionStage, ], @@ -12173,6 +13045,8 @@ export function PlatformEntryFlowShellImpl({ buildPuzzleResultWorkId(item.sourceSessionId), buildPuzzleResultProfileId(item.sourceSessionId), ]); + const failedNotice = getDraftGenerationNotice(noticeKeys); + const isPersistedFailed = isPersistedDraftFailed(item.generationStatus); const hasGeneratingNotice = isDraftNoticeGenerating('puzzle', [ item.workId, item.profileId, @@ -12180,9 +13054,21 @@ export function PlatformEntryFlowShellImpl({ buildPuzzleResultWorkId(item.sourceSessionId), buildPuzzleResultProfileId(item.sourceSessionId), ]); + const hasFailedNotice = isDraftNoticeFailed('puzzle', [ + item.workId, + item.profileId, + item.sourceSessionId, + buildPuzzleResultWorkId(item.sourceSessionId), + buildPuzzleResultProfileId(item.sourceSessionId), + ]); + const noticeErrorMessage = + failedNotice?.status === 'failed' + ? (failedNotice.message ?? buildDraftFailedShelfSummary('puzzle')) + : buildDraftFailedShelfSummary('puzzle'); const isMarkedGenerating = - (hasGeneratingNotice && !resolvePuzzleWorkCoverImageSrc(item)) || - isPersistedPuzzleDraftGenerating(item); + !hasFailedNotice && + ((hasGeneratingNotice && !resolvePuzzleWorkCoverImageSrc(item)) || + isPersistedPuzzleDraftGenerating(item)); setPuzzleOperation(null); setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); @@ -12202,6 +13088,67 @@ export function PlatformEntryFlowShellImpl({ ); const activeGenerationState = backgroundTask?.generationState ?? puzzleGenerationViewState; + const failedGenerationState = + backgroundTask?.generationState.phase === 'failed' + ? backgroundTask.generationState + : item.sourceSessionId === puzzleSession?.sessionId && + activeGenerationState?.phase === 'failed' + ? activeGenerationState + : hasFailedNotice || isPersistedFailed + ? createFailedMiniGameDraftGenerationStateForRestoredDraft( + 'puzzle', + item.updatedAt, + noticeErrorMessage, + { puzzleAiRedraw: true }, + ) + : null; + + if ((hasFailedNotice || isPersistedFailed) && failedGenerationState) { + let failedSession = backgroundTask?.session ?? null; + let failedPayload = backgroundTask?.payload ?? null; + const failedError = + backgroundTask?.error ?? failedNotice?.message ?? noticeErrorMessage; + if (!failedSession) { + try { + const { session: latestSession } = await getPuzzleAgentSession( + item.sourceSessionId, + ); + failedSession = latestSession; + failedPayload = buildPuzzleFormPayloadFromSession(latestSession); + } catch { + failedPayload = buildPuzzleFormPayloadFromWork(item); + } + } + if (!failedPayload) { + failedPayload = failedSession + ? buildPuzzleFormPayloadFromSession(failedSession) + : buildPuzzleFormPayloadFromWork(item); + } + if (backgroundTask) { + puzzleFlow.setSession(backgroundTask.session); + } else if (failedSession) { + puzzleFlow.setSession(failedSession); + } + setPuzzleFormDraftPayload(failedPayload); + setPuzzleError(failedError); + if (failedSession) { + setPuzzleBackgroundCompileTasks((current) => ({ + ...current, + [failedSession!.sessionId]: { + session: failedSession!, + payload: failedPayload!, + generationState: failedGenerationState, + error: failedError, + }, + })); + } + enterCreateTab(); + selectionStageRef.current = 'puzzle-generating'; + activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; + setPuzzleGenerationState(failedGenerationState); + setSelectionStage('puzzle-generating'); + return; + } if ( item.sourceSessionId === puzzleSession?.sessionId && @@ -12258,26 +13205,34 @@ export function PlatformEntryFlowShellImpl({ item.sourceSessionId, ); const payload = buildPuzzleFormPayloadFromSession(latestSession); - const generationState = - createMiniGameDraftGenerationStateForRestoredDraft('puzzle', { - puzzleAiRedraw: payload.aiRedraw ?? true, - puzzleProgressPercent: - latestSession.draft && !latestSession.draft.formDraft - ? latestSession.progressPercent - : undefined, - }); + const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs( + latestSession.updatedAt, + ); + const baseGenerationState = + createMiniGameDraftGenerationStateForRestoredDraft( + 'puzzle', + { + puzzleAiRedraw: payload.aiRedraw ?? true, + puzzleProgressPercent: + latestSession.draft && !latestSession.draft.formDraft + ? latestSession.progressPercent + : undefined, + }, + startedAtMs, + ); + const generationState = mergePuzzleSessionProgressIntoGenerationState( + baseGenerationState, + latestSession, + ); puzzleFlow.setSession(latestSession); setPuzzleFormDraftPayload(payload); - setPuzzleGenerationState( - rebaseMiniGameDraftGenerationStateForDisplay(generationState), - ); + setPuzzleGenerationState(generationState); setPuzzleBackgroundCompileTasks((current) => ({ ...current, [latestSession.sessionId]: { session: latestSession, payload, - generationState: - rebaseMiniGameDraftGenerationStateForDisplay(generationState), + generationState, error: null, }, })); @@ -12317,6 +13272,8 @@ export function PlatformEntryFlowShellImpl({ [ enterCreateTab, getPuzzleBackgroundCompileTask, + getDraftGenerationNotice, + isDraftNoticeFailed, isDraftNoticeGenerating, markDraftNoticeSeen, openPuzzleDetail, @@ -12362,18 +13319,43 @@ export function PlatformEntryFlowShellImpl({ return; } + const failedNotice = getDraftGenerationNotice(noticeKeys); + const hasFailedNotice = isDraftNoticeFailed('match3d', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]); + const noticeErrorMessage = + failedNotice?.status === 'failed' + ? (failedNotice.message ?? buildDraftFailedShelfSummary('match3d')) + : buildDraftFailedShelfSummary('match3d'); const isMarkedGenerating = - isDraftNoticeGenerating('match3d', [ + !hasFailedNotice && + (isDraftNoticeGenerating('match3d', [ item.workId, item.profileId, item.sourceSessionId, - ]) || isPersistedDraftGenerating(item.generationStatus); + ]) || + isPersistedDraftGenerating(item.generationStatus)); const backgroundTask = getMatch3DBackgroundCompileTask( item.sourceSessionId, ); const activeGenerationState = backgroundTask?.generationState ?? match3dGenerationViewState; + const failedGenerationState = + backgroundTask?.generationState.phase === 'failed' + ? backgroundTask.generationState + : item.sourceSessionId === match3dSession?.sessionId && + activeGenerationState?.phase === 'failed' + ? activeGenerationState + : hasFailedNotice + ? createFailedMiniGameDraftGenerationStateForRestoredDraft( + 'match3d', + item.updatedAt, + noticeErrorMessage, + ) + : null; if (hasUnreadReadyNotice) { try { @@ -12406,6 +13388,52 @@ export function PlatformEntryFlowShellImpl({ } } + if (failedGenerationState) { + let failedSession = backgroundTask?.session ?? null; + let failedPayload = backgroundTask?.payload ?? null; + const failedError = + backgroundTask?.error ?? failedNotice?.message ?? noticeErrorMessage; + if (!failedSession) { + try { + const { session: latestSession } = + await match3dCreationClient.getSession(item.sourceSessionId); + failedSession = latestSession; + failedPayload = buildMatch3DFormPayloadFromSession(latestSession); + } catch { + failedPayload = buildMatch3DFormPayloadFromWork(item); + } + } + if (!failedPayload) { + failedPayload = failedSession + ? buildMatch3DFormPayloadFromSession(failedSession) + : buildMatch3DFormPayloadFromWork(item); + } + if (backgroundTask) { + setMatch3DSession(backgroundTask.session); + } else if (failedSession) { + setMatch3DSession(failedSession); + } + setMatch3DFormDraftPayload(failedPayload); + setMatch3DError(failedError); + if (failedSession) { + setMatch3DBackgroundCompileTasks((current) => ({ + ...current, + [failedSession!.sessionId]: { + session: failedSession!, + payload: failedPayload!, + generationState: failedGenerationState, + error: failedError, + }, + })); + } + enterCreateTab(); + selectionStageRef.current = 'match3d-generating'; + activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; + setMatch3DGenerationState(failedGenerationState); + setSelectionStage('match3d-generating'); + return; + } + if ( item.sourceSessionId === match3dSession?.sessionId && isMiniGameDraftGenerating(activeGenerationState) @@ -12462,9 +13490,14 @@ export function PlatformEntryFlowShellImpl({ setMatch3DSession(latestSession); setMatch3DFormDraftPayload(null); setMatch3DProfile(null); - const generationState = rebaseMiniGameDraftGenerationStateForDisplay( - createMiniGameDraftGenerationStateForRestoredDraft('match3d'), - ); + const generationState = + createMiniGameDraftGenerationStateForRestoredDraft( + 'match3d', + undefined, + resolveMiniGameDraftGenerationStartedAtMs( + latestSession.updatedAt, + ), + ); setMatch3DGenerationState(generationState); enterCreateTab(); selectionStageRef.current = 'match3d-generating'; @@ -12506,6 +13539,8 @@ export function PlatformEntryFlowShellImpl({ [ enterCreateTab, getMatch3DBackgroundCompileTask, + getDraftGenerationNotice, + isDraftNoticeFailed, isDraftNoticeGenerating, isDraftNoticeReadyUnread, markDraftNoticeSeen, diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 602d9470..1b5750f6 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -88,9 +88,7 @@ import { } from '../../services/edutainment-baby-object'; import { jumpHopClient } from '../../services/jump-hop/jumpHopClient'; import { match3dCreationClient } from '../../services/match3d-creation'; -import { - createServerMatch3DRuntimeAdapter, -} from '../../services/match3d-runtime'; +import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { deleteMatch3DWork, getMatch3DWorkDetail, @@ -172,6 +170,7 @@ import { } from '../../services/square-hole-works'; import { listVisualNovelGallery } from '../../services/visual-novel-runtime'; import { listVisualNovelWorks } from '../../services/visual-novel-works'; +import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient'; import { type CustomWorldProfile, WorldType } from '../../types'; import { AuthUiContext, @@ -759,6 +758,22 @@ vi.mock('../../services/visual-novel-works', () => ({ updateVisualNovelWork: vi.fn(), })); +vi.mock('../../services/wooden-fish/woodenFishClient', () => ({ + woodenFishClient: { + checkpointRun: vi.fn(), + createSession: vi.fn(), + executeAction: vi.fn(), + finishRun: vi.fn(), + getGalleryDetail: vi.fn(), + getSession: vi.fn(), + getWorkDetail: vi.fn(), + listGallery: vi.fn(), + listWorks: vi.fn(), + publishWork: vi.fn(), + startRun: vi.fn(), + }, +})); + vi.mock('../../services/visual-novel-creation', () => ({ compileVisualNovelWorkProfile: vi.fn(), createVisualNovelSession: vi.fn(), @@ -2672,6 +2687,12 @@ beforeEach(() => { vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]); vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] }); vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] }); + vi.mocked(woodenFishClient.listGallery).mockResolvedValue({ + items: [], + hasMore: false, + nextCursor: null, + }); + vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] }); vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]); vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]); vi.mocked(jumpHopClient.listGallery).mockResolvedValue({ @@ -3825,9 +3846,9 @@ test('bark battle form checks mud points before creating image assets', async () ).toBeTruthy(); expect(screen.getByText('汪汪声浪配置表单')).toBeTruthy(); expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull(); - expect((screen.getByLabelText('汪汪作品标题') as HTMLInputElement).value).toBe( - '自定义声浪杯', - ); + expect( + (screen.getByLabelText('汪汪作品标题') as HTMLInputElement).value, + ).toBe('自定义声浪杯'); expect(createBarkBattleDraft).not.toHaveBeenCalled(); expect(generateAllBarkBattleImageAssets).not.toHaveBeenCalled(); }); @@ -3975,6 +3996,106 @@ test('running match3d form generation can return to draft tab and reopen progres }); }); +test('background match3d draft failure notifies and reopens failed retry page', async () => { + const user = userEvent.setup(); + const runningSession = buildMockMatch3DAgentSession({ + sessionId: 'match3d-background-failed-session', + draft: null, + stage: 'collecting_config', + }); + const persistedFailedWork: Match3DWorkSummary = { + workId: 'match3d-background-failed-work', + profileId: 'match3d-background-failed-profile', + ownerUserId: 'user-1', + sourceSessionId: runningSession.sessionId, + gameName: '失败中的抓鹅', + themeText: '泥塑水果摊', + summary: '正在生成玩法素材。', + tags: ['水果', '抓大鹅'], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-05-18T12:05:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + generatedItemAssets: [], + }; + let rejectCompile!: (reason?: unknown) => void; + vi.mocked(match3dCreationClient.createSession).mockResolvedValue({ + session: runningSession, + }); + vi.mocked(match3dCreationClient.executeAction).mockReturnValue( + new Promise((_, reject) => { + rejectCompile = reject; + }), + ); + vi.mocked(match3dCreationClient.getSession).mockResolvedValue({ + session: buildMockMatch3DAgentSession({ + sessionId: runningSession.sessionId, + stage: 'collecting_config', + draft: null, + updatedAt: '2026-05-18T12:05:00.000Z', + }), + }); + + render(); + + await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('抓大鹅')); + await user.click( + await screen.findByRole('button', { name: '生成抓大鹅草稿' }), + ); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '返回创作中心' })); + await openDraftHub(user); + await expectDraftHubGeneratingBadgeCountAtLeast(1); + vi.mocked(listMatch3DWorks).mockResolvedValue({ + items: [persistedFailedWork], + }); + + await act(async () => { + rejectCompile(new Error('抓大鹅素材服务失败')); + await Promise.resolve(); + }); + + const failureDialog = await screen.findByRole('dialog', { + name: '发生错误', + }); + expect(within(failureDialog).getByText(/抓大鹅素材服务失败/u)).toBeTruthy(); + await user.click(within(failureDialog).getByRole('button', { name: '关闭' })); + + const draftPanel = getPlatformTabPanel('saves'); + const reopenButton = await within(draftPanel).findByRole('button', { + name: /继续创作《(?:失败中的抓鹅|抓大鹅草稿)》/u, + }); + expect(within(draftPanel).getByText('赛博水果摊')).toBeTruthy(); + await user.click(reopenButton); + + expect(await screen.findByText(/生成失败/u)).toBeTruthy(); + const reopenedFailureDialog = await screen.findByRole('dialog', { + name: '发生错误', + }); + await user.click( + within(reopenedFailureDialog).getByRole('button', { name: '关闭' }), + ); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull(); + }); + expect( + await screen.findByRole('button', { name: '重新生成草稿' }), + ).toBeTruthy(); + expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1); +}); + test('running match3d persisted draft reopens progress instead of unfinished result', async () => { const user = userEvent.setup(); const runningSession = buildMockMatch3DAgentSession({ @@ -4065,9 +4186,6 @@ test('running match3d persisted draft reopens progress instead of unfinished res }), ).toBeTruthy(); expect(screen.queryByText('抓大鹅结果页')).toBeNull(); - expect(match3dCreationClient.getSession).toHaveBeenCalledWith( - 'match3d-running-persisted-session', - ); }); test('persisted generating match3d draft opens generation progress after refresh', async () => { @@ -4135,12 +4253,14 @@ test('persisted generating match3d draft opens generation progress after refresh name: '抓大鹅草稿生成进度', }), ).toBeTruthy(); - expect( + const restoredProgressValue = Number( screen .getByRole('progressbar', { name: '抓大鹅草稿生成进度' }) .getAttribute('aria-valuenow'), - ).toBe('0'); - expect(screen.getByText('0%')).toBeTruthy(); + ); + expect(restoredProgressValue).toBeGreaterThan(0); + expect(restoredProgressValue).toBeLessThan(100); + expect(screen.getByText(`${restoredProgressValue}%`)).toBeTruthy(); expect(screen.queryByText('抓大鹅结果页')).toBeNull(); expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith( 'match3d-profile-generating', @@ -4432,9 +4552,7 @@ test('running puzzle form generation creates a new puzzle draft on same template await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click( - await screen.findByRole('button', { name: '生成草稿' }), - ); + await user.click(await screen.findByRole('button', { name: '生成草稿' })); expect( await screen.findByRole('progressbar', { name: '拼图图片生成进度', @@ -4458,7 +4576,9 @@ test('running puzzle form generation creates a new puzzle draft on same template expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false); await user.click(secondGenerateButton); - expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2); + }); expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2); expect(executePuzzleAgentAction).toHaveBeenNthCalledWith( 1, @@ -4467,7 +4587,7 @@ test('running puzzle form generation creates a new puzzle draft on same template ); expect(executePuzzleAgentAction).toHaveBeenNthCalledWith( 2, - 'puzzle-session-1', + 'puzzle-parallel-session-2', expect.objectContaining({ action: 'compile_puzzle_draft' }), ); @@ -4479,7 +4599,11 @@ test('running puzzle form generation creates a new puzzle draft on same template await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); await waitFor(() => { - expect(screen.getAllByText('拼图草稿').length).toBeGreaterThanOrEqual(2); + expect( + within(getPlatformTabPanel('saves')).getAllByRole('button', { + name: /继续创作《[^》]+》,生成中/u, + }).length, + ).toBeGreaterThanOrEqual(2); }); await expectDraftHubGeneratingBadgeCountAtLeast(2); @@ -4513,6 +4637,158 @@ test('running puzzle form generation creates a new puzzle draft on same template }); }); +test('failed parallel puzzle generations stay as separate non-generating drafts', async () => { + const user = userEvent.setup(); + const firstSession = buildMockPuzzleAgentSession({ + sessionId: 'puzzle-parallel-failed-session-1', + }); + const secondSession = buildMockPuzzleAgentSession({ + sessionId: 'puzzle-parallel-failed-session-2', + }); + let rejectFirstCompile!: (reason?: unknown) => void; + let rejectSecondCompile!: (reason?: unknown) => void; + vi.mocked(createPuzzleAgentSession) + .mockResolvedValueOnce({ + session: firstSession, + }) + .mockResolvedValueOnce({ + session: secondSession, + }); + vi.mocked(executePuzzleAgentAction) + .mockReturnValueOnce( + new Promise((_, reject) => { + rejectFirstCompile = reject; + }), + ) + .mockReturnValueOnce( + new Promise((_, reject) => { + rejectSecondCompile = reject; + }), + ); + + render(); + + await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('拼图')); + await user.click(await screen.findByRole('button', { name: '生成草稿' })); + expect( + await screen.findByRole('progressbar', { + name: '拼图图片生成进度', + }), + ).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '返回创作中心' })); + expect(await screen.findByText('18泥点')).toBeTruthy(); + + await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('拼图')); + await user.click(await screen.findByRole('button', { name: '生成草稿' })); + await waitFor(() => { + expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2); + }); + expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2); + + await clickFirstButtonByName(user, '返回'); + expect(await screen.findByText('16泥点')).toBeTruthy(); + await openDraftHub(user); + const draftPanel = getPlatformTabPanel('saves'); + await waitFor(() => { + expect( + within(draftPanel).getAllByRole('button', { + name: /继续创作《[^》]+》,生成中/u, + }).length, + ).toBeGreaterThanOrEqual(2); + }); + await expectDraftHubGeneratingBadgeCountAtLeast(2); + + vi.mocked(listPuzzleWorks).mockResolvedValue({ + items: [ + { + workId: `puzzle-work-${firstSession.sessionId}`, + profileId: `puzzle-profile-${firstSession.sessionId}`, + ownerUserId: 'user-1', + sourceSessionId: firstSession.sessionId, + authorDisplayName: '测试玩家', + workTitle: '', + workDescription: '一套雨夜猫街主题拼图。', + levelName: '第1关', + summary: '一套雨夜猫街主题拼图。', + themeTags: [], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: '2026-05-18T12:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + generationStatus: 'failed', + levels: [], + }, + { + workId: `puzzle-work-${secondSession.sessionId}`, + profileId: `puzzle-profile-${secondSession.sessionId}`, + ownerUserId: 'user-1', + sourceSessionId: secondSession.sessionId, + authorDisplayName: '测试玩家', + workTitle: '', + workDescription: '一套雨夜猫街主题拼图。', + levelName: '第1关', + summary: '一套雨夜猫街主题拼图。', + themeTags: [], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: '2026-05-18T12:00:01.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + generationStatus: 'failed', + levels: [], + }, + ], + }); + + await act(async () => { + rejectFirstCompile( + new Error( + '拼图 VectorEngine 图片编辑失败:创建图片编辑任务失败:error sending request for url (https://api.vectorengine.cn/v1/images/edits)', + ), + ); + rejectSecondCompile( + new Error( + '拼图 VectorEngine 图片编辑失败:创建图片编辑任务失败:error sending request for url (https://api.vectorengine.cn/v1/images/edits)', + ), + ); + await Promise.resolve(); + }); + + await waitFor(() => { + expect( + within(draftPanel).getAllByRole('button', { + name: /继续创作《[^》]+》/u, + }).length, + ).toBeGreaterThanOrEqual(2); + expect(within(draftPanel).queryAllByLabelText('生成中')).toHaveLength(0); + }); + expect(await screen.findByText('20泥点')).toBeTruthy(); + expect(within(draftPanel).queryByText('第1关')).toBeNull(); + expect( + within(draftPanel).getAllByText('拼图草稿生成失败,可重新打开处理。') + .length, + ).toBeGreaterThanOrEqual(2); + expect( + within(draftPanel).getAllByText('一套雨夜猫街主题拼图。').length, + ).toBeGreaterThanOrEqual(2); + const failureDialog = await screen.findByRole('dialog', { + name: '发生错误', + }); + expect(within(failureDialog).getByText(/拼图 VectorEngine 图片编辑失败/u)) + .toBeTruthy(); +}); + test('running puzzle draft opens generation progress from draft tab', async () => { const user = userEvent.setup(); const runningSession = buildMockPuzzleAgentSession({ @@ -4548,9 +4824,7 @@ test('running puzzle draft opens generation progress from draft tab', async () = await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click( - await screen.findByRole('button', { name: '生成草稿' }), - ); + await user.click(await screen.findByRole('button', { name: '生成草稿' })); expect( await screen.findByRole('progressbar', { name: '拼图图片生成进度', @@ -4562,7 +4836,7 @@ test('running puzzle draft opens generation progress from draft tab', async () = await expectDraftHubGeneratingBadgeCountAtLeast(1); await user.click( - screen.getByRole('button', { name: /继续创作《拼图草稿》/u }), + screen.getByRole('button', { name: /继续创作《[^》]+》,生成中/u }), ); expect( @@ -4602,9 +4876,7 @@ test('puzzle form checks mud points before creating a draft', async () => { await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click( - await screen.findByRole('button', { name: '生成草稿' }), - ); + await user.click(await screen.findByRole('button', { name: '生成草稿' })); const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' }); expect( @@ -4894,7 +5166,9 @@ test('match3d result back returns to draft hub when opened from shelf', async () within(draftPanel).getByRole('tablist', { name: '作品筛选' }), ).toBeTruthy(); expect(within(draftPanel).getByText('自动试玩抓大鹅')).toBeTruthy(); - expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true'); + expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe( + 'true', + ); expect(screen.queryByText('抓大鹅结果页')).toBeNull(); }); @@ -5221,13 +5495,9 @@ test('completed match3d draft notice first opens trial then reopens result', asy name: '生成完成', }); expect( - within(completionDialog).getByText( - /抓大鹅草稿 match3d-notice-session-1/u, - ), - ).toBeTruthy(); - expect( - within(completionDialog).getByText(/生成任务已完成/u), + within(completionDialog).getByText(/抓大鹅草稿 match3d-notice-session-1/u), ).toBeTruthy(); + expect(within(completionDialog).getByText(/生成任务已完成/u)).toBeTruthy(); expect( within(completionDialog).getByRole('button', { name: '复制内容' }), ).toBeTruthy(); @@ -5445,9 +5715,7 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click( - await screen.findByRole('button', { name: '生成草稿' }), - ); + await user.click(await screen.findByRole('button', { name: '生成草稿' })); await waitFor(() => { expect(updatePuzzleWork).toHaveBeenCalledWith( @@ -5534,9 +5802,7 @@ test('embedded puzzle form recovers when compile request times out after backend await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click( - await screen.findByRole('button', { name: '生成草稿' }), - ); + await user.click(await screen.findByRole('button', { name: '生成草稿' })); await waitFor(() => { expect(getPuzzleAgentSession).toHaveBeenCalledWith( @@ -6685,7 +6951,10 @@ test('home recommendation puzzle next level switches to similar work detail', as nextLevelId: 'puzzle-level-2', recommendedNextWorks: [], }; - const startedRun = buildMockPuzzleRun(entryWork.profileId, entryWork.levelName); + const startedRun = buildMockPuzzleRun( + entryWork.profileId, + entryWork.levelName, + ); const similarRun = { ...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName), runId: clearedRun.runId, @@ -6719,7 +6988,9 @@ test('home recommendation puzzle next level switches to similar work detail', as vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({ run: clearedRunWithSameWorkNext, }); - let resolveAdvancePuzzleNextLevel!: (value: { run: PuzzleRunSnapshot }) => void; + let resolveAdvancePuzzleNextLevel!: (value: { + run: PuzzleRunSnapshot; + }) => void; vi.mocked(advancePuzzleNextLevel).mockReturnValue( new Promise((resolve) => { resolveAdvancePuzzleNextLevel = resolve; @@ -6753,10 +7024,9 @@ test('home recommendation puzzle next level switches to similar work detail', as await user.click(within(dialog).getByRole('button', { name: '下一关' })); await waitFor(() => { - expect(advancePuzzleNextLevel).toHaveBeenCalledWith( - clearedRun.runId, - { preferSimilarWork: true }, - ); + expect(advancePuzzleNextLevel).toHaveBeenCalledWith(clearedRun.runId, { + preferSimilarWork: true, + }); }); expect(screen.getByTestId('puzzle-board')).toBeTruthy(); expect(screen.queryByText('加载中...')).toBeNull(); @@ -7457,8 +7727,8 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1); expect(createCreativeAgentSession).not.toHaveBeenCalled(); expect( - await screen.findByText('当前登录状态已失效,请重新登录后继续。'), - ).toBeTruthy(); + await screen.findAllByText('当前登录状态已失效,请重新登录后继续。'), + ).not.toHaveLength(0); expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull(); }); @@ -7572,7 +7842,9 @@ test('puzzle draft result back button returns to draft hub when opened from shel within(draftPanel).getByRole('tablist', { name: '作品筛选' }), ).toBeTruthy(); expect(within(draftPanel).getByText('雨夜猫塔')).toBeTruthy(); - expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true'); + expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe( + 'true', + ); expect(screen.queryByText('拼图工作区:missing-session')).toBeNull(); expect( screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'), @@ -7635,14 +7907,14 @@ test('persisted generating puzzle draft opens generation progress after refresh' name: '拼图图片生成进度', }), ).toBeTruthy(); - expect( - Number( - screen - .getByRole('progressbar', { name: '拼图图片生成进度' }) - .getAttribute('aria-valuenow'), - ), - ).toBe(0); - expect(screen.getByText('0%')).toBeTruthy(); + const restoredProgressValue = Number( + screen + .getByRole('progressbar', { name: '拼图图片生成进度' }) + .getAttribute('aria-valuenow'), + ); + expect(restoredProgressValue).toBeGreaterThan(0); + expect(restoredProgressValue).toBeLessThan(100); + expect(screen.getByText(`${restoredProgressValue}%`)).toBeTruthy(); expect(screen.queryByText('拼图结果页')).toBeNull(); }); @@ -7736,7 +8008,9 @@ test('puzzle compile timeout shows failure dialog when reread session is still g await user.click(await screen.findByRole('button', { name: '生成草稿' })); const dialog = await screen.findByRole('dialog', { name: '发生错误' }); - expect(within(dialog).getByText('拼图草稿 puzzle-session-timeout')).toBeTruthy(); + expect( + within(dialog).getByText('拼图草稿 puzzle-session-timeout'), + ).toBeTruthy(); expect( within(dialog).getByText( '拼图共创操作超时,请确认运行时后端已启动后重试。', @@ -9818,8 +10092,12 @@ test('agent draft result back button returns to draft hub without syncing result await waitFor(() => { expect(draftPanel.getAttribute('aria-hidden')).toBe('false'); }); - expect(within(draftPanel).getByRole('tablist', { name: '作品筛选' })).toBeTruthy(); - expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true'); + expect( + within(draftPanel).getByRole('tablist', { name: '作品筛选' }), + ).toBeTruthy(); + expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe( + 'true', + ); expect( vi @@ -10915,8 +11193,9 @@ test('creation hub published work card reveals delete action after card action r publishedCard.focus(); await user.keyboard('{ArrowLeft}'); - expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); - await user.click(screen.getByRole('button', { name: '删除' })); + const deleteButtons = screen.getAllByRole('button', { name: '删除' }); + expect(deleteButtons.length).toBeGreaterThan(0); + await user.click(deleteButtons[0]!); const dialog = await screen.findByRole('dialog', { name: '删除作品' }); expect(dialog.parentElement?.className).toContain('platform-theme--light'); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 47b0e4b8..6d5a5a8d 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -7059,7 +7059,8 @@ export function RpgEntryHomeView({
- ) : isAuthenticated && activeTab === 'create' ? ( + ) : isAuthenticated && + (activeTab === 'create' || activeTab === 'saves') ? (