diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 68968483..f87187c7 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -24,12 +24,45 @@ - 验证方式:泥点和会员商品在小程序运行态都请求 `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-26 平台跨流程错误统一用可复制来源弹窗展示 + +- 背景:拼图等生成链路可能同时存在多个草稿或游玩实例,页面内裸错误 banner 容易让用户误以为当前正在看的拼图失败,也不方便复制完整错误给开发排查。 +- 决策:平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`;弹窗必须带错误来源,例如某个草稿、生成会话、作品详情或游玩实例,并提供复制按钮复制来源与错误内容。页面内旧的裸错误 banner、创作入口 modal 错误、生成页错误徽标等不再重复展示;表单校验和发布确认弹窗里的局部业务错误仍可保留在原弹窗内。 +- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformErrorDialog.tsx`、`src/components/CustomWorldGenerationView.tsx`、`src/components/custom-world-home/CustomWorldCreationHub.tsx`、`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/components/platform-entry/PlatformWorkDetailView.tsx`、`src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`。 +- 验证方式:`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx`、`npm run typecheck`、`npm run check:encoding` 通过;手测时异步失败应弹出包含“错误来源”和“错误内容”的弹窗,复制按钮应复制完整诊断文本。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-26 生成任务完成在离开生成页后弹独立完成弹窗 + +- 背景:抓大鹅、拼图等生成任务完成时,用户如果已经离开生成页,草稿页的未读红点不足以表达“这次生成已完成”;但如果用户仍停留在生成页,结果页或试玩页本身就是完成反馈,不需要再叠一个成功提示。 +- 决策:平台壳层在 `markDraftReady(..., viewedImmediately=false)` 时额外弹出 `PlatformTaskCompletionDialog`,完成弹窗必须带来源和复制按钮;如果 `viewedImmediately=true`,只保留结果页 / 试玩页本身的完成反馈和草稿未读态,不重复弹窗。 +- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformTaskCompletionDialog.tsx`、`src/components/platform-entry/PlatformErrorDialog.test.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "completed match3d draft"` 通过后,离开生成页再完成的草稿应出现“生成完成”弹窗,且复制内容包含来源与状态。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-26 “我的”页任务卡读后端任务摘要并移除常驻填邀请码入口 + +- 背景:移动端“我的”页每日任务卡曾硬编码 `0 / 1`,任务领取完成后只刷新弹窗内任务中心,卡片本身不更新;页面底部还保留旧的“填邀请码”次级按钮,和当前五项常用功能宫格口径重复。 +- 决策:`RpgEntryHomeView` 的每日任务卡以 `/api/profile/tasks` 返回的任务中心为事实源,展示当前可操作任务的奖励、进度和状态;领取成功后同步使用 claim 响应里的 `center` 刷新卡片。移动端“我的”页不再渲染常驻“填邀请码”次级入口,邀请码填写仅保留邀请链接 query 自动打开弹窗和其它明确引导。 +- 影响范围:`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`。 +- 决策:共用 `GenerationProgressHero` 的 SVG 圆弧起始角从 `160deg` 调整为 `155deg`,track 和 fill 都使用同一个 `rotate(155 200 200)` 变换;仍保持 `270deg` 扫描角和正下方 `90deg` 留空。 +- 决策:总进度标题与百分比数字在 `GenerationProgressHero` 中显式提升到圆环之上,圆环 SVG 维持背景层级。 +- 决策:总进度标题与百分比数字的内容区上边距从 `pt-[4%]` 收紧到 `pt-[2%]`,桌面端使用 `sm:pt-[1.5%]`,进一步拉开与圆环弧线的距离。 +- 影响范围:`src/components/GenerationProgressHero.tsx`、共用 `CustomWorldGenerationView`、汪汪声浪 `BarkBattleGeneratingView` 以及生成页圆环布局文档。 +- 验证方式:`CustomWorldGenerationView` 和 `BarkBattleGeneratingView` 测试断言 `data-ring-start-degrees=155` 且 track / fill transform 都是 `rotate(155 200 200)`。 +- 关联文档:`docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`。 + ## 2026-05-25 抓大鹅发现页官方 demo 使用静态资源与本地运行态 -- 背景:本轮抓大鹅资源管线已经生成完整 `level-scene`、背景、UI spritesheet、物品 spritesheet 和切片资源,需要放入发现页作为可试玩验证入口,但不应把一次性本地资源包装成后端正式作品。 -- 决策:发现页官方抓大鹅 demo 固定 profileId 为 `match3d-demo-20260525`、公开作品号为 `M3-20260525`,资源读取 `public/match3d-demo/undersea-candy-market/` 下的静态文件。公开卡片、作品号搜索和详情页沿用平台公开作品详情链路;启动运行态时用 `createLocalMatch3DRuntimeAdapter`,不调用正式 Match3D runtime 后端、不新增 SpacetimeDB schema、不写正式作品统计。 -- 影响范围:`src/data/match3dDemoGalleryCard.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、发现页公开卡片、作品号搜索、Match3D 本地 runtime adapter、玩法链路文档。 -- 验证方式:搜索 `M3-20260525` 能打开“海底糖果集市”并启动本地抓大鹅运行态;正式 Match3D 公开作品仍走 server runtime adapter。 +- 背景:本轮抓大鹅资源管线曾生成一套官方静态 demo,用于验证生图、切图和运行态资源闭环,但该 demo 已被移除,不再作为发现页入口。 +- 决策:发现页不再挂载前端固定官方抓大鹅 demo;公开卡片、作品号搜索、详情页和运行态启动全部来自后端真实 profile / gallery 投影,正式作品统一走 server runtime adapter。 +- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、原前端 demo 数据文件(已删除)、Match3D 相关测试与原静态资源目录(已删除)。 +- 验证方式:发现页不再出现原固定 demo;Match3D 公共详情只从真实后端作品数据读取。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 ## 2026-05-25 抓大鹅运行态 HUD 收敛为拼图同款低遮挡样式 @@ -152,6 +185,14 @@ - 验证方式:执行 `cargo test -p api-server wooden_fish --manifest-path server-rs/Cargo.toml`、`cargo test -p spacetime-client wooden_fish --manifest-path server-rs/Cargo.toml`、`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run typecheck`。 - 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-05-25 通用系列素材图集实现下沉到 platform-image + +- 背景:`generated_asset_sheets` 同时承载 sheet prompt、切图、绿幕去背、边缘 matte 清理和 OSS 持久化准备,长期放在 `api-server` 会把多个玩法的图片 seam 继续绑死在 HTTP crate 上。 +- 决策:通用系列素材图集的实现真值源下沉到 `platform-image::generated_asset_sheets`,`api-server::generated_asset_sheets` 只保留 `AppState` / `AppError` 适配与调用方兼容导出,不再承载图像处理和 OSS 请求构造细节。 +- 影响范围:`server-rs/crates/platform-image/src/generated_asset_sheets/`、`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`server-rs/crates/api-server/src/match3d/item_assets.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:`cargo test -p platform-image --test generated_asset_sheets --manifest-path server-rs/Cargo.toml` 与 `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 通过;调用方继续通过 `api-server` 的薄包装访问同一组能力。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-22 敲木鱼敲击物暂不做服务端抠图后处理 - 背景:gpt-image-2 偶尔会把木鱼图直接回成带黑底或其它实底背景的 PNG,但服务端抠图后处理在玉米等主题上误伤过主体像素。 @@ -180,11 +221,19 @@ ## 2026-05-25 VectorEngine 图片 provider 收到 platform-image - 背景:`api-server` 里原本同时混着 VectorEngine 创建 / 编辑协议、响应解析、远端图片下载、失败日志和审计落库逻辑,Puzzle / Match3D 还各自藏着一份近似实现,导致“provider 协议”和“业务编排”边界不清。 -- 决策:把 VectorEngine `gpt-image-2` 图片 provider 协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志统一收口到 `server-rs/crates/platform-image`。`api-server` 只保留配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计桥接;旧 `openai_image_generation.rs` 只作为兼容转接层,不再承担 provider 实现。 +- 决策:把 VectorEngine `gpt-image-2` 图片 provider 协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志统一收口到 `server-rs/crates/platform-image/src/vector_engine/`,并按 `client.rs`、`transport.rs`、`request.rs`、`payload.rs`、`response.rs`、`image_source.rs` 等小模块拆分,避免把大文件从 `api-server` 平移到平台 crate。`api-server` 只保留配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计桥接;旧 `openai_image_generation.rs` 只作为兼容转接层,不再承担 provider 实现。 - 影响范围:`server-rs/crates/platform-image`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、后端架构与运维文档。 -- 验证方式:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 +- 验证方式:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-image --test vector_engine --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 2026-05-26 音频 provider 协议收口到 platform-audio,Hyper3D 继续保持薄代理 + +- 背景:`api-server/src/vector_engine_audio_generation.rs` 和 `api-server/src/hyper3d_generation.rs` 仍然承担太多 provider 细节,容易把外部协议、下载、解析和 BFF 编排混在一起。 +- 决策:VectorEngine Suno/Vidu 音频协议、任务提交/轮询、下载和 OSS 持久化请求准备收口到 `platform-audio`,并继续按 `client.rs`、`request.rs`、`response.rs`、`download.rs`、`persist.rs`、`error.rs` 拆小模块;`api-server` 只保留路由、配置、计费、asset_object confirm、entity binding 和错误映射。Hyper3D 维持后端安全代理和旧数据兼容,`platform-hyper3d` 承接 Rodin 的协议与解析,`api-server` 仅做薄 wrapper。 +- 影响范围:`server-rs/crates/platform-audio/`、`server-rs/crates/platform-hyper3d/`、`server-rs/crates/api-server/src/vector_engine_audio_generation.rs`、`server-rs/crates/api-server/src/hyper3d_generation.rs`、相关后端架构文档。 +- 验证方式:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-hyper3d --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 通过;`api-server` 不再包含音频 provider 协议和 Hyper3D parser 主实现。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md`。 + ## 2026-05-21 拼图参考图主链改为 OSS assetObjectId 与只读签名 URL - 背景:release 上拼图图生图生成草稿时,旧链路把上传图转成 Data URL/base64 放进创作 action JSON body,容易先触发 Nginx `413 Request Entity Too Large`,也让外部模型调用前的 HTTP body 过大。 @@ -540,6 +589,13 @@ - 验证方式:执行 `npm run check:encoding`、`node scripts/check-wechat-miniprogram-auth-smoke.mjs`、`cargo test -p shared-contracts wechat_bind_phone_request_accepts_mini_program_phone_code --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_miniprogram_bind_phone_code_activates_pending_user --manifest-path server-rs/Cargo.toml -- --nocapture`。 - 关联文档:`docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md`。 +## 2026-05-26 微信小程序进入即开 H5,登录按需走原生手机号授权 + +- 背景:当前产品要求微信小程序进入后不再立刻取手机号,而是默认直接进入 `web-view`,登录状态与 Web 端统一;只有 H5 触发受保护操作时才走微信手机号授权。 +- 决策:小程序壳首次进入只打开 H5,不再把登录态当作启动前置条件;H5 侧在小程序运行态触发登录时,不展示普通登录弹窗,而是跳转到小程序原生手机号授权流程,授权结果再回灌到 H5。未触发登录时保持游客态,与 Web 端一致。 +- 影响范围:`miniprogram/pages/web-view/index.*`、`src/components/auth/AuthGate.tsx`、`src/components/auth/LoginScreen.tsx`、`src/services/authService.ts`、相关测试与说明文档。 +- 验证方式:执行 `npm run check:encoding`、`npm run typecheck`、`npx vitest run src/components/auth/AuthGate.test.tsx src/services/authService.test.ts scripts/miniprogram-web-view-auth.test.ts`。 + ## 2026-05-13 宝贝爱画先作为寓教于乐独立本地 Demo 落地 - 背景:第三关 `宝贝爱画` 需要默认出现在“发现 / 寓教于乐”板块下方,但本阶段只验证画板、手部绘制、绘画魔法和本地保存闭环,不进入创作模板、公开作品或正式持久化。 @@ -957,6 +1013,14 @@ - 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。 - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 2026-05-26 跳一跳地块图集改为专用 2x3 六格切分 + +- 背景:跳一跳创作在地块生图阶段误用了通用系列素材图集 helper,`item_names.len() > grid_size` 的校验会让 6 个地块类型在 `grid_size = 3` 时直接失败;即使绕过校验,通用 helper 仍以“每物品多视图”语义切图,不符合跳一跳地块的一次性六格资产模型。 +- 决策:跳一跳地块图集固定采用专用 `2行*3列` 六格布局,按 `start / normal / target / finish / bonus / accent` 顺序切分并分别持久化为独立 PNG 资产;图集 prompt 不再调用通用系列素材 `build_generated_asset_sheet_prompt`。 +- 影响范围:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过;六张切片都应有独立 OSS 对象与 `JumpHopTileAsset` 记录,不再只有 atlas 预览路径。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。 + # 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙 - 背景:用户要求只替换产品各界面的 UI 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据。 @@ -1000,3 +1064,18 @@ - 影响范围:`WoodenFishWorkspace`、`WoodenFishResultView`、`PlatformEntryFlowShellImpl`、敲木鱼 PRD 和平台入口链路文档。 - 验证方式:工作台首屏不再出现标题 / 简介 / 标签输入;结果页修改后点试玩或发布会先写回当前作品信息。 - 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-26 前端不外露图片模型名 + +- 背景:拼图与相关结果页、生成进度和错误提示里直接显示 `gpt-image-2`、`gemini-3.1-flash-image-preview`、`image-2` 等名称,会把内部模型路由暴露给普通用户。 +- 决策:前端展示层统一改用产品化名称,如“标准模式”“创意模式”,以及“素材”“图片生成模式”等中性文案;内部 `imageModel`、`generationProvider` 和后端契约值保留不变,只改 UI 文案与错误提示。 +- 影响范围:拼图图片模型选择器、拼图结果页关卡重生成面板、拼图生成进度文案、宝贝识物结果页占位提示和相关错误提示。 +- 验证方式:前端可见文本中不再出现 `gpt-image-2` / `gemini-3.1-flash-image-preview` / `image-2 资源`;相关交互测试改为断言产品化模式名,但提交 payload 仍保持原有模型 ID。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 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`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index bbafe88d..bf8af0d3 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -15,6 +15,30 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 平台异步错误必须带来源弹窗,不要只显示裸错误 + +- 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。 +- 原因:不同入口、生成页、结果页、作品详情和运行态各自渲染局部错误,没有统一携带草稿、生成会话、作品或游玩来源。 +- 处理:跨流程错误统一由 `PlatformEntryFlowShellImpl` 汇总为 `PlatformErrorDialog`,来源使用玩法、草稿 / session / work / run 标识组成;弹窗提供复制按钮。关闭弹窗时只清理可安全清理的错误状态;恢复类错误用 dismiss key 防止反复弹出但不擅自改底层状态。 +- 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformErrorDialog.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## “我的”页每日任务卡不要硬编码进度 + +- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。 +- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗。 +- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`,领取后显示已完成。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 + +## “我的”页不要恢复旧的填邀请码次级按钮 + +- 现象:移动端“我的”页在五项常用功能和设置入口下方又出现一个“填邀请码”按钮,看起来像旧入口残留。 +- 原因:邀请码流程迁移后仍按新用户窗口保留 `canShowReferralRedeemShortcut` 次级入口;但当前页面口径已经固定为五项常用功能宫格,邀请码填写应由邀请链接 query 或明确引导打开弹窗。 +- 处理:移除常驻 `次级入口` / `填邀请码` 渲染,不删除 `ProfileReferralModal` 的 `redeem` 面板,也不破坏 `?inviteCode=` / `?invite_code=` 自动打开填写弹窗。 +- 验证:新用户账号打开“我的”页时没有 `次级入口` 和 `填邀请码` 按钮;带 `?inviteCode=spring-2026` 的登录用户仍自动打开邀请码弹窗并预填 `SPRING2026`。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`.hermes/skills/genarrative-profile-invite-flow/SKILL.md`。 + ## 创作卡片点击要直达已有入口表单,别再保留空白入口页 - 现象:创作 Tab 模板卡点击后如果仍然停留在创作大厅,或者先进入“X 创作入口”这种空白页,就会让用户多走一层,还可能被错误的 stage 白名单拉回平台。 @@ -126,6 +150,14 @@ - 验证:`npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx` 应覆盖“运行态不把兼容写入的UI spritesheet当中心容器图”。 - 关联:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`server-rs/crates/api-server/src/match3d/mappers.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 通用系列素材图集先看 platform-image,不要先翻 api-server 大文件 + +- 现象:排查跳一跳、抓大鹅或其它玩法的系列素材图集切片 / 去绿 / 持久化时,最容易先打开 `api-server/src/generated_asset_sheets.rs`,结果在一个 60KB+ 大文件里找实现、测试和辅助函数,定位很慢。 +- 原因:这条通用图片 seam 已经下沉到 `server-rs/crates/platform-image/src/generated_asset_sheets/`,`api-server` 只剩薄包装和调用方兼容;继续把 `api-server` 当真值源会把理解路径拉回旧位置。 +- 处理:先看 `server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs`、`prompt.rs`、`sheet.rs`、`alpha.rs`、`persist.rs` 和 `error.rs`,再看 `api-server/src/generated_asset_sheets.rs` 的 AppError / AppState 适配和玩法调用点。 +- 验证:`cargo test -p platform-image --test generated_asset_sheets --manifest-path server-rs/Cargo.toml` 通过,且 `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 保持绿灯。 +- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/`、`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## UI spritesheet 不要依赖模型直接生成透明背景 - 现象:拼图或抓大鹅运行态解析 UI spritesheet 时,把整张背景图、棋盘格、叶子或装饰图也当作 UI 素材区域,按钮映射错乱;截图里常表现为底部按钮区只剩透明棋盘格或素材碎片。 @@ -293,9 +325,25 @@ - 现象:排查拼图或其它玩法的生图失败时,如果直接在 `api-server` 的大文件里找 `images/generations`、`images/edits`、base64 解码或下载逻辑,会看到很多历史 helper 和测试桥,看起来像每个玩法都自带一份 provider 实现。 - 原因:旧实现把 VectorEngine 图片 provider 协议、响应解析、下载和日志混在 `api-server` 里,后来虽然迁出到 `platform-image`,但兼容层和测试 helper 仍会让人误判真相源位置。 -- 处理:先看 `server-rs/crates/platform-image/src/lib.rs` 的 provider 协议和结构化日志,再看 `server-rs/crates/api-server/src/openai_image_generation.rs` 的兼容桥和 `external_api_audit.rs` 的落库映射;`puzzle/vector_engine.rs` 只保留玩法编排,不再作为 provider 协议真相源。 -- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml -- --nocapture` 通过时,排障先按 `platform-image` 的日志字段查 provider / endpoint / failure_stage。 -- 关联:`server-rs/crates/platform-image/src/lib.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`。 +- 处理:先看 `server-rs/crates/platform-image/src/vector_engine/`:`request.rs` 查路径和请求体,`client.rs` 查生成 / 编辑编排,`transport.rs` 查 HTTP client 与 reqwest 错误归一,`payload.rs` 查响应字段提取,`response.rs` 查上游状态、解析、缺图和下载分流,`image_source.rs` 查参考图和远端图片下载。再看 `server-rs/crates/api-server/src/openai_image_generation.rs` 的兼容桥和 `external_api_audit.rs` 的落库映射;`puzzle/vector_engine.rs` 只保留玩法编排,不再作为 provider 协议真相源。 +- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-image --test vector_engine --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture` 通过时,排障先按 `platform-image` 的日志字段查 provider / endpoint / failure_stage。 +- 关联:`server-rs/crates/platform-image/src/vector_engine/`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`。 + +## 音频 provider 协议先看 platform-audio,不要先翻 api-server 大文件 + +- 现象:排查 Visual Novel 或通用创作音频生成失败时,如果直接打开 `api-server/src/vector_engine_audio_generation.rs`,会同时看到路由、计费、asset binding、下载、解析和 provider 协议,定位时很容易在同一个文件里来回跳。 +- 原因:音频 provider 已经迁到 `server-rs/crates/platform-audio/`,但 `api-server` 仍保留薄 wrapper;如果把 wrapper 当真值源,就会误判边界。 +- 处理:先看 `server-rs/crates/platform-audio/src/client.rs`、`request.rs`、`response.rs`、`download.rs`、`persist.rs`、`error.rs`,再看 `api-server/src/vector_engine_audio_generation.rs` 的路由、配置、计费、asset object confirm 和 entity binding 包裹。 +- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml` 通过,且 `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 保持绿灯。 +- 关联:`server-rs/crates/platform-audio/`、`server-rs/crates/api-server/src/vector_engine_audio_generation.rs`。 + +## Hyper3D 现在只剩后端薄代理,不要再把协议解析写回 api-server + +- 现象:排查 Hyper3D/Rodin 时,如果继续在 `api-server/src/hyper3d_generation.rs` 里扩协议解析、请求体构造或下载列表处理,文件会重新变厚。 +- 原因:`platform-hyper3d` 已经承接 Rodin 的提交、状态和下载协议解析;`api-server` 只是薄 wrapper 和错误 envelope 映射。 +- 处理:新增或修改 Hyper3D 协议时优先放到 `server-rs/crates/platform-hyper3d/` 的 `client.rs`、`request.rs`、`response.rs`、`transport.rs` 和子模块,`api-server` 只保留鉴权、配置校验和错误映射。 +- 验证:`cargo test -p platform-hyper3d --manifest-path server-rs/Cargo.toml` 通过后再看 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/platform-hyper3d/`、`server-rs/crates/api-server/src/hyper3d_generation.rs`。 ## release 创作接口 413 先查是否还在提交 Data URL @@ -1090,6 +1138,22 @@ - 验证:`PuzzleResultView` 单测覆盖发布弹窗内展示 `泥点余额不足`。 - 关联:`src/components/puzzle-result/PuzzleResultView.tsx`、`docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md`、`docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md`。 +## 拼图发布检查阶段会在事件落库时炸 wasm + +- 现象:拼图发布在“发布检查”环节直接报 `The module instance encountered a fatal error`,wasm backtrace 指向 `spacetime_module::puzzle::publish_puzzle_work`,并停在 `procedure_commit_mut_tx` 的 commit 阶段。 +- 原因:`publish_puzzle_work_tx` 会无条件调用 `emit_puzzle_work_published_event` 写入 `puzzle_event`;该表的 `event_id` 是主键,而事件 ID 由 `profile_id + published_at_micros` 组成。只要同一发布动作被重复执行、重放,或极端情况下发生时间戳碰撞,commit 时就会因主键冲突触发 fatal error。 +- 处理:待修复。发布事件写入需要改成幂等,或在重复发布时显式跳过已存在的 `event_id`;发布动作本身也应补一层更明确的幂等键,避免把重复提交直接推到事务提交阶段。 +- 验证:对同一 `session_id/profile_id/published_at_micros` 重复调用 `publish_puzzle_work` 时,不应再在 commit 阶段炸 wasm;正常发布仍应生成作品、更新 session,并可进入公开详情。 +- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/spacetime-client/src/module_bindings/puzzle_event_table.rs`。 + +## 拼图会过早进入待发布态,结果页可能空图但仍显示可发布 + +- 现象:拼图创作有时刚结束就跳到“待发布”结果页,但结果页里的正式图还是空的,发布检查随后又会拦住,用户会感觉“已经完成了却又不能发布”。 +- 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements` 和 `is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src`、`ui_spritesheet_image_src`、`level_background_image_src` 等完整资产都齐;前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。 +- 处理:待修复时要把“待发布”门槛收紧到整套拼图资产包完整,再让恢复逻辑只在完整草稿下抬高为完成态,避免半成品直接进入结果页。 +- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,不应再进入 `ready_to_publish`;结果页也不应把这类草稿误判为已完成。 +- 关联:`server-rs/crates/module-puzzle/src/application.rs`、`server-rs/crates/api-server/src/puzzle/tags.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`。 + ## WebGL 画布在高 DPR 移动端放大溢出 - 现象:抓大鹅试玩入口进入后,3D 锅体和物体从中心圆形区域向右下溢出,顶部状态和底部备选栏也可能看起来被右侧裁切。 @@ -1460,6 +1524,14 @@ - 验证:`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。 - 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 跳一跳地块图集不要套通用系列素材 n 行模型 + +- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者即使绕过报错也只生成了 atlas 预览路径,地块切片没有真正落盘。 +- 原因:跳一跳地块只有 6 个固定 tileType,但旧实现把它塞进通用系列素材 helper,并使用 `grid_size = 3` / `item_names = 6` 的语义冲突模型;随后又只保留 atlas 资产与模拟路径,没把六个切片逐一上传并确认到 `JumpHopTileAsset`。 +- 处理:跳一跳地块改用专用 `2行*3列` 图集 prompt,按 `start / normal / target / finish / bonus / accent` 顺序切 6 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind。 +- 验证:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过后,再看 `jump_hop.rs` 不应再调用 `build_generated_asset_sheet_prompt` 处理地块图集;公开结果里应能拿到 6 个独立 `JumpHopTileAsset`。 +- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## image2 dry-run 带参考图时不要直接打印 data URL - 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。 @@ -1535,3 +1607,11 @@ - 处理:后续如果需要重新暴露存档入口,优先评估是否应回到“玩过”或别的独立弹窗流程,不要默认把存档再塞回常用功能宫格或设置列表。 - 验证:`npm test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile scan action opens camera scanner instead of recharge panel"`。 - 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 访客推荐页上下滑不要绑定登录态 + +- 现象:访客模式进入移动端推荐页后,推荐内容可展示和点击底部“下一个”,但在作品信息区域上下滑不会切换推荐作品,表现为推荐页不能上下滑动。 +- 原因:推荐页滑动切换逻辑 `beginRecommendDrag(...)` 误把 `isAuthenticated` 作为启用条件;访客态虽然允许浏览和通过底部按钮切换,却无法触发同一套拖拽切换。 +- 处理:推荐页拖拽只校验当前是否有作品、多作品可切换以及是否正在提交动画,不再要求登录;登录态相关操作仍由点赞、改造等按钮自身权限控制。 +- 验证:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 覆盖访客态纵向滑动不弹登录且触发下一条推荐。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。 diff --git a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md index 238a200b..fa2c56f6 100644 --- a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md +++ b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md @@ -276,6 +276,7 @@ HTTP 路由: POST /api/creation/wooden-fish/sessions GET /api/creation/wooden-fish/sessions/{sessionId} POST /api/creation/wooden-fish/sessions/{sessionId}/actions +GET /api/creation/wooden-fish/works GET /api/creation/wooden-fish/works/{profileId} POST /api/creation/wooden-fish/works/{profileId}/publish GET /api/runtime/wooden-fish/works/{profileId} @@ -304,6 +305,8 @@ finish 敲木鱼创作请求在前端必须使用长等待窗口,避免 `createSession` 或 `executeAction` 仍沿用共享创作工厂默认的 15 秒超时。因为 `compile-draft` 会串行等待敲击物、背景、返回按钮三次 image2 和 OSS 落库,木鱼 client 需要单独配置与整条 image2 链路匹配的超时。本地测试中该 action 可能达到数分钟级;生成页进度必须按“整理草稿 -> 生成敲击物 -> 生成背景环境图 -> 生成返回按钮图 -> 写入正式草稿”展示,不展示“提示词生成音效”阶段,因为当前木鱼音效只支持上传、录音或默认音。 +作品架使用 `GET /api/creation/wooden-fish/works` 读取当前用户草稿和已发布摘要,前端发布成功后必须刷新该列表和 `GET /api/runtime/wooden-fish/gallery` 公开列表,使刚发布作品立即出现在草稿 Tab 的已发布筛选和推荐 / 最新流中。 + ## 9. SpacetimeDB 表和 view 新增表: diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md index 63af3568..a3ab635a 100644 --- a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md +++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md @@ -168,7 +168,7 @@ jump-hop-gallery-detail ### 6.2 地块只生一次图集 -地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。 +地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。该图集使用跳一跳专用 `2行*3列` 六格布局,不套用通用“每个物品一行、每行 n 个不同视图”的系列素材模型。 地块图集要求: @@ -176,17 +176,24 @@ jump-hop-gallery-detail 2. 必须表现出顶面、侧面和投影; 3. 必须与角色图保持同一光向; 4. 必须有清晰的立体层次,但仍然是 2D 图片; -5. 必须包含至少以下地块类型: +5. 六格必须按固定顺序包含以下地块类型: - 起点地块; - 普通地块; - 目标地块; - - 终点地块。 + - 终点地块; + - 奖励地块; + - 视觉强调地块。 -建议额外包含: +固定格位为: -1. 奖励地块; -2. 视觉强调地块; -3. 风格化变体地块。 +| 格位 | tileType | 语义 | +| --- | --- | --- | +| 第 1 行第 1 列 | `start` | 起点地块 | +| 第 1 行第 2 列 | `normal` | 普通地块 | +| 第 1 行第 3 列 | `target` | 目标地块 | +| 第 2 行第 1 列 | `finish` | 终点地块 | +| 第 2 行第 2 列 | `bonus` | 奖励地块 | +| 第 2 行第 3 列 | `accent` | 视觉强调地块 | 图集生成后按地块类型切分并去掉背景,运行态直接消费切好的 PNG,不在前端做复杂拼接。只有用户在结果页明确点击“重生成地块”时,才允许再调用一次地块图集生图。 diff --git a/docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md b/docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md index d25fbc9c..09e0bff1 100644 --- a/docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md +++ b/docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md @@ -88,6 +88,10 @@ Adapter 只负责媒体持久化和资产绑定,不负责: | Custom World opening CG video | `custom_world_ai.rs` 或后续分层文件 | video | Ark/火山视频 task 结果 URL | 视频下载、OSS、confirm、binding | storyboard->video 顺序、固定点数计费、超时错误 | storyboard 图片仍走图片 Adapter,最终视频走 media persist | | Character visual reference/workflow | `character_visual_assets.rs` | image/cache metadata | GPT image helper、workflow cache | 可复用 media persist 的 source/OSS/confirm/binding;图片生成 provider 不迁入复杂媒体 | 角色 workflow cache 可空继续生成 | 角色视觉发布链路回包字段不变 | | Character animation publish/import | `character_animation_assets.rs` | video / image sequence | data:video base64、remote video、阶段占位 | data URL/base64 解码、视频 OSS、confirm、binding | stage1 placeholder 语义、import-video contract | 导入视频和发布视频都不再复制 OSS/confirm 代码 | + +2026-05-26 补充:图片生成 provider 不再作为复杂媒体 Adapter 的实现细节散落在 `api-server`。VectorEngine `gpt-image-2` 创建 / 编辑协议、响应解析、URL / base64 图片归一、远端下载和 provider 侧结构化失败日志已经收口到 `server-rs/crates/platform-image/src/vector_engine/`;`api-server/src/openai_image_generation.rs` 只保留配置、兼容调用面和外部失败审计桥接。后续扩展视频、音频或 Hyper3D 时,可以复用“platform crate 承接 provider 协议,api-server 承接 HTTP/BFF、计费、OSS 绑定和失败审计桥接”的分层方式,但不得把新的 provider 协议塞回 `api-server` 大文件。 +2026-05-26 补充:音频生成 provider 协议也不应继续挤在 `api-server/src/vector_engine_audio_generation.rs`。VectorEngine Suno/Vidu 的任务提交、轮询、下载、MIME/extension 归一和 OSS 持久化请求准备已经收口到 `server-rs/crates/platform-audio/`,并继续按 `client.rs`、`request.rs`、`response.rs`、`download.rs`、`persist.rs`、`error.rs` 拆成小模块;`api-server` 只保留路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射,不再承担 provider 协议和下载细节。后续若再增加音频子能力,也必须优先放进平台 crate,而不是扩张 `api-server`。 +2026-05-26 补充:Hyper3D 只保留后端安全代理和旧数据兼容,`api-server/src/hyper3d_generation.rs` 应保持薄 wrapper,`platform-hyper3d` 承接 Rodin 的提交、状态和下载协议解析。若未来要继续压缩这一条线,应优先继续下沉协议解析与 transport helper,而不是把 provider 逻辑回流到 `api-server`。 | Match3D 背景音乐 | `match3d.rs` | audio | 现有生成/上传链路 | 仅复用音频持久化 | 不恢复 Rodin/GLB 新草稿回退 | 图片素材仍按图片 Adapter 计划处理 | | Puzzle 背景音乐 | `puzzle.rs` | audio | 现有生成/上传链路 | 仅复用音频持久化 | puzzle 运行态和排行榜语义不变 | `puzzle_background_music` kind/binding 不变 | | Hyper3D/GLB 历史代理 | `hyper3d.rs` | model/glb | Hyper3D Rodin status/download | 如存在转存需求,仅复用 media persist | Match3D 新草稿禁止回退 Rodin/GLB | 历史代理 route contract 不变 | diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 90e69e6a..206c98b5 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -119,10 +119,10 @@ npm run check:server-rs-ddd 2. Adapter 输入应显式包含 provider、prompt、reference images、OSS prefix/path/file name、asset kind、entity kind/id、slot、owner/profile/source job、metadata 和可选透明背景后处理。 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。 4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 -5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image`;`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 +5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 6. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 7. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 -8. 系列素材图集使用 `server-rs/crates/api-server/src/generated_asset_sheets.rs`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 +8. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 ## SpacetimeDB schema 变更规则 @@ -164,8 +164,8 @@ npm run check:server-rs-ddd - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 - 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。 -- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;新 Match3D 草稿和批量新增不再生成 GLB。 -- 音频:视觉小说专用音频路由保留;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 +- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。 +- 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 - OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。 - 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 226211b9..6092eeb5 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -12,6 +12,10 @@ 创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 +平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner;表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。 + +生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。 + `PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。 `platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recent` / `最近创作`,避免旧数据、局部 mock 或异常返回把创作入口初始化直接打崩。 @@ -30,7 +34,7 @@ 单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图和删除确认;新玩法页面不得重复手写这些交互。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec`、`slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。 -`api-server` 的 `generated_asset_sheets` 是当前通用系列素材图集模块:`n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。 +通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets`:`n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求。`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。 当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。 @@ -44,8 +48,9 @@ 6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算。 7. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。 8. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 +9. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。 -发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、次级入口带和法律信息组织,但字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 +发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 ## RPG / 自定义世界 @@ -73,7 +78,7 @@ RPG 从作品架、广场详情或作品号搜索点击“启动”前,入口 RPG 运行态的战斗终局、继续冒险、继续探索和切场景都属于服务端 runtime 快照真相:`module-runtime-story` 必须在终局战斗 action 后调用 post-battle finalization,持久写入 `story_continue_adventure`、`deferredOptions`、`deferredRuntimeState.storyEngineMemory.currentSceneActState` 和清理后的战斗状态;`idle_travel_next_scene` / `camp_travel_home_scene` 必须由后端写入新的 `currentScenePreset`、`currentSceneActState`、`currentEncounter` 和 `runtimeStats.scenesTraveled`。前端只播放退场、进场和继续按钮表现,不能用默认 `观察/试探/调息` fallback 或本地动画假装推进剧情。旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId` 而没有 `connections`,后端生成战后旅行选项时必须兼容这些字段。 -RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列表为真相,恢复动作继续走对应恢复接口,但移动端“我的”页已经不再提供独立的 `次级入口 > 存档` 和设置入口存档按钮;“玩过”弹窗可以继续合并展示可继续存档,个人中心只保留设置、扫码、常用功能和条件性次级入口。移动端“我的”页的五项常用功能宫格只放泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,避免把存档挤入主宫格破坏参考图布局。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。 +RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列表为真相,恢复动作继续走对应恢复接口,但移动端“我的”页已经不再提供独立的 `次级入口 > 存档` 和设置入口存档按钮;“玩过”弹窗可以继续合并展示可继续存档,个人中心只保留设置、扫码和五项常用功能。移动端“我的”页的五项常用功能宫格只放泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,避免把存档或填邀请码挤入主宫格破坏参考图布局。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。 ## 拼图 @@ -90,7 +95,8 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 支持画面描述生图、多参考图生图、上传或历史生成主图后 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 分钟)且不自动重试。每次 `gpt-image-2` 调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须使用进入生成页的当前时间作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。 +- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次图片生成调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须使用进入生成页的当前时间作为原始 `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 传入编辑接口。 - 每次新建关卡生成或重新生成关卡图都必须由 `api-server` 串起当前关卡资产包:AI 重绘开启时第一段沿用草稿生成第一关的拼图主图提示词配置和模型 / 尺寸 / 参考图规则生成 `coverImageSrc/coverAssetId` 作为关卡拼图画面和结果页预览图,提示词来源同样按显式画面描述、关卡画面描述、草稿摘要顺序回退,且固定要求输出画面比例为 `1:1`;上传图且关闭 AI 重绘时跳过这一段,把上传图或历史图持久化为 `sourceType=uploaded` 的正式候选。随后用正式候选图作为参考,`9:16` 生成完整拼图游戏关卡画面并写入 `levelSceneImageSrc/levelSceneImageObjectKey`,提示词必须要求道具按钮上不要显示次数标注,且返回按钮和设置按钮旁禁止标注文字;UI spritesheet 与关卡纯背景在关卡画面完成后并发生成,spritesheet 用 `1:1`、`1k` 先生成纯绿色绿幕背景图,后端上传 OSS 前必须把绿幕扣成透明 PNG,再写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,按钮顺序固定为返回、设置、下一关、提示、原图、冻结,按钮素材自身保留对应中文文字,返回和设置按钮不得额外生成白色外圈、白底圆环或浮雕外框;纯背景用 `9:16`、`1k` 写入 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,提示词必须包含“禁止在背景中出现人像或和拼图画面中主体一致的内容”。运行态不直接使用第二段完整关卡画面,但必须持久化它用于追踪和后续再生成。结果页局部关卡生成进度按 AI 重绘开启约 270 秒、关闭 AI 重绘约 180 秒展示。 @@ -126,7 +132,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 1. 初始草稿生成时,角色形象单独调用一次生图; 2. 初始草稿生成时,地块单独调用一次生图,输出 3D 视图的 2D 图片图集; -3. 地块图集由后端切分为起点、普通、目标、终点等透明 PNG; +3. 跳一跳地块图集使用专用 `2行*3列` 六格布局,后端按 `start / normal / target / finish / bonus / accent` 顺序切分为透明 PNG; 4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图; 5. 显式重生成角色或地块时,只重生成对应资产槽位。 @@ -223,7 +229,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 抓大鹅运行态不渲染右上角设置入口,也不在局内直接暴露重新开始按钮;结算弹层仍保留结果态的再来一局动作。 - 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。 -发现页可挂载官方抓大鹅静态 demo,用于验证生图、切图和运行态资源闭环。当前 demo profile 固定为 `match3d-demo-20260525`,公开作品号为 `M3-20260525`,静态资源位于 `public/match3d-demo/undersea-candy-market/`;公开卡片、作品号搜索和详情启动都走平台现有公开作品详情,不新建页面。demo 运行态使用前端本地 `createLocalMatch3DRuntimeAdapter`,不调用正式 Match3D runtime 后端、不新增 SpacetimeDB schema,也不写正式作品统计;后续若要把 demo 资源转成正式公开作品,必须改为后端 profile / gallery 投影真相后再接正式 runtime。 +发现页不再挂载前端固定官方抓大鹅静态 demo;公开卡片、作品号搜索、详情页和运行态启动只能来自后端真实 profile / gallery 投影。正式公开作品统一走 server runtime adapter,前端不得再用本地 demo profile 绕过后端统计和运行态链路。 ## 视觉小说 diff --git a/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md index 8b5730a9..ef97e819 100644 --- a/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md +++ b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md @@ -13,6 +13,9 @@ - 预计等待 / 已耗时信息卡要压缩为更轻的半透明窄卡,标签使用 `9px-10px`,数值使用 `12px-13px`,字号对齐其他生成页 UI 的小字号,不再使用偏大的提示文本;卡片标题和时间值都居中显示,两个数值只展示时间本身,调用侧不要再拼接“预计还需”或“已耗时”前缀。圆环中心不再保留独立白底块,空心圆环只保留条状进度,圆弧半径继续加大,进度数字与“总进度”标题整体上移,靠近圆环上半区。 - 顶部导航区采用“返回创作中心 / 状态胶囊”结构,返回按钮使用左箭头图标,字号使用 `text-xs-sm`,状态胶囊使用 `11px-12px`,展示 `素材生成中`、`草稿生成中` 等调用侧传入文案。 - 圆弧区域不再包独立大卡片,左右悬浮信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。当前圆环外径以 `w-[min(35rem,94vw)] sm:w-[52rem]` 为基准,圆弧使用 `r=166`、`strokeWidth=18` 的 SVG 描边,不再使用 `conic-gradient + mask`,避免进度条边缘模糊。 +- 圆弧描边以圆心为中心整体按 `155deg` 起始;在当前 SVG 坐标系下,这相对 `160deg` 会向左逆时针回调 `5deg`。track 和 fill 都必须共用同一个 `rotate(155 200 200)` 变换,避免只改视觉起点却让填充和轨道错位。 +- 总进度标题和百分比数字必须显式高于 SVG 圆环层级渲染,避免被圆环边缘压住;圆环本身只做背景层,不抢文字层。 +- 总进度标题和百分比数字要比圆环再上移一点,当前内容区上边距以 `pt-[2%]` 为准,桌面端可进一步微调到 `sm:pt-[1.5%]`,确保数字不与进度条弧线重合。 - 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须重置“展示态 startedAtMs”为进入生成页的当前时间;后端 `progressPercent` 只用于后续真实步骤推进,不得参与首帧总进度展示,避免恢复生成页首帧直接显示 `80%+`。 - 生成页只展示半透明“当前步骤”单卡,卡片内只保留步骤名称、步骤状态、步骤进度条和轻量加载指示;“当前步骤”标签使用 `10px-11px`,步骤名称使用 `14px-15px`,状态使用 `11px-12px`,不再渲染步骤列表或步骤详情。 - 当前作品信息放在圆角信息卡中,标题固定使用 `13px`;有结构化字段时以两列信息块展示,例如“题材 / 素材数量”,无结构化字段时才展示纯文本设定。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 0007216b..09dbe102 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -44,6 +44,8 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 1. 主站登录弹窗必须稳定展示 `短信登录` 与 `密码登录` 两个核心入口;`GET /api/auth/login-options` 只能补充微信等环境相关入口,不能决定是否隐藏短信或密码登录。 2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。 3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。 +4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5,并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。 +5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权,再调用 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 换取系统登录态。 ## 账户与充值 @@ -93,11 +95,12 @@ server-rs + Axum + SpacetimeDB 7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。 -10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、可选次级入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;页面不再提供独立存档按钮入口,填邀请码仅在新用户可填写窗口内展示为次级入口。 -11. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 -12. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能、可选次级入口和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 -13. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 -14. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 +10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。 +11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数、进度和领取 / 去完成 / 已完成状态;任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。 +12. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 +13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 +14. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 +15. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 ## 文案与编码 diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 534128d3..c199300b 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -1,3 +1,6 @@ +/* global Page, wx */ +/* eslint-disable no-console */ + const { API_BASE_URL, MINI_PROGRAM_APP_ID, @@ -10,6 +13,8 @@ const MINI_PROGRAM_CLIENT_TYPE = 'mini_program'; const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program'; const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id'; const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; +const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result'; +const AUTH_ACTION_LOGIN = 'login'; function isConfiguredEntryUrl(value) { const trimmed = String(value || '').trim(); @@ -57,6 +62,18 @@ function appendHashParams(url, params) { return `${baseUrl}#${rawHash}${separator}${pairs.join('&')}`; } +function parseBooleanQueryFlag(value) { + return value === true || value === '1' || value === 'true' || value === 'yes'; +} + +function shouldStartAuthFromQuery(query) { + return String((query && query.authAction) || '').trim() === AUTH_ACTION_LOGIN; +} + +function shouldReturnToPreviousPage(query) { + return String((query && query.returnTo) || '').trim() === 'previous'; +} + function resolveWebViewUrl(authResult) { const entryUrl = String(WEB_VIEW_ENTRY_URL || '').trim(); if (!isConfiguredEntryUrl(entryUrl)) { @@ -75,6 +92,38 @@ function resolveWebViewUrl(authResult) { }); } +function persistAuthResult(authResult) { + wx.setStorageSync(AUTH_RESULT_STORAGE_KEY, JSON.stringify(authResult)); +} + +function consumeAuthResult() { + const rawValue = wx.getStorageSync(AUTH_RESULT_STORAGE_KEY); + if (!rawValue) { + return null; + } + + wx.removeStorageSync(AUTH_RESULT_STORAGE_KEY); + try { + const parsed = JSON.parse(String(rawValue)); + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const token = String(parsed.token || '').trim(); + if (!token) { + return null; + } + + return { + token, + bindingStatus: String(parsed.bindingStatus || 'pending_bind_phone'), + }; + } catch (error) { + console.error('[web-view] parse auth result failed', error); + return null; + } +} + function getClientInstanceId() { const stored = wx.getStorageSync(CLIENT_INSTANCE_STORAGE_KEY); if (stored) { @@ -217,10 +266,12 @@ Page({ errorMessage: '', loading: true, phoneBindingRequired: false, + returnToPreviousPage: false, webViewUrl: '', }, - async onLoad() { + async onLoad(query = {}) { + this._lastLaunchQuery = query; // 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。 if (!isConfiguredEntryUrl(WEB_VIEW_ENTRY_URL)) { this.setData({ @@ -231,6 +282,20 @@ Page({ return; } + const forcedPhoneBinding = parseBooleanQueryFlag(query.phoneBindingRequired); + const returnToPreviousPage = shouldReturnToPreviousPage(query); + if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) { + this.setData({ + authResult: null, + errorMessage: '', + loading: false, + phoneBindingRequired: false, + returnToPreviousPage: false, + webViewUrl: resolveWebViewUrl(null), + }); + return; + } + if (!isConfiguredApiBaseUrl(API_BASE_URL)) { this.setData({ errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。', @@ -240,6 +305,14 @@ Page({ return; } + this.setData({ + loading: true, + phoneBindingRequired: false, + returnToPreviousPage, + errorMessage: '', + webViewUrl: '', + }); + try { const authResult = await resolveAuthResult(); if (authResult.bindingStatus === 'pending_bind_phone') { @@ -248,44 +321,56 @@ Page({ errorMessage: '', loading: false, phoneBindingRequired: true, + returnToPreviousPage, webViewUrl: '', }); return; } + if (returnToPreviousPage) { + persistAuthResult(authResult); + wx.navigateBack(); + return; + } + this.setData({ authResult, errorMessage: '', loading: false, phoneBindingRequired: false, + returnToPreviousPage, webViewUrl: resolveWebViewUrl(authResult), }); } catch (error) { this.setData({ authResult: null, errorMessage: - error && error.message - ? error.message - : '微信登录失败,请稍后重试。', + error && error.message ? error.message : '微信登录失败,请稍后重试。', loading: false, phoneBindingRequired: false, + returnToPreviousPage, webViewUrl: '', }); } }, onShow() { - const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY); - if (!result || !this.data.webViewUrl) { - return; + const authResult = consumeAuthResult(); + if (authResult) { + this.setData({ + webViewUrl: resolveWebViewUrl(authResult), + }); } - wx.removeStorageSync(PAY_RESULT_STORAGE_KEY); - this.setData({ - webViewUrl: appendHashParams(this.data.webViewUrl, { - wx_pay_result: result, - }), - }); + const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY); + if (result && this.data.webViewUrl) { + wx.removeStorageSync(PAY_RESULT_STORAGE_KEY); + this.setData({ + webViewUrl: appendHashParams(this.data.webViewUrl, { + wx_pay_result: result, + }), + }); + } }, async handleGetPhoneNumber(event) { @@ -318,6 +403,17 @@ Page({ token: response.token, bindingStatus: 'active', }; + if (this.data.returnToPreviousPage) { + persistAuthResult(nextAuthResult); + this.setData({ + bindingPhone: false, + errorMessage: '', + loading: false, + phoneBindingRequired: false, + }); + wx.navigateBack(); + return; + } this.setData({ authResult: nextAuthResult, bindingPhone: false, @@ -344,9 +440,10 @@ Page({ errorMessage: '', loading: true, phoneBindingRequired: false, + returnToPreviousPage: false, webViewUrl: '', }); - this.onLoad(); + this.onLoad(this._lastLaunchQuery || { authAction: AUTH_ACTION_LOGIN }); }, handleWebViewLoad(event) { diff --git a/miniprogram/pages/web-view/index.wxml b/miniprogram/pages/web-view/index.wxml index b9469f31..b8985678 100644 --- a/miniprogram/pages/web-view/index.wxml +++ b/miniprogram/pages/web-view/index.wxml @@ -1,5 +1,6 @@ {{errorMessage}} + + 登录完成后将自动返回。 +
- {isGenerating - ? activeBadgeLabel - : error - ? pausedBadgeLabel - : idleBadgeLabel} + {isGenerating ? activeBadgeLabel : idleBadgeLabel}
@@ -195,12 +189,6 @@ export function CustomWorldGenerationView({ /> - {error ? ( -
- {error} -
- ) : null} -
{!isGenerating ? ( <> diff --git a/src/components/GenerationProgressHero.tsx b/src/components/GenerationProgressHero.tsx index 9883d96c..9fa0af3a 100644 --- a/src/components/GenerationProgressHero.tsx +++ b/src/components/GenerationProgressHero.tsx @@ -4,7 +4,7 @@ import { useEffect, useId, useRef } from 'react'; import generationHeroVideo from '../../media/create_bg_video.mp4'; -const GENERATION_PROGRESS_RING_START_DEGREES = 225; +const GENERATION_PROGRESS_RING_START_DEGREES = 155; const GENERATION_PROGRESS_RING_SWEEP_DEGREES = 270; const GENERATION_PROGRESS_RING_VIEWBOX = 400; const GENERATION_PROGRESS_RING_CENTER = GENERATION_PROGRESS_RING_VIEWBOX / 2; @@ -173,7 +173,7 @@ export function GenerationProgressHero({ >
-
+
总进度
-
+
{safeProgress}%
diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 3120f30d..d8341460 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -26,6 +26,8 @@ const authMocks = vi.hoisted(() => ({ getAuthAuditLogs: vi.fn(), getAuthRiskBlocks: vi.fn(), getAuthSessions: vi.fn(), + isWechatMiniProgramWebViewRuntime: vi.fn(() => false), + requestWechatMiniProgramPhoneLogin: vi.fn(), revokeAuthSessions: vi.fn(), sendPhoneLoginCode: vi.fn(), startWechatLogin: vi.fn(), @@ -52,10 +54,12 @@ vi.mock('../../services/authService', () => ({ getCurrentAuthUser: authMocks.getCurrentAuthUser, getAuthSessions: authMocks.getAuthSessions, getCaptchaChallengeFromError: vi.fn(() => null), + isWechatMiniProgramWebViewRuntime: authMocks.isWechatMiniProgramWebViewRuntime, liftAuthRiskBlock: vi.fn(), loginWithPhoneCode: authMocks.loginWithPhoneCode, logoutAllAuthSessions: authMocks.logoutAllAuthSessions, logoutAuthUser: authMocks.logoutAuthUser, + requestWechatMiniProgramPhoneLogin: authMocks.requestWechatMiniProgramPhoneLogin, redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode, resetPassword: authMocks.resetPassword, revokeAuthSessions: authMocks.revokeAuthSessions, @@ -152,6 +156,8 @@ beforeEach(() => { expiresInSeconds: 300, }); authMocks.startWechatLogin.mockResolvedValue(undefined); + authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(false); + authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true); }); async function acceptLegalConsent( @@ -412,6 +418,29 @@ test('auth gate opens a login modal for protected actions and resumes after logi expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); }); +test('auth gate uses mini program auth bridge instead of opening login modal in mini program runtime', async () => { + const user = userEvent.setup(); + authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(true); + authMocks.getAuthLoginOptions.mockResolvedValue({ + availableLoginMethods: ['phone', 'wechat'], + }); + + render( + + + , + ); + + await user.click(await screen.findByRole('button', { name: '进入作品' })); + + await waitFor(() => { + expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(1); + }); + expect(authMocks.startWechatLogin).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); + expect(authMocks.isWechatMiniProgramWebViewRuntime).toHaveBeenCalled(); +}); + test('login modal requires first-time legal consent before sms login', async () => { const user = userEvent.setup(); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index e2dc89a6..404764b2 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -32,11 +32,13 @@ import { getAuthSessions, getCaptchaChallengeFromError, getCurrentAuthUser, + isWechatMiniProgramWebViewRuntime, liftAuthRiskBlock, loginWithPhoneCode, logoutAllAuthSessions, logoutAuthUser, redeemRegistrationInviteCode, + requestWechatMiniProgramPhoneLogin, resetPassword, revokeAuthSessions, sendPhoneLoginCode, @@ -276,6 +278,22 @@ export function AuthGate({ children }: AuthGateProps) { setInitialSettingsSection(null); }, []); + const requestMiniProgramLogin = useCallback(() => { + setWechatLoading(true); + setError(''); + void requestWechatMiniProgramPhoneLogin() + .catch((miniProgramError) => { + setError( + miniProgramError instanceof Error + ? miniProgramError.message + : '请在微信小程序内完成登录。', + ); + }) + .finally(() => { + setWechatLoading(false); + }); + }, []); + const openLoginModal = useCallback( (postLoginAction?: (() => void) | null) => { if (readyUser) { @@ -284,9 +302,15 @@ export function AuthGate({ children }: AuthGateProps) { } pendingProtectedActionRef.current = postLoginAction ?? null; + if (isWechatMiniProgramWebViewRuntime()) { + setShowLoginModal(false); + requestMiniProgramLogin(); + return; + } + setShowLoginModal(true); }, - [readyUser], + [readyUser, requestMiniProgramLogin], ); const requireAuth = useCallback( @@ -425,11 +449,26 @@ export function AuthGate({ children }: AuthGateProps) { void hydrate(++authHydrateVersionRef.current); }; + const handleAuthHashChange = () => { + const callbackResult = consumeAuthCallbackResult(); + if (!callbackResult) { + return; + } + if (callbackResult.error) { + setError(callbackResult.error); + return; + } + setStatus('checking'); + void hydrate(++authHydrateVersionRef.current); + }; + window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange); + window.addEventListener('hashchange', handleAuthHashChange); return () => { isActive = false; window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange); + window.removeEventListener('hashchange', handleAuthHashChange); }; }, [restoreAuthSession]); diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index fc41169e..c255caa4 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -7,6 +7,9 @@ import type { AuthLoginMethod, } from '../../services/authService'; import { getStoredLastLoginPhone } from '../../services/authService'; +import { + isWechatMiniProgramWebViewRuntime, +} from '../../services/authService'; import { LegalDocumentModal } from '../common/LegalDocumentModal'; import { getLegalDocument, @@ -83,6 +86,7 @@ export function LoginScreen({ const passwordLoginEnabled = true; const phoneLoginEnabled = true; const wechatLoginEnabled = availableLoginMethods.includes('wechat'); + const miniProgramRuntime = isWechatMiniProgramWebViewRuntime(); const [activeLoginTab, setActiveLoginTab] = useState('phone'); useEffect(() => { @@ -317,7 +321,7 @@ export function LoginScreen({
- {wechatLoginEnabled ? ( + {wechatLoginEnabled && !miniProgramRuntime ? ( 当前登录入口暂不可用。
diff --git a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx index 02af134b..e2d25871 100644 --- a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx +++ b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx @@ -116,7 +116,10 @@ describe('BarkBattleGeneratingView', () => { 'justify-start', ); expect(screen.getByTestId('generation-hero-progress-content').className).toContain( - 'pt-[4%]', + 'z-30', + ); + expect(screen.getByTestId('generation-hero-progress-content').className).toContain( + 'pt-[2%]', ); expect(screen.getByText('玩家形象')).toBeTruthy(); expect(screen.getByText('进行中 36%')).toBeTruthy(); @@ -142,7 +145,7 @@ describe('BarkBattleGeneratingView', () => { screen .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) .getAttribute('data-ring-start-degrees'), - ).toBe('225'); + ).toBe('155'); expect( screen .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) @@ -161,6 +164,9 @@ describe('BarkBattleGeneratingView', () => { expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe( 'svg', ); + expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain( + 'z-0', + ); expect( screen .getByTestId('generation-hero-progress-ring') @@ -176,6 +182,16 @@ describe('BarkBattleGeneratingView', () => { .getByTestId('generation-hero-progress-ring-track') .getAttribute('stroke-width'), ).toBe('18'); + expect( + screen + .getByTestId('generation-hero-progress-ring-track') + .getAttribute('transform'), + ).toBe('rotate(155 200 200)'); + expect( + screen + .getByTestId('generation-hero-progress-ring-fill') + .getAttribute('transform'), + ).toBe('rotate(155 200 200)'); expect( screen .getByTestId('generation-hero-progress-ring-fill') diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index ccd20cd5..857f9f48 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -1,12 +1,13 @@ import { useEffect, useMemo, useState } from 'react'; -import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; -import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; @@ -43,7 +44,6 @@ type CustomWorldCreationHubProps = { loading: boolean; error: string | null; onRetry: () => void; - createError?: string | null; createBusy?: boolean; entryConfig: CreationEntryConfig; creationTypes: readonly PlatformCreationTypeCard[]; @@ -65,6 +65,9 @@ type CustomWorldCreationHubProps = { jumpHopItems?: JumpHopWorkSummaryResponse[]; onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void; onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null; + woodenFishItems?: WoodenFishWorkSummaryResponse[]; + onOpenWoodenFishDetail?: ((item: WoodenFishWorkSummaryResponse) => void) | null; + onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null; puzzleItems?: PuzzleWorkSummary[]; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null; @@ -154,7 +157,6 @@ export function CustomWorldCreationHub({ loading, error, onRetry, - createError = null, createBusy = false, entryConfig, creationTypes, @@ -176,6 +178,9 @@ export function CustomWorldCreationHub({ jumpHopItems = [], onOpenJumpHopDetail, onDeleteJumpHop = null, + woodenFishItems = [], + onOpenWoodenFishDetail = null, + onDeleteWoodenFish = null, puzzleItems = [], onOpenPuzzleDetail, onDeletePuzzle = null, @@ -209,6 +214,7 @@ export function CustomWorldCreationHub({ match3dItems, squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [], jumpHopItems, + woodenFishItems, puzzleItems, babyObjectMatchItems, barkBattleItems, @@ -219,6 +225,7 @@ export function CustomWorldCreationHub({ canDeleteSquareHole: isSquareHoleCreationVisible && Boolean(onDeleteSquareHole), canDeleteJumpHop: Boolean(onDeleteJumpHop), + canDeleteWoodenFish: Boolean(onDeleteWoodenFish), canDeletePuzzle: Boolean(onDeletePuzzle), canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch), canDeleteBarkBattle: Boolean(onDeleteBarkBattle), @@ -234,6 +241,8 @@ export function CustomWorldCreationHub({ onDeleteSquareHole: onDeleteSquareHole ?? undefined, onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined, onDeleteJumpHop: onDeleteJumpHop ?? undefined, + onOpenWoodenFishDetail: onOpenWoodenFishDetail ?? undefined, + onDeleteWoodenFish: onDeleteWoodenFish ?? undefined, onOpenPuzzleDetail, onDeletePuzzle: onDeletePuzzle ?? undefined, onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined, @@ -261,6 +270,7 @@ export function CustomWorldCreationHub({ onDeleteBarkBattle, onDeleteVisualNovel, onDeleteJumpHop, + onDeleteWoodenFish, onClaimPuzzlePointIncentive, onOpenBigFishDetail, onOpenDraft, @@ -270,6 +280,7 @@ export function CustomWorldCreationHub({ onOpenPuzzleDetail, onOpenSquareHoleDetail, onOpenVisualNovelDetail, + onOpenWoodenFishDetail, onEnterPublished, getWorkState, puzzleItems, @@ -277,6 +288,7 @@ export function CustomWorldCreationHub({ onOpenSquareHoleDetail, onOpenJumpHopDetail, jumpHopItems, + woodenFishItems, visualNovelItems, ], ); @@ -327,6 +339,9 @@ export function CustomWorldCreationHub({ case 'jump-hop': onOpenJumpHopDetail?.(item.source.item); return; + case 'wooden-fish': + onOpenWoodenFishDetail?.(item.source.item); + return; case 'rpg': if (item.status === 'draft') { onOpenDraft(item.source.item); @@ -360,7 +375,6 @@ export function CustomWorldCreationHub({ {showStartCard ? ( -
{error}
+
diff --git a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx index c5021674..6647e42c 100644 --- a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx +++ b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx @@ -1,5 +1,5 @@ import { Coins, Trophy } from 'lucide-react'; -import { useMemo, useState, type UIEvent } from 'react'; +import { type UIEvent,useMemo, useState } from 'react'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import { @@ -10,7 +10,6 @@ import { type CustomWorldCreationStartCardProps = { busy?: boolean; - error?: string | null; entryConfig: CreationEntryConfig; creationTypes: readonly PlatformCreationTypeCard[]; onCreateType: (type: PlatformCreationTypeId) => void; @@ -25,7 +24,6 @@ function shouldShowCreationBadge(badge: string) { export function CustomWorldCreationStartCard({ busy = false, - error = null, entryConfig, creationTypes, onCreateType, @@ -233,11 +231,6 @@ export function CustomWorldCreationStartCard({ })}
- {error ? ( -
- {error} -
- ) : null}
); diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 24fde099..392282c4 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -60,6 +60,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record = match3d: '/creation-type-references/match3d.webp', 'square-hole': '/creation-type-references/square-hole.webp', 'jump-hop': '/creation-type-references/jump-hop.webp', + 'wooden-fish': '/wooden-fish/default-hit-object.png', puzzle: '/creation-type-references/puzzle.webp', 'baby-object-match': '/creation-type-references/creative-agent.webp', 'bark-battle': '/creation-type-references/bark-battle.webp', diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index 1db7d6d6..6388732a 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -56,6 +56,47 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code', expect(items[1]?.publicWorkCode).toBeNull(); }); +test('buildCreationWorkShelfItems maps wooden fish items with WF public code', () => { + const onOpenWoodenFishDetail = vi.fn(); + const woodenFishWork = { + runtimeKind: 'wooden-fish' as const, + workId: 'wooden-fish-work-1', + profileId: 'wooden-fish-profile-12345678', + ownerUserId: 'user-1', + sourceSessionId: 'wooden-fish-session-1', + workTitle: '苹果敲木鱼', + workDescription: '苹果主题木鱼。', + themeTags: ['苹果', '休闲'], + coverImageSrc: '/wooden-fish/apple-cover.png', + publicationStatus: 'published', + playCount: 9, + updatedAt: '2026-05-20T00:00:00.000Z', + publishedAt: '2026-05-20T00:00:00.000Z', + publishReady: true, + generationStatus: 'ready' as const, + }; + + const items = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [], + woodenFishItems: [woodenFishWork], + onOpenWoodenFishDetail, + }); + + items[0]?.actions.open(); + + expect(items).toHaveLength(1); + expect(items[0]?.kind).toBe('wooden-fish'); + expect(items[0]?.status).toBe('published'); + expect(items[0]?.publicWorkCode).toBe('WF-12345678'); + 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(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork); +}); + test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index e1fecab8..e44022a2 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -8,6 +8,7 @@ import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contr import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildBabyObjectMatchPublicWorkCode, @@ -19,6 +20,7 @@ import { buildPuzzlePublicWorkCode, buildSquareHolePublicWorkCode, buildVisualNovelPublicWorkCode, + buildWoodenFishPublicWorkCode, } from '../../services/publicWorkCode'; import type { CustomWorldProfile } from '../../types'; @@ -34,6 +36,7 @@ export type CreationWorkShelfKind = | 'match3d' | 'square-hole' | 'jump-hop' + | 'wooden-fish' | 'puzzle' | 'baby-object-match' | 'bark-battle' @@ -90,6 +93,10 @@ export type CreationWorkShelfSource = kind: 'jump-hop'; item: JumpHopWorkSummaryResponse; } + | { + kind: 'wooden-fish'; + item: WoodenFishWorkSummaryResponse; + } | { kind: 'puzzle'; item: PuzzleWorkSummary; @@ -145,6 +152,7 @@ export function buildCreationWorkShelfItems(params: { match3dItems?: Match3DWorkSummary[]; squareHoleItems?: SquareHoleWorkSummary[]; jumpHopItems?: JumpHopWorkSummaryResponse[]; + woodenFishItems?: WoodenFishWorkSummaryResponse[]; puzzleItems: PuzzleWorkSummary[]; babyObjectMatchItems?: BabyObjectMatchDraft[]; barkBattleItems?: BarkBattleWorkSummary[]; @@ -154,6 +162,7 @@ export function buildCreationWorkShelfItems(params: { canDeleteMatch3D?: boolean; canDeleteSquareHole?: boolean; canDeleteJumpHop?: boolean; + canDeleteWoodenFish?: boolean; canDeletePuzzle?: boolean; canDeleteBabyObjectMatch?: boolean; canDeleteBarkBattle?: boolean; @@ -169,6 +178,8 @@ export function buildCreationWorkShelfItems(params: { onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void; onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void; onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void; + onOpenWoodenFishDetail?: (item: WoodenFishWorkSummaryResponse) => void; + onDeleteWoodenFish?: (item: WoodenFishWorkSummaryResponse) => void; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; onDeletePuzzle?: (item: PuzzleWorkSummary) => void; onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void; @@ -189,6 +200,7 @@ export function buildCreationWorkShelfItems(params: { match3dItems = [], squareHoleItems = [], jumpHopItems = [], + woodenFishItems = [], puzzleItems, babyObjectMatchItems = [], barkBattleItems = [], @@ -198,6 +210,7 @@ export function buildCreationWorkShelfItems(params: { canDeleteMatch3D = false, canDeleteSquareHole = false, canDeleteJumpHop = false, + canDeleteWoodenFish = false, canDeletePuzzle = false, canDeleteBabyObjectMatch = false, canDeleteBarkBattle = false, @@ -213,6 +226,8 @@ export function buildCreationWorkShelfItems(params: { onDeleteSquareHole, onOpenJumpHopDetail, onDeleteJumpHop, + onOpenWoodenFishDetail, + onDeleteWoodenFish, onOpenPuzzleDetail, onDeletePuzzle, onClaimPuzzlePointIncentive, @@ -257,6 +272,12 @@ export function buildCreationWorkShelfItems(params: { onDelete: onDeleteJumpHop, }), ), + ...woodenFishItems.map((item) => + mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, { + onOpen: onOpenWoodenFishDetail, + onDelete: onDeleteWoodenFish, + }), + ), ...puzzleItems.map((item) => mapPuzzleWorkToShelfItem(item, canDeletePuzzle, { onOpen: onOpenPuzzleDetail, @@ -815,6 +836,54 @@ function mapJumpHopWorkToShelfItem( }; } +function mapWoodenFishWorkToShelfItem( + item: WoodenFishWorkSummaryResponse, + canDelete: boolean, + adapter: WorkShelfAdapter, +): CreationWorkShelfItem { + const status = item.publicationStatus === 'published' ? 'published' : 'draft'; + const publicWorkCode = + status === 'published' ? buildWoodenFishPublicWorkCode(item.profileId) : null; + const title = item.workTitle.trim() || '敲木鱼'; + const summary = + item.workDescription.trim() || (status === 'draft' ? '未填写作品描述' : ''); + + return { + id: item.workId, + kind: 'wooden-fish', + status, + title, + summary, + authorDisplayName: resolveAuthorDisplayName(item), + updatedAt: item.updatedAt, + coverImageSrc: normalizeCoverImageSrc(item.coverImageSrc), + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + publicWorkCode, + sharePath: + publicWorkCode && status === 'published' + ? buildPublicWorkStagePath('work-detail', publicWorkCode) + : null, + openActionLabel: status === 'published' ? '查看详情' : '继续创作', + canDelete, + canShare: status === 'published' && Boolean(publicWorkCode), + badges: [ + buildStatusBadge(status), + { id: 'type', label: '敲木鱼', tone: 'neutral' }, + ], + metrics: + status === 'published' + ? buildPublishedMetrics({ + playCount: item.playCount, + remixCount: 0, + likeCount: 0, + }) + : [], + actions: buildWorkShelfActions(item, adapter), + source: { kind: 'wooden-fish', item }, + }; +} + function resolveAuthorDisplayName( ...sources: Array @@ -1026,6 +1095,8 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) { return item.source.item.generationStatus === 'generating'; case 'puzzle': return isPersistedPuzzleDraftGenerating(item.source.item); + case 'wooden-fish': + return item.source.item.generationStatus === 'generating'; case 'bark-battle': return isPersistedBarkBattleDraftGenerating(item.source.item); default: diff --git a/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx b/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx index cef47414..e5a37ba0 100644 --- a/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx +++ b/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx @@ -174,7 +174,7 @@ test('baby object result blocks placeholder assets and exposes regeneration', as ); expect( - screen.getByText('当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。'), + screen.getByText('当前作品仍是占位资源,请重新生成素材后再试玩或发布。'), ).toBeTruthy(); expect( (screen.getByRole('button', { name: '试玩' }) as HTMLButtonElement) diff --git a/src/components/edutainment-result/BabyObjectMatchResultView.tsx b/src/components/edutainment-result/BabyObjectMatchResultView.tsx index 5c3e8bd0..cec5e306 100644 --- a/src/components/edutainment-result/BabyObjectMatchResultView.tsx +++ b/src/components/edutainment-result/BabyObjectMatchResultView.tsx @@ -158,7 +158,7 @@ export function BabyObjectMatchResultView({ {!hasGeneratedAssets ? (
- 当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。 + 当前作品仍是占位资源,请重新生成素材后再试玩或发布。
) : null} diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx index 583c19ad..fdfe2575 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx @@ -51,7 +51,6 @@ test('dispatches wooden fish creation type selection', () => { {}} diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index 8d6698aa..21b297b9 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -10,7 +10,6 @@ import { export interface PlatformEntryCreationTypeModalProps { isOpen: boolean; isBusy: boolean; - error: string | null; entryConfig: CreationEntryConfig; creationTypes: readonly PlatformCreationTypeCard[]; onClose: () => void; @@ -94,7 +93,6 @@ function CreationTypeCard(props: { export function PlatformEntryCreationTypeModal({ isOpen, isBusy, - error, entryConfig, creationTypes, onClose, @@ -172,11 +170,6 @@ export function PlatformEntryCreationTypeModal({ ))} - {error ? ( -
- {error} -
- ) : null} ); } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 01a81b32..48a1278c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -38,6 +38,7 @@ import type { BabyObjectMatchDraft, CreateBabyObjectMatchDraftRequest, } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { CreateMatch3DSessionRequest, ExecuteMatch3DActionRequest, @@ -107,12 +108,6 @@ import type { VisualNovelWorkSummary, } from '../../../packages/shared/src/contracts/visualNovel'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; -import { - MATCH3D_DEMO_GALLERY_CARD, - MATCH3D_DEMO_PROFILE_ID, - MATCH3D_DEMO_WORK_PROFILE, - isMatch3DDemoProfileId, -} from '../../data/match3dDemoGalleryCard'; import { buildPublicWorkStagePath, pushAppHistoryPath, @@ -160,13 +155,6 @@ import { type CreationEntryConfig, fetchCreationEntryConfig, } from '../../services/creationEntryConfigService'; -import { - cancelCreativeAgentSession, - confirmCreativePuzzleTemplate, - createCreativeAgentSession, - streamCreativeAgentMessage, - streamCreativeDraftEdit, -} from '../../services/creative-agent'; import { clearCreationUrlState, type CreationUrlState, @@ -175,11 +163,12 @@ import { writeCreationUrlState, } from '../../services/creationUrlState'; import { - clearPuzzleRuntimeUrlState, - readPuzzleRuntimeUrlState, - writePuzzleRuntimeUrlState, - type PuzzleRuntimeUrlState, -} from '../../services/puzzleRuntimeUrlState'; + cancelCreativeAgentSession, + confirmCreativePuzzleTemplate, + createCreativeAgentSession, + streamCreativeAgentMessage, + streamCreativeDraftEdit, +} from '../../services/creative-agent'; import { readCustomWorldAgentUiState, shouldRestoreCustomWorldAgentUiState, @@ -202,12 +191,8 @@ import { JumpHopWorkProfileResponse, JumpHopWorkspaceCreateRequest, } from '../../services/jump-hop/jumpHopClient'; -import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import { match3dCreationClient } from '../../services/match3d-creation'; -import { - createLocalMatch3DRuntimeAdapter, - createServerMatch3DRuntimeAdapter, -} from '../../services/match3d-runtime'; +import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { deleteMatch3DWork, getMatch3DWorkDetail, @@ -296,6 +281,12 @@ import { listPuzzleWorks, updatePuzzleWork, } from '../../services/puzzle-works'; +import { + clearPuzzleRuntimeUrlState, + type PuzzleRuntimeUrlState, + readPuzzleRuntimeUrlState, + writePuzzleRuntimeUrlState, +} from '../../services/puzzleRuntimeUrlState'; import { deleteRpgCreationAgentSession } from '../../services/rpg-creation'; import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter'; import { @@ -352,6 +343,7 @@ import { type WoodenFishWorkProfileResponse, type WoodenFishWorkspaceCreateRequest, } from '../../services/wooden-fish/woodenFishClient'; +import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { PublishShareModal } from '../common/PublishShareModal'; @@ -384,6 +376,7 @@ import { mapVisualNovelWorkToPlatformGalleryCard, mapWoodenFishWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, + resolvePlatformPublicWorkCode, } from '../rpg-entry/rpgEntryWorldPresentation'; import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling'; import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; @@ -423,6 +416,7 @@ import { PlatformEntryHomeView, type PlatformHomeTab, } from './PlatformEntryHomeView'; +import { usePlatformDesktopLayout } from './platformEntryResponsive'; import { buildCreationHubFallbackItems, resolveRpgCreationErrorMessage, @@ -432,11 +426,18 @@ import type { SelectionStage, } from './platformEntryTypes'; import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; +import { + PlatformErrorDialog, + type PlatformErrorDialogPayload, +} from './PlatformErrorDialog'; +import { + PlatformTaskCompletionDialog, + type PlatformTaskCompletionDialogPayload, +} from './PlatformTaskCompletionDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; -import { usePlatformDesktopLayout } from './platformEntryResponsive'; import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail'; import { usePlatformEntryNavigation } from './usePlatformEntryNavigation'; @@ -448,6 +449,7 @@ type DraftGenerationNoticeStatus = 'generating' | 'ready'; type DraftGenerationNotice = { status: DraftGenerationNoticeStatus; seen: boolean; + completedAtMs?: number; }; type DraftGenerationNoticeMap = Record; type CreationWorkShelfKind = CreationWorkShelfItem['kind']; @@ -2025,6 +2027,84 @@ function createPendingDraftShelfState( }; } +function normalizePlatformErrorMessage(message: string | null | undefined) { + const normalized = message?.trim(); + return normalized ? normalized : null; +} + +function formatPlatformErrorSource(label: string, id?: string | null) { + const normalizedId = id?.trim(); + return normalizedId ? `${label} ${normalizedId}` : label; +} + +function formatPlatformTaskCompletionSource(label: string, id?: string | null) { + const normalizedId = id?.trim(); + return normalizedId ? `${label} ${normalizedId}` : label; +} + +function buildPlatformErrorDialogDismissKey( + error: (PlatformErrorDialogPayload & { key: string }) | null, +) { + return error ? `${error.key}:${error.source}:${error.message}` : null; +} + +function buildPlatformTaskCompletionDialogDismissKey( + completion: + | (PlatformTaskCompletionDialogPayload & { + key: string; + completedAtMs: number | null; + }) + | null, +) { + return completion + ? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}` + : null; +} + +function pickDraftCompletionDialogSourceId( + ids: Array, +) { + const normalizedIds = ids + .map((id) => id?.trim() ?? '') + .filter((id) => Boolean(id)); + return ( + normalizedIds.find((id) => /session/i.test(id)) ?? + normalizedIds.find((id) => /work/i.test(id)) ?? + normalizedIds.find((id) => /draft/i.test(id)) ?? + normalizedIds.find((id) => /run/i.test(id)) ?? + normalizedIds.find((id) => /profile/i.test(id)) ?? + normalizedIds[0] ?? + null + ); +} + +function buildDraftCompletionDialogSource( + kind: CreationWorkShelfKind, + ids: Array, +) { + const sourceId = pickDraftCompletionDialogSourceId(ids); + switch (kind) { + case 'rpg': + return formatPlatformTaskCompletionSource('RPG 草稿', sourceId); + case 'big-fish': + return formatPlatformTaskCompletionSource('大鱼吃小鱼草稿', sourceId); + case 'match3d': + return formatPlatformTaskCompletionSource('抓大鹅草稿', sourceId); + case 'square-hole': + return formatPlatformTaskCompletionSource('方洞挑战草稿', sourceId); + case 'jump-hop': + return formatPlatformTaskCompletionSource('跳一跳草稿', sourceId); + case 'puzzle': + return formatPlatformTaskCompletionSource('拼图草稿', sourceId); + case 'visual-novel': + return formatPlatformTaskCompletionSource('视觉小说草稿', sourceId); + case 'bark-battle': + return formatPlatformTaskCompletionSource('汪汪声浪草稿', sourceId); + case 'baby-object-match': + return formatPlatformTaskCompletionSource('宝贝识物草稿', sourceId); + } +} + function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, metadata?: MiniGameDraftGenerationState['metadata'], @@ -2296,6 +2376,15 @@ function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] { item.source.item.workId, item.source.item.draftId, ]); + case 'wooden-fish': + return collectDraftNoticeKeys('wooden-fish', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + ]); + default: + return []; } } @@ -3085,6 +3174,9 @@ export function PlatformEntryFlowShellImpl({ >(null); const [woodenFishWork, setWoodenFishWork] = useState(null); + const [woodenFishWorks, setWoodenFishWorks] = useState< + WoodenFishWorkSummaryResponse[] + >([]); const [woodenFishGalleryEntries, setWoodenFishGalleryEntries] = useState< WoodenFishGalleryCardResponse[] >([]); @@ -3322,6 +3414,17 @@ export function PlatformEntryFlowShellImpl({ useState({}); const [pendingDraftShelfItems, setPendingDraftShelfItems] = useState({}); + const [ + pendingPlatformTaskCompletionDialog, + setPendingPlatformTaskCompletionDialog, + ] = useState< + | (PlatformTaskCompletionDialogPayload & { + key: string; + completedAtMs: number | null; + }) + | null + >(null); + const [profileTaskRefreshKey, setProfileTaskRefreshKey] = useState(0); const [initialCreationUrlState] = useState(() => readCreationUrlState()); const handledInitialCreationUrlStateRef = useRef(false); const [initialPuzzleRuntimeUrlState] = useState(() => @@ -3399,10 +3502,14 @@ export function PlatformEntryFlowShellImpl({ return; } + const completedAtMs = status === 'ready' ? Date.now() : undefined; setDraftGenerationNotices((current) => { const next = { ...current }; for (const key of uniqueKeys) { - next[key] = { status, seen }; + next[key] = + completedAtMs === undefined + ? { status, seen } + : { status, seen, completedAtMs }; } return next; }); @@ -3444,12 +3551,13 @@ export function PlatformEntryFlowShellImpl({ ); const markDraftGenerating = useCallback( (kind: CreationWorkShelfKind, ids: Array) => { + setPendingPlatformTaskCompletionDialog(null); updateDraftGenerationNotices( collectDraftNoticeKeys(kind, ids), 'generating', ); }, - [updateDraftGenerationNotices], + [setPendingPlatformTaskCompletionDialog, updateDraftGenerationNotices], ); const markDraftReady = useCallback( ( @@ -3462,17 +3570,26 @@ export function PlatformEntryFlowShellImpl({ 'ready', viewedImmediately, ); + setProfileTaskRefreshKey((current) => current + 1); + const completedAtMs = Date.now(); + setPendingPlatformTaskCompletionDialog({ + key: `${kind}:${collectDraftNoticeKeys(kind, ids).join('|')}:${completedAtMs}`, + source: buildDraftCompletionDialogSource(kind, ids), + message: '生成任务已完成,可以继续查看草稿。', + completedAtMs, + }); }, - [updateDraftGenerationNotices], + [setPendingPlatformTaskCompletionDialog, updateDraftGenerationNotices], ); const markPendingDraftGenerating = useCallback( ( kind: Exclude, id: string | null | undefined, ) => { + setPendingPlatformTaskCompletionDialog(null); updatePendingDraftShelfItem(kind, id, 'generating'); }, - [updatePendingDraftShelfItem], + [setPendingPlatformTaskCompletionDialog, updatePendingDraftShelfItem], ); const markPendingDraftReady = useCallback( ( @@ -3807,6 +3924,20 @@ export function PlatformEntryFlowShellImpl({ } }, []); + const refreshWoodenFishShelf = useCallback(async () => { + try { + const worksResponse = await woodenFishClient.listWorks(); + setWoodenFishWorks(worksResponse.items); + return worksResponse.items; + } catch (error) { + setWoodenFishWorks([]); + setWoodenFishError( + resolvePuzzleErrorMessage(error, '读取敲木鱼作品列表失败。'), + ); + return []; + } + }, [resolvePuzzleErrorMessage]); + const refreshPuzzleShelf = useCallback(async () => { setIsPuzzleLoadingLibrary(true); @@ -4219,9 +4350,6 @@ export function PlatformEntryFlowShellImpl({ } return '服务端预览'; }, [agentResultPreview]); - const match3dDemoProfile = MATCH3D_DEMO_WORK_PROFILE; - const match3dDemoGalleryCard = MATCH3D_DEMO_GALLERY_CARD; - const featuredGalleryEntries = useMemo(() => { const bigFishPublicEntries = isBigFishCreationVisible ? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard) @@ -4260,7 +4388,6 @@ export function PlatformEntryFlowShellImpl({ [ ...bigFishPublicEntries, ...match3dPublicEntries, - match3dDemoGalleryCard, ...puzzlePublicEntries, ...barkBattlePublicEntries, ...squareHolePublicEntries, @@ -4285,7 +4412,6 @@ export function PlatformEntryFlowShellImpl({ squareHoleGalleryEntries, visualNovelGalleryEntries, woodenFishGalleryEntries, - match3dDemoGalleryCard, ]); const latestGalleryEntries = useMemo( () => @@ -4296,7 +4422,6 @@ export function PlatformEntryFlowShellImpl({ ? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard) : []), ...match3dGalleryEntries.map(mapMatch3DWorkToPublicWorkDetail), - match3dDemoGalleryCard, ...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), ...barkBattleGalleryEntries.map(mapBarkBattleWorkToPlatformGalleryCard), ...jumpHopGalleryEntries.map(mapJumpHopWorkToPlatformGalleryCard), @@ -4338,7 +4463,6 @@ export function PlatformEntryFlowShellImpl({ barkBattleGalleryEntries, barkBattleWorks, woodenFishGalleryEntries, - match3dDemoGalleryCard, ], ); const recommendRuntimeEntries = useMemo(() => { @@ -4346,9 +4470,7 @@ export function PlatformEntryFlowShellImpl({ filterGeneralPublicWorks([ ...featuredGalleryEntries, ...latestGalleryEntries, - ]) - .filter((entry) => !isMatch3DDemoProfileId(entry.profileId)) - .forEach((entry) => { + ]).forEach((entry) => { entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); }); return Array.from(entryMap.values()); @@ -4386,6 +4508,10 @@ export function PlatformEntryFlowShellImpl({ ], [jumpHopWorks, pendingDraftShelfItems], ); + const woodenFishShelfItems = useMemo( + () => woodenFishWorks, + [woodenFishWorks], + ); const match3dShelfItems = useMemo( () => [ ...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks), @@ -4468,6 +4594,13 @@ export function PlatformEntryFlowShellImpl({ item.sourceSessionId, ]), ), + ...woodenFishShelfItems.flatMap((item) => + collectDraftNoticeKeys('wooden-fish', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ), ...match3dShelfItems.flatMap((item) => collectDraftNoticeKeys('match3d', [ item.workId, @@ -4511,6 +4644,7 @@ export function PlatformEntryFlowShellImpl({ barkBattleShelfItems, bigFishShelfItems, jumpHopShelfItems, + woodenFishShelfItems, creationHubItems, isSquareHoleCreationVisible, match3dShelfItems, @@ -4892,20 +5026,9 @@ export function PlatformEntryFlowShellImpl({ () => createServerMatch3DRuntimeAdapter(), [], ); - const match3dDemoRuntimeAdapter = useMemo( - () => - createLocalMatch3DRuntimeAdapter({ - clearCount: 21, - profileId: MATCH3D_DEMO_PROFILE_ID, - }), - [], - ); const resolveMatch3DRuntimeAdapter = useCallback( - (profileId: string | null | undefined) => - isMatch3DDemoProfileId(profileId) - ? match3dDemoRuntimeAdapter - : match3dRuntimeAdapter, - [match3dDemoRuntimeAdapter, match3dRuntimeAdapter], + (_profileId: string | null | undefined) => match3dRuntimeAdapter, + [match3dRuntimeAdapter], ); const match3dFlow = usePlatformCreationAgentFlowController< Match3DAgentSessionSnapshot, @@ -5842,6 +5965,372 @@ export function PlatformEntryFlowShellImpl({ isMiniGameDraftGenerating( activePuzzleBackgroundCompileTask?.generationState ?? null, ); + const [dismissedPlatformErrorDialogKey, setDismissedPlatformErrorDialogKey] = + useState(null); + const [ + dismissedPlatformTaskCompletionDialogKey, + setDismissedPlatformTaskCompletionDialogKey, + ] = useState(null); + const currentPlatformErrorDialog = useMemo< + (PlatformErrorDialogPayload & { key: string }) | null + >(() => { + const candidates: Array<{ + key: string; + source: string; + message: string | null | undefined; + }> = [ + { + key: 'creation-entry-config', + source: '创作入口配置', + message: creationEntryConfigError, + }, + { + key: 'platform-bootstrap', + source: '平台首页', + message: platformBootstrap.platformError, + }, + { + key: 'rpg-creation-type', + source: '创作入口', + message: sessionController.creationTypeError, + }, + { + key: 'rpg-restore', + source: '创作作品架', + message: sessionController.agentWorkspaceRestoreError, + }, + { + key: 'rpg-result', + source: formatPlatformErrorSource( + 'RPG 草稿', + sessionController.agentSession?.sessionId ?? + sessionController.generatedCustomWorldProfile?.id, + ), + message: resultViewError, + }, + { + key: 'public-work-detail', + source: formatPlatformErrorSource( + '作品详情', + selectedPublicWorkDetail + ? resolvePlatformPublicWorkCode(selectedPublicWorkDetail) + : selectedDetailEntry?.profileId, + ), + message: publicWorkDetailError ?? detailNavigation.detailError, + }, + { + key: 'big-fish', + source: formatPlatformErrorSource( + selectionStage === 'big-fish-runtime' ? '大鱼吃小鱼游玩' : '大鱼草稿', + bigFishRun?.runId ?? bigFishSession?.sessionId, + ), + message: bigFishError, + }, + { + key: 'match3d', + source: formatPlatformErrorSource( + selectionStage === 'match3d-runtime' ? '抓大鹅游玩' : '抓大鹅草稿', + match3dRun?.runId ?? + match3dGenerationViewSession?.sessionId ?? + match3dSession?.sessionId, + ), + message: match3dGenerationViewError ?? match3dError, + }, + { + key: 'square-hole', + source: formatPlatformErrorSource( + selectionStage === 'square-hole-runtime' + ? '方洞挑战游玩' + : '方洞挑战草稿', + squareHoleRun?.runId ?? squareHoleSession?.sessionId, + ), + message: squareHoleError, + }, + { + key: 'jump-hop', + source: formatPlatformErrorSource( + selectionStage === 'jump-hop-runtime' ? '跳一跳游玩' : '跳一跳草稿', + jumpHopRun?.runId ?? jumpHopSession?.sessionId, + ), + message: jumpHopError, + }, + { + key: 'wooden-fish', + source: formatPlatformErrorSource( + selectionStage === 'wooden-fish-runtime' + ? '敲木鱼游玩' + : '敲木鱼草稿', + woodenFishRun?.runId ?? woodenFishSession?.sessionId, + ), + message: woodenFishError, + }, + { + key: 'puzzle', + source: formatPlatformErrorSource( + selectionStage === 'puzzle-runtime' ? '拼图游玩' : '拼图草稿', + puzzleRun?.runId ?? + puzzleGenerationViewSession?.sessionId ?? + puzzleSession?.sessionId, + ), + message: puzzleGenerationViewError ?? puzzleCreationError ?? puzzleError, + }, + { + key: 'puzzle-onboarding', + source: '拼图首次创作', + message: puzzleOnboardingError, + }, + { + key: 'puzzle-shelf', + source: '拼图作品架', + message: puzzleShelfError, + }, + { + key: 'visual-novel', + source: formatPlatformErrorSource( + selectionStage === 'visual-novel-runtime' + ? '视觉小说游玩' + : '视觉小说草稿', + visualNovelRun?.runId ?? visualNovelSession?.sessionId, + ), + message: visualNovelError, + }, + { + key: 'baby-object-match', + source: formatPlatformErrorSource( + selectionStage === 'baby-object-match-runtime' + ? '宝贝识物游玩' + : '宝贝识物草稿', + babyObjectMatchDraft?.profileId, + ), + message: babyObjectMatchError, + }, + { + key: 'bark-battle', + source: formatPlatformErrorSource( + selectionStage === 'bark-battle-runtime' + ? '汪汪声浪游玩' + : '汪汪声浪草稿', + barkBattlePublishedConfig?.workId ?? barkBattleDraftConfig?.workId, + ), + message: barkBattleError, + }, + { + key: 'creative-agent', + source: formatPlatformErrorSource( + '智能创作 Agent', + creativeAgentSession?.sessionId, + ), + message: creativeAgentError, + }, + { + key: 'rpg-generation', + source: formatPlatformErrorSource( + 'RPG 草稿生成', + sessionController.agentSession?.sessionId, + ), + message: sessionController.activeGenerationError, + }, + ]; + + for (const candidate of candidates) { + const message = normalizePlatformErrorMessage(candidate.message); + if (message) { + return { + key: candidate.key, + source: candidate.source, + message, + }; + } + } + + return null; + }, [ + babyObjectMatchDraft?.profileId, + babyObjectMatchError, + barkBattleDraftConfig?.workId, + barkBattleError, + barkBattlePublishedConfig?.workId, + bigFishError, + bigFishRun?.runId, + bigFishSession?.sessionId, + creationEntryConfigError, + creativeAgentError, + creativeAgentSession?.sessionId, + detailNavigation.detailError, + jumpHopError, + jumpHopRun?.runId, + jumpHopSession?.sessionId, + match3dError, + match3dGenerationViewError, + match3dGenerationViewSession?.sessionId, + match3dRun?.runId, + match3dSession?.sessionId, + platformBootstrap.platformError, + publicWorkDetailError, + puzzleCreationError, + puzzleError, + puzzleGenerationViewError, + puzzleGenerationViewSession?.sessionId, + puzzleOnboardingError, + puzzleRun?.runId, + puzzleSession?.sessionId, + puzzleShelfError, + resultViewError, + selectedDetailEntry?.profileId, + selectedPublicWorkDetail, + selectionStage, + sessionController.activeGenerationError, + sessionController.agentSession?.sessionId, + sessionController.agentWorkspaceRestoreError, + sessionController.creationTypeError, + sessionController.generatedCustomWorldProfile?.id, + squareHoleError, + squareHoleRun?.runId, + squareHoleSession?.sessionId, + visualNovelError, + visualNovelRun?.runId, + visualNovelSession?.sessionId, + woodenFishError, + woodenFishRun?.runId, + woodenFishSession?.sessionId, + ]); + const currentPlatformTaskCompletionDialog = useMemo< + | (PlatformTaskCompletionDialogPayload & { + key: string; + completedAtMs: number | null; + }) + | null + >(() => pendingPlatformTaskCompletionDialog, [ + pendingPlatformTaskCompletionDialog, + ]); + const activePlatformTaskCompletionDialogDismissKey = + buildPlatformTaskCompletionDialogDismissKey( + currentPlatformTaskCompletionDialog, + ); + const activePlatformTaskCompletionDialog = + activePlatformTaskCompletionDialogDismissKey && + activePlatformTaskCompletionDialogDismissKey === + dismissedPlatformTaskCompletionDialogKey + ? null + : currentPlatformTaskCompletionDialog; + const activePlatformErrorDialogDismissKey = + buildPlatformErrorDialogDismissKey(currentPlatformErrorDialog); + const activePlatformErrorDialog = + activePlatformErrorDialogDismissKey && + activePlatformErrorDialogDismissKey === dismissedPlatformErrorDialogKey + ? null + : currentPlatformErrorDialog; + const closePlatformErrorDialog = useCallback(() => { + if (!currentPlatformErrorDialog) { + return; + } + + const dismissKey = buildPlatformErrorDialogDismissKey( + currentPlatformErrorDialog, + ); + if (dismissKey) { + setDismissedPlatformErrorDialogKey(dismissKey); + } + + if (currentPlatformErrorDialog.key === 'creation-entry-config') { + setCreationEntryConfigError(null); + return; + } + if (currentPlatformErrorDialog.key === 'platform-bootstrap') { + platformBootstrap.setPlatformError(null); + return; + } + if (currentPlatformErrorDialog.key === 'rpg-creation-type') { + sessionController.setCreationTypeError(null); + return; + } + if (currentPlatformErrorDialog.key === 'rpg-restore') { + return; + } + if ( + currentPlatformErrorDialog.key === 'rpg-result' || + currentPlatformErrorDialog.key === 'rpg-generation' + ) { + autosaveCoordinator.setCustomWorldAutoSaveError(null); + sessionController.setCustomWorldError(null); + return; + } + if (currentPlatformErrorDialog.key === 'public-work-detail') { + setPublicWorkDetailError(null); + detailNavigation.setDetailError(null); + return; + } + if (currentPlatformErrorDialog.key === 'big-fish') { + setBigFishError(null); + return; + } + if (currentPlatformErrorDialog.key === 'match3d') { + setMatch3DError(null); + return; + } + if (currentPlatformErrorDialog.key === 'square-hole') { + setSquareHoleError(null); + return; + } + if (currentPlatformErrorDialog.key === 'jump-hop') { + setJumpHopError(null); + return; + } + if (currentPlatformErrorDialog.key === 'wooden-fish') { + setWoodenFishError(null); + return; + } + if ( + currentPlatformErrorDialog.key === 'puzzle' || + currentPlatformErrorDialog.key === 'puzzle-onboarding' || + currentPlatformErrorDialog.key === 'puzzle-shelf' + ) { + setPuzzleCreationError(null); + setPuzzleOnboardingError(null); + setPuzzleShelfError(null); + setPuzzleError(null); + return; + } + if (currentPlatformErrorDialog.key === 'visual-novel') { + setVisualNovelError(null); + return; + } + if (currentPlatformErrorDialog.key === 'baby-object-match') { + setBabyObjectMatchError(null); + return; + } + if (currentPlatformErrorDialog.key === 'bark-battle') { + setBarkBattleError(null); + return; + } + if (currentPlatformErrorDialog.key === 'creative-agent') { + setCreativeAgentError(null); + } + }, [ + autosaveCoordinator, + currentPlatformErrorDialog, + detailNavigation, + platformBootstrap, + sessionController, + setBigFishError, + setMatch3DError, + setPuzzleError, + setSquareHoleError, + setVisualNovelError, + ]); + const closePlatformTaskCompletionDialog = useCallback(() => { + if (!currentPlatformTaskCompletionDialog) { + return; + } + + const dismissKey = buildPlatformTaskCompletionDialogDismissKey( + currentPlatformTaskCompletionDialog, + ); + if (dismissKey) { + setDismissedPlatformTaskCompletionDialogKey(dismissKey); + } + setPendingPlatformTaskCompletionDialog(null); + }, [currentPlatformTaskCompletionDialog]); const shouldPollPuzzleGenerationSession = selectionStage === 'puzzle-generating' && activePuzzleGenerationSessionId != null && @@ -6862,6 +7351,7 @@ export function PlatformEntryFlowShellImpl({ setIsProfilePlayStatsOpen(false); setDraftGenerationNotices({}); setPendingDraftShelfItems({}); + setPendingPlatformTaskCompletionDialog(null); resetRpgSessionViewState(); setRpgGeneratedCustomWorldProfile(null); setRpgCustomWorldError(null); @@ -7441,7 +7931,7 @@ export function PlatformEntryFlowShellImpl({ setBabyObjectMatchError( resolvePuzzleErrorMessage( error, - '重新生成宝贝识物 image-2 资源失败。', + '重新生成宝贝识物素材失败。', ), ); } finally { @@ -7474,7 +7964,7 @@ export function PlatformEntryFlowShellImpl({ setBabyObjectMatchError( resolvePuzzleErrorMessage( error, - '生成宝贝识物 image-2 资源失败,请重试后再发布。', + '生成宝贝识物素材失败,请重试后再发布。', ), ); } finally { @@ -7525,7 +8015,7 @@ export function PlatformEntryFlowShellImpl({ } catch (error) { const message = resolvePuzzleErrorMessage( error, - '生成宝贝识物 image-2 资源失败,请重试后再试玩。', + '生成宝贝识物素材失败,请重试后再试玩。', ); setBabyObjectMatchError(message); if (options.embedded) { @@ -8608,6 +9098,8 @@ export function PlatformEntryFlowShellImpl({ try { const response = await woodenFishClient.publishWork(profileId); setWoodenFishWork(response.item); + void refreshWoodenFishShelf(); + void refreshWoodenFishGallery(); openPublishShareModal({ title: response.item.summary.workTitle || '敲木鱼', publicWorkCode: buildWoodenFishPublicWorkCode( @@ -8625,6 +9117,8 @@ export function PlatformEntryFlowShellImpl({ } }, [ openPublishShareModal, + refreshWoodenFishGallery, + refreshWoodenFishShelf, setSelectionStage, woodenFishWork?.summary.profileId, ]); @@ -9210,11 +9704,9 @@ export function PlatformEntryFlowShellImpl({ setMatch3DError(null); try { - const isDemoProfile = isMatch3DDemoProfileId(profile.profileId); let runtimeProfile: Match3DWorkProfile | Match3DWorkSummary = - isDemoProfile ? match3dDemoProfile : profile; + profile; if ( - !isDemoProfile && (!hasMatch3DRuntimeAsset(profile.generatedItemAssets) || !hasMatch3DRuntimeBackgroundAsset(profile)) ) { @@ -9295,7 +9787,6 @@ export function PlatformEntryFlowShellImpl({ }, [ isMatch3DBusy, - match3dDemoProfile, authUi, match3dFlow, resolveMatch3DErrorMessage, @@ -11076,11 +11567,8 @@ export function PlatformEntryFlowShellImpl({ try { const entries = match3dGalleryEntries.length > 0 - ? [...match3dGalleryEntries, match3dDemoProfile] - : await refreshMatch3DGallery().then((items) => [ - ...items, - match3dDemoProfile, - ]); + ? match3dGalleryEntries + : await refreshMatch3DGallery(); const matchedEntry = entries.find( (entry) => entry.profileId === profileId, ); @@ -11100,7 +11588,6 @@ export function PlatformEntryFlowShellImpl({ }, [ match3dGalleryEntries, - match3dDemoProfile, openPublicWorkDetail, refreshMatch3DGallery, resolveMatch3DErrorMessage, @@ -11270,6 +11757,48 @@ export function PlatformEntryFlowShellImpl({ [openPublicWorkDetail, setSelectionStage], ); + const openWoodenFishDraft = useCallback( + async (item: WoodenFishWorkSummaryResponse) => { + markDraftNoticeSeen( + collectDraftNoticeKeys('wooden-fish', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ); + + if (item.publicationStatus === 'published') { + void openWoodenFishPublicWorkDetail(item.profileId); + return; + } + + setWoodenFishError(null); + setPublicWorkDetailError(null); + setIsWoodenFishBusy(true); + try { + const detail = await woodenFishClient.getWorkDetail(item.profileId); + setWoodenFishSession(null); + setWoodenFishRun(null); + setWoodenFishWork(detail.item); + setWoodenFishRuntimeReturnStage('wooden-fish-result'); + enterCreateTab(); + setSelectionStage('wooden-fish-result'); + } catch (error) { + setWoodenFishError( + resolveRpgCreationErrorMessage(error, '读取敲木鱼草稿失败。'), + ); + } finally { + setIsWoodenFishBusy(false); + } + }, + [ + enterCreateTab, + markDraftNoticeSeen, + openWoodenFishPublicWorkDetail, + setSelectionStage, + ], + ); + const openPublicGalleryDetail = useCallback( (entry: PlatformPublicGalleryCard) => { if (isBigFishGalleryEntry(entry)) { @@ -13658,11 +14187,8 @@ export function PlatformEntryFlowShellImpl({ const tryOpenMatch3DGalleryEntry = async () => { const entries = match3dGalleryEntries.length > 0 - ? [...match3dGalleryEntries, match3dDemoProfile] - : await refreshMatch3DGallery().then((items) => [ - ...items, - match3dDemoProfile, - ]); + ? match3dGalleryEntries + : await refreshMatch3DGallery(); const matchedEntry = entries.find((entry) => { const detailEntry = mapMatch3DWorkToPublicWorkDetail(entry); return ( @@ -14079,7 +14605,6 @@ export function PlatformEntryFlowShellImpl({ refreshBigFishGallery, resolveBigFishErrorMessage, setBigFishError, - match3dDemoProfile, ], ); @@ -14142,6 +14667,7 @@ export function PlatformEntryFlowShellImpl({ if (isVisualNovelCreationOpen) { void refreshVisualNovelShelf(); } + void refreshWoodenFishShelf(); void refreshBabyObjectMatchShelf(); void refreshBarkBattleShelf(); } @@ -14154,6 +14680,7 @@ export function PlatformEntryFlowShellImpl({ refreshBarkBattleShelf, refreshMatch3DShelf, refreshPuzzleShelf, + refreshWoodenFishShelf, refreshSquareHoleShelf, refreshVisualNovelShelf, selectionStage, @@ -14248,25 +14775,13 @@ export function PlatformEntryFlowShellImpl({ void refreshSquareHoleShelf(); } void refreshPuzzleShelf(); + void refreshWoodenFishShelf(); if (isVisualNovelCreationOpen) { void refreshVisualNovelShelf(); } void refreshBabyObjectMatchShelf(); void refreshBarkBattleShelf(); }} - createError={ - creationEntryConfigError ?? - sessionController.creationTypeError ?? - bigFishError ?? - match3dError ?? - (isSquareHoleCreationVisible ? squareHoleError : null) ?? - woodenFishError ?? - puzzleCreationError ?? - puzzleError ?? - (isVisualNovelCreationOpen ? visualNovelError : null) ?? - babyObjectMatchError ?? - barkBattleError - } createBusy={ !creationEntryConfig || sessionController.isCreatingAgentSession || @@ -14314,6 +14829,7 @@ export function PlatformEntryFlowShellImpl({ rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []} jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []} + woodenFishItems={woodenFishShelfItems} onOpenBigFishDetail={ isBigFishCreationVisible ? (item) => { @@ -14342,6 +14858,13 @@ export function PlatformEntryFlowShellImpl({ : null } onDeleteJumpHop={null} + onOpenWoodenFishDetail={(item) => { + runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); + void openWoodenFishDraft(item); + }); + }} + onDeleteWoodenFish={null} match3dItems={match3dShelfItems} onOpenMatch3DDetail={(item) => { runProtectedAction(() => { @@ -14454,6 +14977,7 @@ export function PlatformEntryFlowShellImpl({ isLoadingPlatform={platformBootstrap.isLoadingPlatform} isLoadingDashboard={platformBootstrap.isLoadingDashboard} hasUnreadDraftUpdate={hasUnreadDraftUpdates} + profileTaskRefreshKey={profileTaskRefreshKey} isDesktopLayout={isDesktopLayout} isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey} platformError={ @@ -15953,7 +16477,6 @@ export function PlatformEntryFlowShellImpl({ settingDescription={null} progressTitle="拼图草稿生成进度" activeBadgeLabel="草稿生成中" - pausedBadgeLabel="草稿生成已暂停" idleBadgeLabel="等待返回工作区" hideBatchModule /> @@ -16619,7 +17142,7 @@ export function PlatformEntryFlowShellImpl({ {creationEntryConfig ? ( - { @@ -16741,6 +17250,18 @@ export function PlatformEntryFlowShellImpl({ payload={publishSharePayload} onClose={() => setPublishSharePayload(null)} /> + + ({ + copyTextToClipboard: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('PlatformErrorDialog', () => { + test('shows source, message, and copies the full error report', async () => { + vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true); + + render( + {}} + />, + ); + + const dialog = screen.getByRole('dialog', { name: '发生错误' }); + expect(within(dialog).getByText('拼图草稿 puzzle-session-123')).toBeTruthy(); + expect(within(dialog).getByText('图片生成失败,请稍后再试。')).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: '复制报错' })); + + expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith( + ['来源:拼图草稿 puzzle-session-123', '错误:图片生成失败,请稍后再试。'].join( + '\n', + ), + ); + await waitFor(() => { + expect( + within(dialog).getByRole('button', { name: '已复制' }), + ).toBeTruthy(); + }); + }); + + test('does not render when there is no active error', () => { + render( {}} />); + + expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull(); + }); +}); + +describe('PlatformTaskCompletionDialog', () => { + test('shows source, message, and copies the full completion report', async () => { + vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true); + + render( + {}} + />, + ); + + const dialog = screen.getByRole('dialog', { name: '生成完成' }); + expect( + within(dialog).getByText('抓大鹅草稿 match3d-notice-session-1'), + ).toBeTruthy(); + expect( + within(dialog).getByText('生成任务已完成,可以继续查看草稿。'), + ).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: '复制内容' })); + + expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith( + [ + '来源:抓大鹅草稿 match3d-notice-session-1', + '状态:生成任务已完成,可以继续查看草稿。', + ].join('\n'), + ); + await waitFor(() => { + expect( + within(dialog).getByRole('button', { name: '已复制' }), + ).toBeTruthy(); + }); + }); + + test('does not render when there is no active completion', () => { + render( + {}} />, + ); + + expect(screen.queryByRole('dialog', { name: '生成完成' })).toBeNull(); + }); +}); diff --git a/src/components/platform-entry/PlatformErrorDialog.tsx b/src/components/platform-entry/PlatformErrorDialog.tsx new file mode 100644 index 00000000..794a6a5c --- /dev/null +++ b/src/components/platform-entry/PlatformErrorDialog.tsx @@ -0,0 +1,120 @@ +import { Check, Copy } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { copyTextToClipboard } from '../../services/clipboard'; +import { UnifiedModal } from '../common/UnifiedModal'; + +export type PlatformErrorDialogPayload = { + source: string; + message: string; +}; + +type PlatformErrorDialogProps = { + error: PlatformErrorDialogPayload | null; + onClose: () => void; + overlayClassName?: string; + panelClassName?: string; +}; + +function buildPlatformErrorReport(error: PlatformErrorDialogPayload) { + return [`来源:${error.source}`, `错误:${error.message}`].join('\n'); +} + +export function PlatformErrorDialog({ + error, + onClose, + overlayClassName = 'platform-theme platform-theme--light !items-center', + panelClassName = 'platform-remap-surface rounded-[1.5rem]', +}: PlatformErrorDialogProps) { + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const resetTimerRef = useRef(null); + const reportText = useMemo( + () => (error ? buildPlatformErrorReport(error) : ''), + [error], + ); + + useEffect( + () => () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + }, + [], + ); + + useEffect(() => { + setCopyState('idle'); + }, [error?.source, error?.message]); + + const copyError = () => { + if (!reportText) { + return; + } + + void copyTextToClipboard(reportText).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => { + resetTimerRef.current = null; + setCopyState('idle'); + }, 1400); + }); + }; + + return ( + + {copyState === 'copied' ? ( + + ) : ( + + )} + {copyState === 'copied' + ? '已复制' + : copyState === 'failed' + ? '复制失败' + : '复制报错'} + + } + > + {error ? ( + <> +
+
+ 来源 +
+
+ {error.source} +
+
+
+
+ 错误 +
+
+ {error.message} +
+
+ + ) : null} +
+ ); +} diff --git a/src/components/platform-entry/PlatformTaskCompletionDialog.tsx b/src/components/platform-entry/PlatformTaskCompletionDialog.tsx new file mode 100644 index 00000000..66513dd9 --- /dev/null +++ b/src/components/platform-entry/PlatformTaskCompletionDialog.tsx @@ -0,0 +1,124 @@ +import { CheckCircle2, Copy } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { copyTextToClipboard } from '../../services/clipboard'; +import { UnifiedModal } from '../common/UnifiedModal'; + +export type PlatformTaskCompletionDialogPayload = { + source: string; + message: string; +}; + +type PlatformTaskCompletionDialogProps = { + completion: PlatformTaskCompletionDialogPayload | null; + onClose: () => void; + overlayClassName?: string; + panelClassName?: string; +}; + +function buildPlatformTaskCompletionReport( + completion: PlatformTaskCompletionDialogPayload, +) { + return [`来源:${completion.source}`, `状态:${completion.message}`].join( + '\n', + ); +} + +export function PlatformTaskCompletionDialog({ + completion, + onClose, + overlayClassName = 'platform-theme platform-theme--light !items-center', + panelClassName = 'platform-remap-surface rounded-[1.5rem]', +}: PlatformTaskCompletionDialogProps) { + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const resetTimerRef = useRef(null); + const reportText = useMemo( + () => (completion ? buildPlatformTaskCompletionReport(completion) : ''), + [completion], + ); + + useEffect( + () => () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + }, + [], + ); + + useEffect(() => { + setCopyState('idle'); + }, [completion?.source, completion?.message]); + + const copyCompletion = () => { + if (!reportText) { + return; + } + + void copyTextToClipboard(reportText).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => { + resetTimerRef.current = null; + setCopyState('idle'); + }, 1400); + }); + }; + + return ( + + {copyState === 'copied' ? ( + + ) : ( + + )} + {copyState === 'copied' + ? '已复制' + : copyState === 'failed' + ? '复制失败' + : '复制内容'} + + } + > + {completion ? ( + <> +
+
+ 来源 +
+
+ {completion.source} +
+
+
+
+ 状态 +
+
+ {completion.message} +
+
+ + ) : null} +
+ ); +} diff --git a/src/components/platform-entry/PlatformWorkDetailView.tsx b/src/components/platform-entry/PlatformWorkDetailView.tsx index 7477d978..65d3c404 100644 --- a/src/components/platform-entry/PlatformWorkDetailView.tsx +++ b/src/components/platform-entry/PlatformWorkDetailView.tsx @@ -24,7 +24,6 @@ import { formatPlatformWorldTime, isBarkBattleGalleryEntry, isEdutainmentGalleryEntry, - isJumpHopGalleryEntry, type PlatformPublicGalleryCard, resolvePlatformPublicWorkCode, resolvePlatformWorldCoverSlides, @@ -36,7 +35,7 @@ export interface PlatformWorkDetailViewProps { authorAvatarUrl?: string | null; authorDisplayName?: string | null; isBusy: boolean; - error: string | null; + error?: string | null; visibleCoverCount?: number; onBack: () => void; onLike: () => void; @@ -89,7 +88,6 @@ export function PlatformWorkDetailView({ authorAvatarUrl, authorDisplayName, isBusy, - error, visibleCoverCount = 1, onBack, onLike, @@ -432,9 +430,6 @@ export function PlatformWorkDetailView({ {shareState === 'copied' ? '分享内容已复制' : '分享失败'} ) : null} - {error ? ( -
{error}
- ) : null} diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx index 3785425c..cd58b666 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -411,7 +411,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () = }); }); -test('puzzle workspace switches the image model from the description box', () => { +test('puzzle workspace switches image mode without exposing model names', () => { const onCreateFromForm = vi.fn(); render( @@ -427,9 +427,9 @@ test('puzzle workspace switches the image model from the description box', () => fireEvent.change(screen.getByLabelText('画面描述'), { target: { value: '一只猫在雨夜灯牌下回头。' }, }); - fireEvent.click(screen.getByRole('button', { name: '图片模型' })); - expect(screen.queryByRole('menuitemradio', { name: '原模型' })).toBeNull(); - fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' })); + fireEvent.click(screen.getByRole('button', { name: '图片生成模式' })); + expect(screen.queryByText(/gpt|nanobanana|gemini/u)).toBeNull(); + fireEvent.click(screen.getByRole('menuitemradio', { name: '创意模式' })); fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u })); confirmPuzzlePointCost(); diff --git a/src/components/puzzle-agent/PuzzleImageModelPicker.tsx b/src/components/puzzle-agent/PuzzleImageModelPicker.tsx index c8cb4e31..3cefe328 100644 --- a/src/components/puzzle-agent/PuzzleImageModelPicker.tsx +++ b/src/components/puzzle-agent/PuzzleImageModelPicker.tsx @@ -45,8 +45,8 @@ export function PuzzleImageModelPicker({ className={`inline-flex min-h-8 max-w-[10rem] items-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 px-3 text-[11px] font-bold text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${disabled ? 'cursor-not-allowed opacity-55' : ''}`} aria-haspopup="menu" aria-expanded={isOpen} - aria-label="图片模型" - title="图片模型" + aria-label="图片生成模式" + title="图片生成模式" > {getPuzzleImageModelLabel(normalizedValue)} diff --git a/src/components/puzzle-agent/puzzleImageModelOptions.ts b/src/components/puzzle-agent/puzzleImageModelOptions.ts index 12498083..147685f1 100644 --- a/src/components/puzzle-agent/puzzleImageModelOptions.ts +++ b/src/components/puzzle-agent/puzzleImageModelOptions.ts @@ -9,8 +9,8 @@ export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{ id: PuzzleImageModelId; label: string; }> = [ - { id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' }, - { id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' }, + { id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: '标准模式' }, + { id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: '创意模式' }, ]; export function normalizePuzzleImageModel( @@ -25,6 +25,6 @@ export function normalizePuzzleImageModel( export function getPuzzleImageModelLabel(model: PuzzleImageModelId) { return ( PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ?? - 'gpt-image-2' + '标准模式' ); } diff --git a/src/components/puzzle-result/PuzzleResultView.test.tsx b/src/components/puzzle-result/PuzzleResultView.test.tsx index 8bd79844..018ce784 100644 --- a/src/components/puzzle-result/PuzzleResultView.test.tsx +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -1305,7 +1305,7 @@ describe('PuzzleResultView', () => { expect(screen.queryByPlaceholderText('参考图链接或资产ID')).toBeNull(); }); - test('passes the selected image model when regenerating a level image', () => { + test('passes the selected image mode without exposing model names', () => { const onExecuteAction = vi.fn(); render( @@ -1319,9 +1319,12 @@ describe('PuzzleResultView', () => { openPuzzleLevelsTab(); fireEvent.click(screen.getByText('雨夜猫街')); const dialog = screen.getByRole('dialog', { name: '关卡详情' }); - fireEvent.click(within(dialog).getByRole('button', { name: '图片模型' })); fireEvent.click( - within(dialog).getByRole('menuitemradio', { name: 'gpt-image-2' }), + within(dialog).getByRole('button', { name: '图片生成模式' }), + ); + expect(within(dialog).queryByText(/gpt|nanobanana|gemini/u)).toBeNull(); + fireEvent.click( + within(dialog).getByRole('menuitemradio', { name: '标准模式' }), ); fireEvent.click( within(dialog).getByRole('button', { name: /重新生成画面/u }), diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index 4b280ad5..093b529a 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -1819,12 +1819,7 @@ export function PuzzleResultView({ ) : null} - {error ? ( -
- {error} -
- ) : null} - {!error && autoSaveError ? ( + {autoSaveError ? (
{autoSaveError}
diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index ecc4c5f0..e1641ac3 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -84,7 +84,6 @@ import { } from '../../services/edutainment-baby-object'; import { match3dCreationClient } from '../../services/match3d-creation'; import { - createLocalMatch3DRuntimeAdapter, createServerMatch3DRuntimeAdapter, } from '../../services/match3d-runtime'; import { @@ -674,7 +673,6 @@ vi.mock('../../services/match3dGeneratedModelCache', () => ({ })); const match3dRuntimeServiceMocks = vi.hoisted(() => ({ - createLocalMatch3DRuntimeAdapter: vi.fn(), createServerMatch3DRuntimeAdapter: vi.fn(), })); @@ -687,15 +685,6 @@ const match3dServerRuntimeAdapterMock = vi.hoisted(() => ({ stopRun: vi.fn(), })); -const match3dLocalRuntimeAdapterMock = vi.hoisted(() => ({ - clickItem: vi.fn(), - finishTimeUp: vi.fn(), - getRun: vi.fn(), - restartRun: vi.fn(), - startRun: vi.fn(), - stopRun: vi.fn(), -})); - vi.mock('../../services/match3d-runtime', async () => { const actual = await vi.importActual< typeof import('../../services/match3d-runtime') @@ -2405,9 +2394,6 @@ beforeEach(() => { vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue( match3dServerRuntimeAdapterMock, ); - vi.mocked(createLocalMatch3DRuntimeAdapter).mockReturnValue( - match3dLocalRuntimeAdapterMock, - ); match3dServerRuntimeAdapterMock.startRun.mockRejectedValue( new Error('未启动抓大鹅运行态'), ); @@ -2423,21 +2409,6 @@ beforeEach(() => { match3dServerRuntimeAdapterMock.stopRun.mockResolvedValue({ run: buildMockMatch3DRun('match3d-profile-stopped'), }); - match3dLocalRuntimeAdapterMock.startRun.mockResolvedValue({ - run: buildMockMatch3DRun('match3d-demo-20260525'), - }); - match3dLocalRuntimeAdapterMock.clickItem.mockRejectedValue( - new Error('未执行本地抓大鹅点击'), - ); - match3dLocalRuntimeAdapterMock.restartRun.mockResolvedValue({ - run: buildMockMatch3DRun('match3d-demo-20260525'), - }); - match3dLocalRuntimeAdapterMock.finishTimeUp.mockResolvedValue({ - run: buildMockMatch3DRun('match3d-demo-20260525'), - }); - match3dLocalRuntimeAdapterMock.stopRun.mockResolvedValue({ - run: buildMockMatch3DRun('match3d-demo-20260525'), - }); window.history.replaceState(null, '', '/'); window.sessionStorage.clear(); window.localStorage.clear(); @@ -5080,6 +5051,22 @@ test('completed match3d draft notice first opens trial then reopens result', asy resolveCompile({ session: generatedSession }); }); + const completionDialog = await screen.findByRole('dialog', { + name: '生成完成', + }); + expect( + within(completionDialog).getByText( + /抓大鹅草稿 match3d-notice-session-1/u, + ), + ).toBeTruthy(); + expect( + within(completionDialog).getByText(/生成任务已完成/u), + ).toBeTruthy(); + expect( + within(completionDialog).getByRole('button', { name: '复制内容' }), + ).toBeTruthy(); + await user.click(within(completionDialog).getByLabelText('关闭')); + expect(await screen.findByLabelText('新生成完成')).toBeTruthy(); await user.click( await screen.findByRole('button', { @@ -7503,6 +7490,48 @@ test('persisted generating puzzle draft keeps session polling on the same sessio expect(getPuzzleAgentSession).toHaveBeenCalledTimes(2); }); +test('puzzle compile timeout shows failure dialog when reread session is still generating', async () => { + const user = userEvent.setup(); + const runningSession = buildMockPuzzleAgentSession({ + sessionId: 'puzzle-session-timeout', + draft: null, + stage: 'collecting_anchors', + progressPercent: 88, + lastAssistantReply: '正在生成拼图草稿。', + }); + vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({ + session: runningSession, + }); + vi.mocked(executePuzzleAgentAction).mockRejectedValueOnce( + Object.assign(new Error('请求超时:1800000ms'), { + name: 'TimeoutError', + }), + ); + vi.mocked(getPuzzleAgentSession).mockResolvedValue({ + session: runningSession, + }); + + render(); + + await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('拼图')); + 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( + '拼图共创操作超时,请确认运行时后端已启动后重试。', + ), + ).toBeTruthy(); + expect(within(dialog).getByRole('button', { name: '复制报错' })).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '拼图草稿生成进度', + }), + ).toBeTruthy(); +}); + test('published puzzle work card restores its source session for editing', async () => { const user = userEvent.setup(); @@ -8332,38 +8361,6 @@ test('public code search opens a published Match3D work by M3 code and starts ru expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled(); }); -test('public code search opens the local Match3D demo and starts local runtime', async () => { - const user = userEvent.setup(); - - vi.mocked(listMatch3DGallery).mockResolvedValue({ items: [] }); - - render(); - await openDiscoverHub(user); - - const searchInput = - await screen.findByPlaceholderText('搜索作品号、名称、作者、描述'); - await user.type(searchInput, 'M3-20260525'); - await user.click(screen.getByRole('button', { name: '搜索' })); - - expect(await screen.findByText('详情')).toBeTruthy(); - expect(screen.getByText('海底糖果集市')).toBeTruthy(); - await user.click(screen.getByRole('button', { name: '启动' })); - - await waitFor(() => { - expect(match3dLocalRuntimeAdapterMock.startRun).toHaveBeenCalledWith( - 'match3d-demo-20260525', - {}, - ); - }); - expect(match3dServerRuntimeAdapterMock.startRun).not.toHaveBeenCalled(); - expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith( - 'match3d-demo-20260525', - ); - expect( - await screen.findByText('抓大鹅运行态:match3d-run-match3d-demo-20260525'), - ).toBeTruthy(); -}); - test('published Match3D runtime receives persisted generated models', async () => { const user = userEvent.setup(); const match3dWork: Match3DWorkSummary = { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index ebc0acb5..4c226935 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -702,14 +702,22 @@ function mockNarrowMobileLayout() { }); } -function renderProfileView( +function ProfileHomeViewHarness({ onRechargeSuccess = vi.fn(), - profileDashboardOverrides: Partial< + profileDashboardOverrides = {}, + userOverrides = {}, + activeTab = 'profile', + profileTaskRefreshKey = 0, +}: { + onRechargeSuccess?: () => void | Promise; + profileDashboardOverrides?: Partial< NonNullable - > = {}, - userOverrides: Partial = {}, -) { - return render( + >; + userOverrides?: Partial; + activeTab?: RpgEntryHomeViewProps['activeTab']; + profileTaskRefreshKey?: number; +}) { + return ( - , + + ); +} + +function renderProfileView( + onRechargeSuccess = vi.fn(), + profileDashboardOverrides: Partial< + NonNullable + > = {}, + userOverrides: Partial = {}, + profileTaskRefreshKey = 0, +) { + return render( + , ); } @@ -1902,11 +1929,18 @@ test('non-wechat profile opens reward code from recharge-shaped entry', async () expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled(); }); -test('profile daily task shortcut opens task center and claims reward', async () => { +test('profile daily task shortcut reflects task progress and claim updates', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); renderProfileView(onRechargeSuccess); + + const dailyTask = screen.getByRole('button', { name: /每日任务/u }); + await waitFor(() => { + expect(within(dailyTask).getByText('1 / 1')).toBeTruthy(); + }); + expect(within(dailyTask).getByText('领取')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /每日任务/u })); expect(await screen.findByText('每日登录')).toBeTruthy(); @@ -1923,6 +1957,7 @@ test('profile daily task shortcut opens task center and claims reward', async () expect(await screen.findByText('已领取 10 泥点')).toBeTruthy(); expect(screen.queryByRole('button', { name: '已领取' })).toBeNull(); expect(screen.getByText('暂无任务')).toBeTruthy(); + expect(within(dailyTask).getByText('已完成')).toBeTruthy(); }); test('profile task center keeps only the highest priority actionable task', async () => { @@ -1985,7 +2020,7 @@ test('profile task center keeps only the highest priority actionable task', asyn expect(screen.queryByText('低优先级已完成')).toBeNull(); }); -test('profile total play time card always uses hours', () => { +test('profile total play time card always uses hours', async () => { renderProfileView(vi.fn(), { totalPlayTimeMs: 90 * 60 * 1000, }); @@ -1996,9 +2031,10 @@ test('profile total play time card always uses hours', () => { expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy(); expect(within(playTimeCard).queryByText('90分')).toBeNull(); + await screen.findByText('1 / 1'); }); -test('profile played works card shows count unit', () => { +test('profile played works card shows count unit', async () => { renderProfileView(vi.fn(), { playedWorldCount: 1, }); @@ -2008,9 +2044,10 @@ test('profile played works card shows count unit', () => { }); expect(within(playedCard).getByText('1个')).toBeTruthy(); + await screen.findByText('1 / 1'); }); -test('profile stats cards are centered without update timestamp', () => { +test('profile stats cards are centered without update timestamp', async () => { renderProfileView(vi.fn(), { updatedAt: '2026-05-03T08:01:00Z', }); @@ -2026,6 +2063,7 @@ test('profile stats cards are centered without update timestamp', () => { expect(card.className).toContain('text-center'); } expect(screen.queryByText(/更新于/u)).toBeNull(); + await screen.findByText('1 / 1'); }); test('mobile profile page matches the reference layout sections', async () => { @@ -2083,7 +2121,7 @@ test('mobile profile page matches the reference layout sections', async () => { expect(dailyTask.querySelector('.platform-profile-daily-task-card__desc')).toBeTruthy(); expect(dailyTask.querySelector('.platform-profile-daily-task-card__progress')).toBeTruthy(); expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点'); - expect(within(dailyTask).getByText('0 / 1')).toBeTruthy(); + expect(await within(dailyTask).findByText('1 / 1')).toBeTruthy(); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); expect( @@ -2091,7 +2129,7 @@ test('mobile profile page matches the reference layout sections', async () => { ).toBeTruthy(); expect( shortcutRegion.querySelectorAll('.platform-profile-shortcut-button'), - ).toHaveLength(5); + ).toHaveLength(4); expect( shortcutRegion .querySelector('.platform-profile-shortcut-grid') @@ -2099,7 +2137,6 @@ test('mobile profile page matches the reference layout sections', async () => { ).toBe(true); for (const label of [ '泥点充值', - '邀请好友', '兑换码', '玩家社区', '反馈与建议', @@ -2177,7 +2214,7 @@ test('profile scan action opens camera scanner instead of recharge panel', async expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled(); }); -test('desktop account entry uses saved avatar image when available', () => { +test('desktop account entry uses saved avatar image when available', async () => { mockDesktopLayout(); const avatarUrl = 'data:image/png;base64,AAAA'; @@ -2187,6 +2224,7 @@ test('desktop account entry uses saved avatar image when available', () => { const avatarImage = accountEntry.querySelector('img'); expect(avatarImage?.getAttribute('src')).toBe(avatarUrl); expect(within(accountEntry).queryByText('测')).toBeNull(); + await screen.findByText('1 / 1'); }); test('profile avatar upload uses the shared square crop tool', async () => { @@ -2236,83 +2274,83 @@ test('wallet ledger modal shows empty and error states', async () => { expect(screen.getByText('重新加载')).toBeTruthy(); }); -test('profile invite shortcut shows reward subtitle and invited users', async () => { +test('profile community shortcut shows reward subtitle and invited users', async () => { const user = userEvent.setup(); renderProfileView(vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() }); - const inviteButton = screen.getByRole('button', { name: /邀请好友/u }); - expect(within(inviteButton).getByText('双方得 30 泥点')).toBeTruthy(); + expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull(); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy(); - await user.click(inviteButton); + await user.click(communityButton); expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1); - expect( - await screen.findByText('邀请一个用户注册,双方都可以获得30泥点。'), - ).toBeTruthy(); - expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy(); - expect(screen.getByText('成功邀请')).toBeTruthy(); - expect(screen.getByText('被邀请玩家')).toBeTruthy(); - expect(screen.queryByText('已奖')).toBeNull(); - expect(screen.queryByText('今日')).toBeNull(); + expect(screen.getByAltText('玩家社区微信群二维码')).toBeTruthy(); + expect(screen.getByAltText('玩家社区 QQ 群二维码')).toBeTruthy(); + expect(screen.getByText('微信群')).toBeTruthy(); + expect(screen.getByText('QQ群')).toBeTruthy(); + expect(screen.queryByText('成功邀请')).toBeNull(); + expect(screen.queryByText('被邀请玩家')).toBeNull(); }); -test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => { +test('profile page hides legacy redeem invite secondary shortcut for fresh accounts', async () => { renderProfileView( vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() }, ); - const inviteButton = screen.getByRole('button', { name: /邀请好友/u }); - const redeemButton = await screen.findByRole('button', { - name: /填邀请码/u, - }); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); - const secondaryShortcuts = screen.getByRole('region', { - name: '次级入口', + + await waitFor(() => { + expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1); }); - expect(inviteButton).toBeTruthy(); + expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull(); expect(communityButton).toBeTruthy(); - expect( - within(secondaryShortcuts).getByRole('button', { name: /填邀请码/u }), - ).toBeTruthy(); - expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy(); + expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull(); + expect(screen.queryByRole('button', { name: /填邀请码/u })).toBeNull(); }); test('profile redeem invite shortcut hides after redeemed or one day old', async () => { - const user = userEvent.setup(); - - mockGetRpgProfileReferralInviteCenter.mockResolvedValueOnce( - mockBuildReferralCenter({ - invitedUsers: [], - hasRedeemedCode: true, - boundInviterUserId: 'user-2', - boundAt: '2026-05-01T08:00:00Z', - }), - ); const { unmount } = renderProfileView(); - await user.click(screen.getByRole('button', { name: /邀请好友/u })); - await screen.findByText('成功邀请'); const firstShortcutRegion = screen.getByRole('region', { name: '常用功能' }); + expect( + within(firstShortcutRegion).queryByRole('button', { name: /邀请好友/u }), + ).toBeNull(); expect( within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }), ).toBeNull(); + await waitFor(() => { + expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1); + }); + await act(async () => { + await Promise.resolve(); + }); unmount(); renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' }); const expiredShortcutRegion = screen.getByRole('region', { name: '常用功能', }); + expect( + within(expiredShortcutRegion).queryByRole('button', { + name: /邀请好友/u, + }), + ).toBeNull(); expect( within(expiredShortcutRegion).queryByRole('button', { name: /填邀请码/u, }), ).toBeNull(); + await waitFor(() => { + expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2); + }); + await act(async () => { + await Promise.resolve(); + }); }); test('invite query opens login modal for logged out users', async () => { @@ -2345,9 +2383,10 @@ test('profile redeem invite modal reads query invite code after login', async () expect((input as HTMLInputElement).value).toBe('SPRING2026'); }); -test('profile redeem invite modal submits code and hides shortcut after success', async () => { +test('profile redeem invite query modal submits code after login', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); + window.history.replaceState(null, '', '/?inviteCode=spring-2026'); renderProfileView( onRechargeSuccess, @@ -2355,9 +2394,7 @@ test('profile redeem invite modal submits code and hides shortcut after success' { createdAt: buildFreshProfileCreatedAt() }, ); - await user.click(await screen.findByRole('button', { name: /填邀请码/u })); - const input = await screen.findByLabelText('邀请码'); - await user.type(input, 'spring-2026'); + expect(await screen.findByLabelText('邀请码')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '提交' })); await waitFor(() => { @@ -2367,12 +2404,23 @@ test('profile redeem invite modal submits code and hides shortcut after success' }); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); expect(await screen.findByText('已填写')).toBeTruthy(); - const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); - expect( - within(shortcutRegion).queryByRole('button', { - name: /填邀请码/u, - }), - ).toBeNull(); + expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull(); +}); + +test('profile task center reloads when refresh key changes', async () => { + const { rerender } = renderProfileView(); + + await waitFor(() => { + expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2); + }); }); test('opens reward code modal from profile action on mobile', async () => { @@ -2402,8 +2450,8 @@ test('profile page shows legal entries and hides archive shortcuts', async () => ?.classList.contains('platform-profile-shortcut-grid'), ).toBe(true); expect( - within(shortcutRegion).getByRole('button', { name: /邀请好友/u }), - ).toBeTruthy(); + within(shortcutRegion).queryByRole('button', { name: /邀请好友/u }), + ).toBeNull(); expect( within(shortcutRegion).getByRole('button', { name: /玩家社区/u }), ).toBeTruthy(); @@ -3274,6 +3322,41 @@ test('logged out active recommend bottom tab selects next work without login', a expect(openLoginModal).not.toHaveBeenCalled(); }); +test('logged out recommend card supports vertical swipe without login', () => { + vi.useFakeTimers(); + const onSelectNextRecommendEntry = vi.fn(); + const openLoginModal = vi.fn(); + + renderLoggedOutHomeView(openLoginModal, { + latestEntries: [ + puzzlePublicEntry, + { + ...puzzlePublicEntry, + workId: 'puzzle-work-guest-next', + profileId: 'puzzle-profile-guest-next', + ownerUserId: 'user-guest-next', + publicWorkCode: 'PZ-GUEST-NEXT', + worldName: '访客下一张', + }, + ], + activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', + onSelectNextRecommendEntry, + recommendRuntimeContent:
, + }); + + const meta = screen.getByLabelText('奇幻拼图 作品信息') as HTMLElement; + act(() => { + dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 320 }); + dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 220 }); + dispatchPointerEvent(meta, 'pointerup', { pointerId: 1, clientY: 220 }); + vi.advanceTimersByTime(180); + }); + + expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1); + expect(openLoginModal).not.toHaveBeenCalled(); + vi.useRealTimers(); +}); + test('mobile recommend meta loads real author avatar from public user summary', async () => { mockGetPublicAuthUserById.mockResolvedValueOnce({ id: 'user-2', diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 636ff874..ba2b26f9 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -29,7 +29,6 @@ import { Star, ThumbsUp, Ticket, - UserPlus, UserRound, XCircle, } from 'lucide-react'; @@ -50,7 +49,6 @@ import profileClockImage from '../../../media/profile/_Image (1).png'; import profileGamepadImage from '../../../media/profile/_Image (2).png'; import profileStillLifeImage from '../../../media/profile/_Image (3).png'; import profileCoinsImage from '../../../media/profile/_Image (4).png'; -import profileInviteImage from '../../../media/profile/_Image (5).png'; import profileGiftImage from '../../../media/profile/_Image (6).png'; import profileCommunityImage from '../../../media/profile/_Image (7).png'; import profileFeedbackImage from '../../../media/profile/_Image (8).png'; @@ -79,7 +77,6 @@ import type { WechatMiniProgramVirtualPayParams, WechatNativePayment, } from '../../../packages/shared/src/contracts/runtime'; -import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes'; import type { AuthUser } from '../../services/authService'; @@ -218,6 +215,7 @@ export interface RpgEntryHomeViewProps { onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void; onOpenFeedback?: () => void; onRechargeSuccess?: () => void | Promise; + profileTaskRefreshKey?: number; createTabContent?: ReactNode; draftTabContent?: ReactNode; hasUnreadDraftUpdate?: boolean; @@ -257,18 +255,25 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; -const PROFILE_TASK_STATUS_PRIORITY_RANK: Record = { +const PROFILE_TASK_STATUS_PRIORITY_RANK: Record< + ProfileTaskItem['status'], + number +> = { claimable: 2, incomplete: 1, disabled: 0, claimed: -1, }; +const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10; const PROFILE_QR_SCAN_INTERVAL_MS = 360; function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) { return tasks .map((task, index) => ({ task, index })) - .filter(({ task }) => task.status === 'claimable' || task.status === 'incomplete') + .filter( + ({ task }) => + task.status === 'claimable' || task.status === 'incomplete', + ) .sort( (left, right) => PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] - @@ -279,6 +284,37 @@ function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) { .map(({ task }) => task); } +function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) { + return ( + selectProfileTaskCenterTasks(tasks)[0] ?? + tasks.find((task) => task.status === 'claimed') ?? + tasks.find((task) => task.status !== 'disabled') ?? + null + ); +} + +function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) { + const task = selectProfileTaskCardTask(center?.tasks ?? []); + const threshold = Math.max(1, task?.threshold ?? 1); + const progressCount = Math.min(task?.progressCount ?? 0, threshold); + const rewardPoints = + task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS; + const actionLabel = + task?.status === 'claimable' + ? '领取' + : task?.status === 'claimed' + ? '已完成' + : '去完成'; + + return { + actionLabel, + progressCount, + progressPercent: Math.round((progressCount / threshold) * 100), + rewardPoints, + threshold, + }; +} + type ProfileReferralPanel = 'invite' | 'redeem' | 'community'; type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives'; type BarcodeDetectorLike = { @@ -2451,42 +2487,6 @@ function ProfileSettingsRow({ ); } -function ProfileSecondaryShortcutButton({ - label, - subLabel, - icon, - onClick, -}: { - label: string; - subLabel?: string; - icon: ComponentType<{ className?: string }>; - onClick: () => void; -}) { - const Icon = icon; - - return ( - - ); -} - function ProfileLegalSection({ onOpenDocument, }: { @@ -3987,6 +3987,7 @@ export function RpgEntryHomeView({ onOpenPlayedWork, onOpenFeedback, onRechargeSuccess, + profileTaskRefreshKey = 0, createTabContent, draftTabContent, hasUnreadDraftUpdate = false, @@ -4028,6 +4029,7 @@ export function RpgEntryHomeView({ useState(null); const [taskCenterError, setTaskCenterError] = useState(null); const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false); + const taskCenterRequestIdRef = useRef(0); const [claimingTaskId, setClaimingTaskId] = useState(null); const [taskClaimSuccess, setTaskClaimSuccess] = useState(null); const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); @@ -4047,6 +4049,7 @@ export function RpgEntryHomeView({ : readProfileInviteCodeFromLocationSearch(window.location.search), [], ); + const promptedLoginForInviteQueryRef = useRef(false); const autoOpenedInviteQueryRef = useRef(false); const [referralRedeemCode, setReferralRedeemCode] = useState( pendingProfileInviteCode, @@ -4225,12 +4228,10 @@ export function RpgEntryHomeView({ profileDashboard?.totalPlayTimeMs ?? 0, ); const playedWorkCount = profileDashboard?.playedWorldCount ?? 0; - const canShowReferralRedeemShortcut = - isAuthenticated && - isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt) && - isReferralCenterInitialized && - Boolean(referralCenter) && - referralCenter?.hasRedeemedCode !== true; + const profileTaskCardSummary = useMemo( + () => buildProfileTaskCardSummary(taskCenter), + [taskCenter], + ); const tabIcons: Record< PlatformHomeTab, ComponentType<{ className?: string }> @@ -4299,19 +4300,13 @@ export function RpgEntryHomeView({ return; } - const firstCategoryGroup = - categoryGroups.find((group) => - group.entries.some((entry) => !isMatch3DDemoProfileId(entry.profileId)), - ) ?? categoryGroups[0]; + const firstCategoryGroup = categoryGroups[0]; const selectedCategoryGroup = categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null; if ( firstCategoryGroup && (!selectedCategoryGroup || (!hasManualCategoryTagSelectionRef.current && - selectedCategoryGroup.entries.every((entry) => - isMatch3DDemoProfileId(entry.profileId), - ) && firstCategoryGroup.tag !== selectedCategoryGroup.tag)) ) { setSelectedCategoryTag(firstCategoryGroup.tag); @@ -4397,12 +4392,15 @@ export function RpgEntryHomeView({ return; } - autoOpenedInviteQueryRef.current = true; if (!authUi?.user) { - authUi?.openLoginModal(); + if (!promptedLoginForInviteQueryRef.current) { + promptedLoginForInviteQueryRef.current = true; + authUi?.openLoginModal(); + } return; } + autoOpenedInviteQueryRef.current = true; setReferralRedeemCode(pendingProfileInviteCode); setReferralError(null); setReferralSuccess(null); @@ -4803,23 +4801,49 @@ export function RpgEntryHomeView({ document.removeEventListener('visibilitychange', handleResume); }; }, [handleWechatPayResult]); - const loadTaskCenter = () => { + const loadTaskCenter = useCallback(() => { + const requestId = ++taskCenterRequestIdRef.current; setTaskCenterError(null); setIsLoadingTaskCenter(true); void getRpgProfileTasks() - .then(setTaskCenter) + .then((center) => { + if (requestId === taskCenterRequestIdRef.current) { + setTaskCenter(center); + } + }) .catch((error: unknown) => { + if (requestId !== taskCenterRequestIdRef.current) { + return; + } setTaskCenter(null); setTaskCenterError( error instanceof Error ? error.message : '读取每日任务失败', ); }) - .finally(() => setIsLoadingTaskCenter(false)); - }; + .finally(() => { + if (requestId === taskCenterRequestIdRef.current) { + setIsLoadingTaskCenter(false); + } + }); + }, []); + + useEffect(() => { + if (activeTab !== 'profile' || !isAuthenticated) { + taskCenterRequestIdRef.current += 1; + setTaskCenter(null); + setTaskCenterError(null); + return; + } + + loadTaskCenter(); + }, [activeTab, isAuthenticated, loadTaskCenter, profileTaskRefreshKey]); + const openTaskCenterPanel = () => { setIsTaskCenterOpen(true); setTaskClaimSuccess(null); - loadTaskCenter(); + if (!taskCenter) { + loadTaskCenter(); + } }; const openQrScannerPanel = () => { if (!authUi?.user) { @@ -5266,7 +5290,6 @@ export function RpgEntryHomeView({ (event: PointerEvent) => { if ( recommendDragCommitDirection || - !isAuthenticated || !activeRecommendEntry || recommendedFeedEntries.length <= 1 ) { @@ -5282,7 +5305,6 @@ export function RpgEntryHomeView({ }, [ activeRecommendEntry, - isAuthenticated, recommendDragCommitDirection, recommendedFeedEntries.length, ], @@ -6223,14 +6245,24 @@ export function RpgEntryHomeView({ 每日任务 - 完成任务可领取 10 泥点 + 完成任务可领取{' '} + + {profileTaskCardSummary.rewardPoints} + {' '} + 泥点 - 0 / 1 + {profileTaskCardSummary.progressCount} /{' '} + {profileTaskCardSummary.threshold} - + @@ -6240,7 +6272,7 @@ export function RpgEntryHomeView({ className="platform-profile-daily-task-card__mascot" /> - 去完成 + {profileTaskCardSummary.actionLabel} @@ -6256,13 +6288,6 @@ export function RpgEntryHomeView({ imageSrc={profileCoinsImage} onClick={openRechargeOrRewardCodeModal} /> - openProfilePopupPanel('invite')} - /> - {canShowReferralRedeemShortcut ? ( -
- openProfilePopupPanel('redeem')} - /> -
- ) : null} - ) : ( diff --git a/src/components/rpg-entry/rpgEntryShared.ts b/src/components/rpg-entry/rpgEntryShared.ts index 55537e39..2193bb8e 100644 --- a/src/components/rpg-entry/rpgEntryShared.ts +++ b/src/components/rpg-entry/rpgEntryShared.ts @@ -9,6 +9,9 @@ import type { CustomWorldProfile } from '../../types'; export function resolveRpgEntryErrorMessage(error: unknown, fallback: string) { if (isTimeoutError(error)) { + if (/拼图/u.test(fallback) && /操作|执行|编译|生成草稿/u.test(fallback)) { + return '拼图共创操作超时,请确认运行时后端已启动后重试。'; + } if (/智能创作/u.test(fallback)) { return '开启智能创作工作区超时,请确认运行时后端已启动后重试。'; } diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts index 27f8a848..5c39a03b 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts @@ -10,7 +10,6 @@ import { formatPlatformWorldTime, isBarkBattleGalleryEntry, isEdutainmentGalleryEntry, - isMatch3DGalleryEntry, isVisualNovelGalleryEntry, isWoodenFishGalleryEntry, mapBabyObjectMatchDraftToPlatformGalleryCard, @@ -22,7 +21,6 @@ import { resolvePlatformPublicWorkCode, resolvePlatformWorldFallbackCoverImage, } from './rpgEntryWorldPresentation'; -import { buildMatch3DDemoGalleryCard } from '../../data/match3dDemoGalleryCard'; test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => { expect(formatPlatformWorldTime('1777110165.990127Z')).toBe('2026-04-25'); @@ -80,24 +78,6 @@ test('platform public cards use play type reference images as cover fallback', ( ); }); -test('builds local Match3D demo gallery card with generated runtime assets intact', () => { - const card = buildMatch3DDemoGalleryCard(); - - expect(isMatch3DGalleryEntry(card)).toBe(true); - expect(card.publicWorkCode).toBe('M3-20260525'); - expect(resolvePlatformPublicWorkCode(card)).toBe('M3-20260525'); - expect(card.coverImageSrc).toBe( - '/match3d-demo/undersea-candy-market/level-scene.png', - ); - expect(card.generatedBackgroundAsset?.uiSpritesheetImageSrc).toBe( - '/match3d-demo/undersea-candy-market/ui-spritesheet.png', - ); - expect(card.generatedBackgroundAsset?.containerImageSrc).toBeNull(); - expect(card.generatedItemAssets?.[0]?.imageViews?.[0]?.imageSrc).toBe( - '/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png', - ); -}); - test('buildPuzzleWorkCoverSlides prefers each level formal image', () => { const slides = buildPuzzleWorkCoverSlides({ workId: 'work-1', diff --git a/src/data/match3dDemoGalleryCard.ts b/src/data/match3dDemoGalleryCard.ts deleted file mode 100644 index 533f6220..00000000 --- a/src/data/match3dDemoGalleryCard.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { - Match3DGeneratedBackgroundAsset, - Match3DGeneratedItemAsset, - Match3DWorkProfile, -} from '../../packages/shared/src/contracts/match3dWorks'; -import { buildMatch3DPublicWorkCode } from '../services/publicWorkCode'; -import type { PlatformMatch3DGalleryCard } from '../components/rpg-entry/rpgEntryWorldPresentation'; - -export const MATCH3D_DEMO_PROFILE_ID = 'match3d-demo-20260525'; -export const MATCH3D_DEMO_WORK_ID = 'match3d-demo-undersea-candy-market'; -export const MATCH3D_DEMO_PUBLIC_WORK_CODE = - buildMatch3DPublicWorkCode(MATCH3D_DEMO_PROFILE_ID); - -const MATCH3D_DEMO_ASSET_BASE = '/match3d-demo/undersea-candy-market'; -const MATCH3D_DEMO_PUBLISHED_AT = '2026-05-25T12:04:17.000+08:00'; -const MATCH3D_DEMO_ITEM_NAMES = [ - '海星糖', - '贝壳糖', - '珊瑚软糖', - '珍珠泡泡糖', - '海马棒棒糖', - '鱼尾果冻', - '水母棉花糖', - '螺旋饼干', - '海螺巧克力', - '贝珠马卡龙', - '珊瑚杯糕', - '星砂软糖', - '小鱼糖块', - '海草曲奇', - '泡泡杯', - '蓝莓珊瑚糖', - '迷你糖罐', - '珍珠饼', - '海浪甜甜圈', - '贝壳蛋糕', -] as const; - -export const MATCH3D_DEMO_BACKGROUND_ASSET: Match3DGeneratedBackgroundAsset = { - prompt: '海底糖果集市抓大鹅关卡背景', - levelScenePrompt: '海底糖果集市完整关卡画面', - levelSceneImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/level-scene.png`, - imageSrc: `${MATCH3D_DEMO_ASSET_BASE}/background.png`, - uiSpritesheetPrompt: '海底糖果集市 UI 透明 spritesheet', - uiSpritesheetImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/ui-spritesheet.png`, - itemSpritesheetPrompt: '海底糖果集市物品 10x10 透明 spritesheet', - itemSpritesheetImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/item-spritesheet.png`, - containerImageSrc: null, - status: 'image_ready', -}; - -export function buildMatch3DDemoGeneratedItemAssets() { - return MATCH3D_DEMO_ITEM_NAMES.map( - (itemName, itemIndex) => { - const itemNumber = itemIndex + 1; - const paddedItemNumber = String(itemNumber).padStart(2, '0'); - return { - itemId: `match3d-item-${itemNumber}`, - itemName, - itemSize: - itemIndex < 4 ? '大' : itemIndex < 14 ? '中' : '小', - imageViews: Array.from({ length: 5 }, (_, viewIndex) => { - const viewNumber = viewIndex + 1; - return { - viewId: `view-${String(viewNumber).padStart(2, '0')}`, - viewIndex: viewNumber, - imageSrc: `${MATCH3D_DEMO_ASSET_BASE}/item-slices/item-${paddedItemNumber}/view-${String(viewNumber).padStart(2, '0')}.png`, - }; - }), - backgroundAsset: - itemIndex === 0 ? MATCH3D_DEMO_BACKGROUND_ASSET : null, - status: 'image_ready', - }; - }, - ); -} - -export function buildMatch3DDemoWorkProfile(): Match3DWorkProfile { - return { - workId: MATCH3D_DEMO_WORK_ID, - profileId: MATCH3D_DEMO_PROFILE_ID, - ownerUserId: 'official-match3d-demo', - sourceSessionId: 'match3d-demo-session-20260525', - gameName: '海底糖果集市', - themeText: '海底糖果集市', - summary: '在海底糖果集市里把同款甜点抓成三消。', - tags: ['抓大鹅', '海底糖果', '官方示例'], - coverImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/level-scene.png`, - referenceImageSrc: null, - clearCount: 21, - difficulty: 8, - publicationStatus: 'published', - playCount: 0, - updatedAt: MATCH3D_DEMO_PUBLISHED_AT, - publishedAt: MATCH3D_DEMO_PUBLISHED_AT, - publishReady: true, - generationStatus: 'ready', - backgroundPrompt: MATCH3D_DEMO_BACKGROUND_ASSET.prompt, - backgroundImageSrc: MATCH3D_DEMO_BACKGROUND_ASSET.imageSrc, - backgroundImageObjectKey: null, - generatedBackgroundAsset: MATCH3D_DEMO_BACKGROUND_ASSET, - generatedItemAssets: buildMatch3DDemoGeneratedItemAssets(), - }; -} - -export function buildMatch3DDemoGalleryCard(): PlatformMatch3DGalleryCard { - const profile = buildMatch3DDemoWorkProfile(); - return { - sourceType: 'match3d', - workId: profile.workId, - profileId: profile.profileId, - sourceSessionId: profile.sourceSessionId, - publicWorkCode: MATCH3D_DEMO_PUBLIC_WORK_CODE, - ownerUserId: profile.ownerUserId, - authorDisplayName: '官方示例', - worldName: profile.gameName, - subtitle: '抓大鹅 · 资源管线示例', - summaryText: profile.summary, - coverImageSrc: profile.coverImageSrc ?? null, - themeTags: profile.tags, - playCount: profile.playCount, - remixCount: 0, - likeCount: 0, - recentPlayCount7d: 0, - visibility: 'published', - publishedAt: profile.publishedAt ?? null, - updatedAt: profile.updatedAt, - backgroundPrompt: profile.backgroundPrompt ?? null, - backgroundImageSrc: profile.backgroundImageSrc ?? null, - backgroundImageObjectKey: profile.backgroundImageObjectKey ?? null, - generatedBackgroundAsset: profile.generatedBackgroundAsset ?? null, - generatedItemAssets: profile.generatedItemAssets ?? [], - }; -} - -export const MATCH3D_DEMO_WORK_PROFILE = buildMatch3DDemoWorkProfile(); -export const MATCH3D_DEMO_GALLERY_CARD = buildMatch3DDemoGalleryCard(); - -export function isMatch3DDemoProfileId(profileId: string | null | undefined) { - return profileId?.trim() === MATCH3D_DEMO_PROFILE_ID; -} diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index d95502c7..8a925fac 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -20,8 +20,8 @@ import { clearStoredAccessToken, getStoredAccessToken } from './apiClient'; import { authEntry, bindWechatPhone, - changePhoneNumber, changePassword, + changePhoneNumber, consumeAuthCallbackResult, getAuthAuditLogs, getAuthLoginOptions, @@ -34,6 +34,7 @@ import { loginWithPhoneCode, logoutAllAuthSessions, redeemRegistrationInviteCode, + requestWechatMiniProgramPhoneLogin, revokeAuthSession, revokeAuthSessions, sendPhoneLoginCode, @@ -408,6 +409,84 @@ describe('authService', () => { ); }); + it('requests mini program phone login by opening the native auth page', async () => { + const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { + options.success?.(); + }); + vi.stubGlobal( + 'window', + createWindowMock({ + location: { + pathname: '/', + hash: '', + search: '?clientRuntime=wechat_mini_program', + assign: vi.fn(), + }, + wx: { + miniProgram: { + navigateTo, + }, + }, + }), + ); + + const result = await requestWechatMiniProgramPhoneLogin(); + + expect(result).toBe(true); + expect(navigateTo).toHaveBeenCalledWith({ + url: '/pages/web-view/index?authAction=login&returnTo=previous', + success: expect.any(Function), + fail: expect.any(Function), + }); + }); + + it('waits for an existing WeChat JS SDK script before opening the native auth page', async () => { + const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { + options.success?.(); + }); + const scriptListeners = new Map(); + const existingScript = { + addEventListener: vi.fn( + (type: string, listener: EventListener) => { + scriptListeners.set(type, listener); + }, + ), + }; + vi.stubGlobal( + 'window', + createWindowMock({ + location: { + pathname: '/', + hash: '', + search: '?clientRuntime=wechat_mini_program', + assign: vi.fn(), + }, + }), + ); + vi.stubGlobal('document', { + querySelector: vi.fn(() => existingScript), + head: { + appendChild: vi.fn(), + }, + createElement: vi.fn(), + }); + + const request = requestWechatMiniProgramPhoneLogin(); + window.wx = { + miniProgram: { + navigateTo, + }, + }; + scriptListeners.get('load')?.(new Event('load')); + + await expect(request).resolves.toBe(true); + expect(navigateTo).toHaveBeenCalledWith({ + url: '/pages/web-view/index?authAction=login&returnTo=previous', + success: expect.any(Function), + fail: expect.any(Function), + }); + }); + it('loads available login methods for the unauthenticated login screen', async () => { apiClientMocks.requestJson.mockResolvedValue({ availableLoginMethods: ['phone', 'wechat'], diff --git a/src/services/authService.ts b/src/services/authService.ts index e7002375..0232d51c 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -20,8 +20,8 @@ import type { AuthRiskBlockSummary, AuthSessionsResponse, AuthSessionSummary, - AuthWechatBindPhoneResponse, AuthWechatBindPhoneRequest, + AuthWechatBindPhoneResponse, AuthWechatStartResponse, LogoutResponse, PublicUserSearchResponse, @@ -55,6 +55,10 @@ export type ConsumedAuthCallback = { error: string | null; }; +const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; +const MINI_PROGRAM_AUTH_PAGE_URL = + '/pages/web-view/index?authAction=login&returnTo=previous'; + // 登录前公开认证入口不能误带旧 token,也不能先触发 refresh 探测, // 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。 const PUBLIC_AUTH_REQUEST_OPTIONS = { @@ -80,6 +84,92 @@ export function clearRuntimeGuestTokenCache() { runtimeGuestTokenCache.value = null; } +export function isWechatMiniProgramWebViewRuntime() { + if (typeof window === 'undefined') { + return false; + } + + const params = new URLSearchParams(window.location.search || ''); + return ( + params.get('clientRuntime') === 'wechat_mini_program' || + params.get('clientType') === 'mini_program' || + Boolean(window.wx?.miniProgram?.postMessage) + ); +} + +function loadWechatMiniProgramBridge() { + if (typeof window === 'undefined') { + return Promise.reject(new Error('请在微信小程序内完成登录')); + } + + if (window.wx?.miniProgram?.navigateTo) { + return Promise.resolve(window.wx); + } + + return new Promise>((resolve, reject) => { + const existingScript = document.querySelector( + `script[src="${WECHAT_JS_SDK_URL}"]`, + ); + const complete = () => { + if (window.wx?.miniProgram?.navigateTo) { + resolve(window.wx); + } else { + reject(new Error('请在微信小程序内完成登录')); + } + }; + + if (existingScript) { + if (window.wx?.miniProgram?.navigateTo) { + complete(); + return; + } + + existingScript.addEventListener('load', complete, { once: true }); + existingScript.addEventListener( + 'error', + () => reject(new Error('请在微信小程序内完成登录')), + { once: true }, + ); + return; + } + + const script = document.createElement('script'); + script.src = WECHAT_JS_SDK_URL; + script.async = true; + script.onload = complete; + script.onerror = () => reject(new Error('请在微信小程序内完成登录')); + document.head.appendChild(script); + }); +} + +export async function requestWechatMiniProgramPhoneLogin() { + if (!isWechatMiniProgramWebViewRuntime()) { + return false; + } + + const wxBridge = await loadWechatMiniProgramBridge(); + const miniProgram = wxBridge.miniProgram; + const navigateTo = miniProgram?.navigateTo; + if (typeof navigateTo !== 'function') { + return false; + } + + await new Promise((resolve, reject) => { + navigateTo({ + url: MINI_PROGRAM_AUTH_PAGE_URL, + success() { + resolve(); + }, + fail(error) { + reject( + new Error(error?.errMsg || '请在微信小程序内完成登录'), + ); + }, + }); + }); + return true; +} + export async function ensureRuntimeGuestToken() { if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) { return runtimeGuestTokenCache.value!; diff --git a/src/services/edutainment-baby-object/babyObjectMatchClient.ts b/src/services/edutainment-baby-object/babyObjectMatchClient.ts index d74623e7..27fccefe 100644 --- a/src/services/edutainment-baby-object/babyObjectMatchClient.ts +++ b/src/services/edutainment-baby-object/babyObjectMatchClient.ts @@ -279,7 +279,7 @@ async function generateBabyObjectMatchAssets( const assets = normalizeGeneratedAssets(response.assets, itemNames); const visualPackage = normalizeGeneratedVisualPackage(response.visualPackage); if (!assets || !visualPackage) { - throw new Error('宝贝识物 image-2 资源生成结果不完整,请重试。'); + throw new Error('宝贝识物素材生成结果不完整,请重试。'); } return { assets, visualPackage }; diff --git a/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts b/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts index ac40af0e..5a1951d4 100644 --- a/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts +++ b/src/services/match3d-runtime/match3dRuntimeAdapter.test.ts @@ -117,15 +117,6 @@ test('local Match3D runtime adapter exposes the same runtime seam as the server expect(stopped.run.status).toBe('Stopped'); }); -test('local Match3D runtime adapter keeps the requested profile id on restart', async () => { - const adapter = createLocalMatch3DRuntimeAdapter({ clearCount: 1 }); - const started = await adapter.startRun('match3d-demo-20260525'); - const restarted = await adapter.restartRun(started.run.runId); - - expect(started.run.profileId).toBe('match3d-demo-20260525'); - expect(restarted.run.profileId).toBe('match3d-demo-20260525'); -}); - test('local Match3D runtime adapter keeps authority run local to the adapter', async () => { const adapter = createLocalMatch3DRuntimeAdapter({ initialRun: startLocalMatch3DRun(1) }); const first = await adapter.getRun('unused-run-id'); diff --git a/src/services/miniGameDraftGenerationProgress.test.ts b/src/services/miniGameDraftGenerationProgress.test.ts index 7344963f..e1aeb76a 100644 --- a/src/services/miniGameDraftGenerationProgress.test.ts +++ b/src/services/miniGameDraftGenerationProgress.test.ts @@ -37,7 +37,7 @@ describe('miniGameDraftGenerationProgress', () => { '建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。', ); expect(progress?.steps[2]?.detail).toBe( - '调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。', + '生成 1:1 拼图首图,预计 4 分钟。', ); expect(progress?.estimatedRemainingMs).toBe(446_500); expect(progress?.overallProgress).toBe(0); diff --git a/src/services/miniGameDraftGenerationProgress.ts b/src/services/miniGameDraftGenerationProgress.ts index 08bab687..58232ee9 100644 --- a/src/services/miniGameDraftGenerationProgress.ts +++ b/src/services/miniGameDraftGenerationProgress.ts @@ -167,7 +167,7 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) { steps.push({ id: 'puzzle-cover-image', label: '生成拼图首图', - detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。', + detail: '生成 1:1 拼图首图,预计 4 分钟。', durationMs: PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS, }); } @@ -177,15 +177,15 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) { id: 'puzzle-level-scene', label: '生成关卡画面', detail: shouldSkipPuzzleCoverGeneration(state) - ? '直接使用上传图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。' - : '使用拼图首图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。', + ? '直接使用上传图作为参考,生成 9:16 完整关卡画面,预计 90 秒。' + : '使用拼图首图作为参考,生成 9:16 完整关卡画面,预计 90 秒。', durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS, }, { id: 'puzzle-ui-assets', label: '生成UI与背景', detail: - '用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景;两次 gpt-image-2 并发,预计 90 秒。', + '用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景,预计 90 秒。', durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS, }, { @@ -305,7 +305,7 @@ const MATCH3D_STEPS = [ { id: 'match3d-level-scene', label: '生成关卡整图', - detail: '调用 gpt-image-2 生成 9:16 完整抓大鹅关卡画面。', + detail: '生成 9:16 完整抓大鹅关卡画面。', weight: 28, }, { diff --git a/src/services/wooden-fish/woodenFishClient.test.ts b/src/services/wooden-fish/woodenFishClient.test.ts index f99ec1d8..aef88dee 100644 --- a/src/services/wooden-fish/woodenFishClient.test.ts +++ b/src/services/wooden-fish/woodenFishClient.test.ts @@ -1,5 +1,7 @@ import { beforeEach, expect, test, vi } from 'vitest'; +const requestJsonMock = vi.hoisted(() => vi.fn()); + const { createCreationAgentClientMock } = vi.hoisted(() => ({ createCreationAgentClientMock: vi.fn(), })); @@ -9,7 +11,7 @@ vi.mock('../creation-agent', () => ({ })); vi.mock('../apiClient', () => ({ - requestJson: vi.fn(), + requestJson: requestJsonMock, })); beforeEach(() => { @@ -22,6 +24,7 @@ beforeEach(() => { streamMessage: vi.fn(), executeAction: vi.fn(), }); + requestJsonMock.mockReset(); }); test('wooden fish creation keeps image2 generation requests alive long enough', async () => { @@ -34,3 +37,16 @@ test('wooden fish creation keeps image2 generation requests alive long enough', }), ); }); + +test('wooden fish list works uses creation works endpoint', async () => { + const { woodenFishClient } = await import('./woodenFishClient'); + requestJsonMock.mockResolvedValueOnce({ items: [] }); + + await woodenFishClient.listWorks(); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/creation/wooden-fish/works', + { method: 'GET' }, + '读取敲木鱼作品列表失败', + ); +}); diff --git a/src/services/wooden-fish/woodenFishClient.ts b/src/services/wooden-fish/woodenFishClient.ts index 8aa08ef4..f6f31005 100644 --- a/src/services/wooden-fish/woodenFishClient.ts +++ b/src/services/wooden-fish/woodenFishClient.ts @@ -13,6 +13,7 @@ import type { WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkProfileResponse, + WoodenFishWorksResponse, WoodenFishWorkspaceCreateRequest, WoodenFishWorkSummaryResponse, } from '../../../packages/shared/src/contracts/woodenFish'; @@ -57,6 +58,7 @@ export type { WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkProfileResponse, + WoodenFishWorksResponse, WoodenFishWorkspaceCreateRequest, }; export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest; @@ -186,6 +188,15 @@ export async function getWoodenFishWorkDetail(profileId: string) { return normalizeWoodenFishWorkDetailResponse(response); } +export async function listWoodenFishWorks() { + const response = await requestJson( + WOODEN_FISH_WORKS_API_BASE, + { method: 'GET' }, + '读取敲木鱼作品列表失败', + ); + return response; +} + export async function listWoodenFishGallery() { return requestJson( `${WOODEN_FISH_RUNTIME_API_BASE}/gallery`, @@ -312,6 +323,7 @@ export const woodenFishClient = { getSession: getWoodenFishCreationSession, getWorkDetail: getWoodenFishWorkDetail, listGallery: listWoodenFishGallery, + listWorks: listWoodenFishWorks, publishWork: publishWoodenFishWork, startRun: startWoodenFishRuntimeRun, };