Sync local updates with origin/master
This commit is contained in:
@@ -39,6 +39,15 @@
|
|||||||
- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
- 影响范围:`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` 通过。
|
- 验证方式:`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`。
|
- 关联文档:`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 使用静态资源与本地运行态
|
## 2026-05-25 抓大鹅发现页官方 demo 使用静态资源与本地运行态
|
||||||
|
|
||||||
@@ -996,6 +1005,14 @@
|
|||||||
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。
|
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 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`。
|
- 关联文档:`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 陶泥儿主视觉配色回收为暖白/陶土橙
|
# 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙
|
||||||
|
|
||||||
- 背景:用户要求只替换产品各界面的 UI 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据。
|
- 背景:用户要求只替换产品各界面的 UI 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据。
|
||||||
@@ -1047,3 +1064,10 @@
|
|||||||
- 影响范围:拼图图片模型选择器、拼图结果页关卡重生成面板、拼图生成进度文案、宝贝识物结果页占位提示和相关错误提示。
|
- 影响范围:拼图图片模型选择器、拼图结果页关卡重生成面板、拼图生成进度文案、宝贝识物结果页占位提示和相关错误提示。
|
||||||
- 验证方式:前端可见文本中不再出现 `gpt-image-2` / `gemini-3.1-flash-image-preview` / `image-2 资源`;相关交互测试改为断言产品化模式名,但提交 payload 仍保持原有模型 ID。
|
- 验证方式:前端可见文本中不再出现 `gpt-image-2` / `gemini-3.1-flash-image-preview` / `image-2 资源`;相关交互测试改为断言产品化模式名,但提交 payload 仍保持原有模型 ID。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`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`。
|
||||||
|
|||||||
@@ -1138,6 +1138,22 @@
|
|||||||
- 验证:`PuzzleResultView` 单测覆盖发布弹窗内展示 `泥点余额不足`。
|
- 验证:`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`。
|
- 关联:`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 移动端放大溢出
|
## WebGL 画布在高 DPR 移动端放大溢出
|
||||||
|
|
||||||
- 现象:抓大鹅试玩入口进入后,3D 锅体和物体从中心圆形区域向右下溢出,顶部状态和底部备选栏也可能看起来被右侧裁切。
|
- 现象:抓大鹅试玩入口进入后,3D 锅体和物体从中心圆形区域向右下溢出,顶部状态和底部备选栏也可能看起来被右侧裁切。
|
||||||
@@ -1508,6 +1524,14 @@
|
|||||||
- 验证:`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。
|
- 验证:`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`。
|
- 关联:`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
|
## image2 dry-run 带参考图时不要直接打印 data URL
|
||||||
|
|
||||||
- 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。
|
- 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ HTTP 路由:
|
|||||||
POST /api/creation/wooden-fish/sessions
|
POST /api/creation/wooden-fish/sessions
|
||||||
GET /api/creation/wooden-fish/sessions/{sessionId}
|
GET /api/creation/wooden-fish/sessions/{sessionId}
|
||||||
POST /api/creation/wooden-fish/sessions/{sessionId}/actions
|
POST /api/creation/wooden-fish/sessions/{sessionId}/actions
|
||||||
|
GET /api/creation/wooden-fish/works
|
||||||
GET /api/creation/wooden-fish/works/{profileId}
|
GET /api/creation/wooden-fish/works/{profileId}
|
||||||
POST /api/creation/wooden-fish/works/{profileId}/publish
|
POST /api/creation/wooden-fish/works/{profileId}/publish
|
||||||
GET /api/runtime/wooden-fish/works/{profileId}
|
GET /api/runtime/wooden-fish/works/{profileId}
|
||||||
@@ -304,6 +305,8 @@ finish
|
|||||||
|
|
||||||
敲木鱼创作请求在前端必须使用长等待窗口,避免 `createSession` 或 `executeAction` 仍沿用共享创作工厂默认的 15 秒超时。因为 `compile-draft` 会串行等待敲击物、背景、返回按钮三次 image2 和 OSS 落库,木鱼 client 需要单独配置与整条 image2 链路匹配的超时。本地测试中该 action 可能达到数分钟级;生成页进度必须按“整理草稿 -> 生成敲击物 -> 生成背景环境图 -> 生成返回按钮图 -> 写入正式草稿”展示,不展示“提示词生成音效”阶段,因为当前木鱼音效只支持上传、录音或默认音。
|
敲木鱼创作请求在前端必须使用长等待窗口,避免 `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
|
## 9. SpacetimeDB 表和 view
|
||||||
|
|
||||||
新增表:
|
新增表:
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ jump-hop-gallery-detail
|
|||||||
|
|
||||||
### 6.2 地块只生一次图集
|
### 6.2 地块只生一次图集
|
||||||
|
|
||||||
地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。
|
地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。该图集使用跳一跳专用 `2行*3列` 六格布局,不套用通用“每个物品一行、每行 n 个不同视图”的系列素材模型。
|
||||||
|
|
||||||
地块图集要求:
|
地块图集要求:
|
||||||
|
|
||||||
@@ -176,17 +176,24 @@ jump-hop-gallery-detail
|
|||||||
2. 必须表现出顶面、侧面和投影;
|
2. 必须表现出顶面、侧面和投影;
|
||||||
3. 必须与角色图保持同一光向;
|
3. 必须与角色图保持同一光向;
|
||||||
4. 必须有清晰的立体层次,但仍然是 2D 图片;
|
4. 必须有清晰的立体层次,但仍然是 2D 图片;
|
||||||
5. 必须包含至少以下地块类型:
|
5. 六格必须按固定顺序包含以下地块类型:
|
||||||
- 起点地块;
|
- 起点地块;
|
||||||
- 普通地块;
|
- 普通地块;
|
||||||
- 目标地块;
|
- 目标地块;
|
||||||
- 终点地块。
|
- 终点地块;
|
||||||
|
- 奖励地块;
|
||||||
|
- 视觉强调地块。
|
||||||
|
|
||||||
建议额外包含:
|
固定格位为:
|
||||||
|
|
||||||
1. 奖励地块;
|
| 格位 | tileType | 语义 |
|
||||||
2. 视觉强调地块;
|
| --- | --- | --- |
|
||||||
3. 风格化变体地块。
|
| 第 1 行第 1 列 | `start` | 起点地块 |
|
||||||
|
| 第 1 行第 2 列 | `normal` | 普通地块 |
|
||||||
|
| 第 1 行第 3 列 | `target` | 目标地块 |
|
||||||
|
| 第 2 行第 1 列 | `finish` | 终点地块 |
|
||||||
|
| 第 2 行第 2 列 | `bonus` | 奖励地块 |
|
||||||
|
| 第 2 行第 3 列 | `accent` | 视觉强调地块 |
|
||||||
|
|
||||||
图集生成后按地块类型切分并去掉背景,运行态直接消费切好的 PNG,不在前端做复杂拼接。只有用户在结果页明确点击“重生成地块”时,才允许再调用一次地块图集生图。
|
图集生成后按地块类型切分并去掉背景,运行态直接消费切好的 PNG,不在前端做复杂拼接。只有用户在结果页明确点击“重生成地块”时,才允许再调用一次地块图集生图。
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算。
|
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算。
|
||||||
7. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。
|
7. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。
|
||||||
8. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
8. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||||
|
9. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
|
||||||
|
|
||||||
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
|
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
|
||||||
|
|
||||||
@@ -131,7 +132,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
|
|
||||||
1. 初始草稿生成时,角色形象单独调用一次生图;
|
1. 初始草稿生成时,角色形象单独调用一次生图;
|
||||||
2. 初始草稿生成时,地块单独调用一次生图,输出 3D 视图的 2D 图片图集;
|
2. 初始草稿生成时,地块单独调用一次生图,输出 3D 视图的 2D 图片图集;
|
||||||
3. 地块图集由后端切分为起点、普通、目标、终点等透明 PNG;
|
3. 跳一跳地块图集使用专用 `2行*3列` 六格布局,后端按 `start / normal / target / finish / bonus / accent` 顺序切分为透明 PNG;
|
||||||
4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图;
|
4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图;
|
||||||
5. 显式重生成角色或地块时,只重生成对应资产槽位。
|
5. 显式重生成角色或地块时,只重生成对应资产槽位。
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
- 预计等待 / 已耗时信息卡要压缩为更轻的半透明窄卡,标签使用 `9px-10px`,数值使用 `12px-13px`,字号对齐其他生成页 UI 的小字号,不再使用偏大的提示文本;卡片标题和时间值都居中显示,两个数值只展示时间本身,调用侧不要再拼接“预计还需”或“已耗时”前缀。圆环中心不再保留独立白底块,空心圆环只保留条状进度,圆弧半径继续加大,进度数字与“总进度”标题整体上移,靠近圆环上半区。
|
- 预计等待 / 已耗时信息卡要压缩为更轻的半透明窄卡,标签使用 `9px-10px`,数值使用 `12px-13px`,字号对齐其他生成页 UI 的小字号,不再使用偏大的提示文本;卡片标题和时间值都居中显示,两个数值只展示时间本身,调用侧不要再拼接“预计还需”或“已耗时”前缀。圆环中心不再保留独立白底块,空心圆环只保留条状进度,圆弧半径继续加大,进度数字与“总进度”标题整体上移,靠近圆环上半区。
|
||||||
- 顶部导航区采用“返回创作中心 / 状态胶囊”结构,返回按钮使用左箭头图标,字号使用 `text-xs-sm`,状态胶囊使用 `11px-12px`,展示 `素材生成中`、`草稿生成中` 等调用侧传入文案。
|
- 顶部导航区采用“返回创作中心 / 状态胶囊”结构,返回按钮使用左箭头图标,字号使用 `text-xs-sm`,状态胶囊使用 `11px-12px`,展示 `素材生成中`、`草稿生成中` 等调用侧传入文案。
|
||||||
- 圆弧区域不再包独立大卡片,左右悬浮信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。当前圆环外径以 `w-[min(35rem,94vw)] sm:w-[52rem]` 为基准,圆弧使用 `r=166`、`strokeWidth=18` 的 SVG 描边,不再使用 `conic-gradient + mask`,避免进度条边缘模糊。
|
- 圆弧区域不再包独立大卡片,左右悬浮信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。当前圆环外径以 `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%+`。
|
- 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须重置“展示态 startedAtMs”为进入生成页的当前时间;后端 `progressPercent` 只用于后续真实步骤推进,不得参与首帧总进度展示,避免恢复生成页首帧直接显示 `80%+`。
|
||||||
- 生成页只展示半透明“当前步骤”单卡,卡片内只保留步骤名称、步骤状态、步骤进度条和轻量加载指示;“当前步骤”标签使用 `10px-11px`,步骤名称使用 `14px-15px`,状态使用 `11px-12px`,不再渲染步骤列表或步骤详情。
|
- 生成页只展示半透明“当前步骤”单卡,卡片内只保留步骤名称、步骤状态、步骤进度条和轻量加载指示;“当前步骤”标签使用 `10px-11px`,步骤名称使用 `14px-15px`,状态使用 `11px-12px`,不再渲染步骤列表或步骤详情。
|
||||||
- 当前作品信息放在圆角信息卡中,标题固定使用 `13px`;有结构化字段时以两列信息块展示,例如“题材 / 素材数量”,无结构化字段时才展示纯文本设定。
|
- 当前作品信息放在圆角信息卡中,标题固定使用 `13px`;有结构化字段时以两列信息块展示,例如“题材 / 素材数量”,无结构化字段时才展示纯文本设定。
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ export interface WoodenFishWorkProfileResponse {
|
|||||||
floatingWords: string[];
|
floatingWords: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WoodenFishWorksResponse {
|
||||||
|
items: WoodenFishWorkSummaryResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface WoodenFishWorkDetailResponse {
|
export interface WoodenFishWorkDetailResponse {
|
||||||
item: WoodenFishWorkProfileResponse;
|
item: WoodenFishWorkProfileResponse;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ use crate::{
|
|||||||
api_response::json_success_body,
|
api_response::json_success_body,
|
||||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||||
generated_asset_sheets::{
|
generated_asset_sheets::{
|
||||||
GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt,
|
apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte,
|
||||||
slice_generated_asset_sheet,
|
|
||||||
},
|
},
|
||||||
generated_image_assets::{
|
generated_image_assets::{
|
||||||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||||||
@@ -56,6 +55,15 @@ const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
|
|||||||
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
|
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
|
||||||
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
|
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
|
||||||
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
|
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
|
||||||
|
const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 2;
|
||||||
|
const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct JumpHopTileAtlasSlice {
|
||||||
|
tile_type: JumpHopTileType,
|
||||||
|
source_atlas_cell: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_jump_hop_session(
|
pub async fn create_jump_hop_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -379,7 +387,7 @@ pub async fn get_jump_hop_gallery_detail(
|
|||||||
async fn maybe_generate_jump_hop_assets(
|
async fn maybe_generate_jump_hop_assets(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
request_context: &RequestContext,
|
request_context: &RequestContext,
|
||||||
session_id: &str,
|
_session_id: &str,
|
||||||
owner_user_id: &str,
|
owner_user_id: &str,
|
||||||
payload: &mut JumpHopActionRequest,
|
payload: &mut JumpHopActionRequest,
|
||||||
) -> Result<(), Response> {
|
) -> Result<(), Response> {
|
||||||
@@ -457,21 +465,7 @@ async fn maybe_generate_jump_hop_assets(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let sheet_prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
|
let sheet_prompt = build_jump_hop_tile_atlas_prompt(tile_prompt);
|
||||||
subject_text: tile_prompt,
|
|
||||||
item_names: &vec![
|
|
||||||
"start".to_string(),
|
|
||||||
"normal".to_string(),
|
|
||||||
"target".to_string(),
|
|
||||||
"finish".to_string(),
|
|
||||||
"bonus".to_string(),
|
|
||||||
"accent".to_string(),
|
|
||||||
],
|
|
||||||
grid_size: 3,
|
|
||||||
item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视图"),
|
|
||||||
special_prompt: Some("每个格子对应一个 tile 类型,供跳一跳地块裁切使用。"),
|
|
||||||
})
|
|
||||||
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
|
||||||
let tile_generated = create_openai_image_generation(
|
let tile_generated = create_openai_image_generation(
|
||||||
&http_client,
|
&http_client,
|
||||||
&settings,
|
&settings,
|
||||||
@@ -494,19 +488,9 @@ async fn maybe_generate_jump_hop_assets(
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let tile_slices = slice_generated_asset_sheet(
|
let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| {
|
||||||
&tile_image,
|
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||||
&vec![
|
})?;
|
||||||
"start".to_string(),
|
|
||||||
"normal".to_string(),
|
|
||||||
"target".to_string(),
|
|
||||||
"finish".to_string(),
|
|
||||||
"bonus".to_string(),
|
|
||||||
"accent".to_string(),
|
|
||||||
],
|
|
||||||
3,
|
|
||||||
)
|
|
||||||
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
|
||||||
let tile_atlas_asset = persist_jump_hop_generated_image_asset(
|
let tile_atlas_asset = persist_jump_hop_generated_image_asset(
|
||||||
state,
|
state,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
@@ -520,28 +504,20 @@ async fn maybe_generate_jump_hop_assets(
|
|||||||
request_context,
|
request_context,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let tile_assets = tile_slices
|
let mut tile_assets = Vec::with_capacity(tile_slices.len());
|
||||||
.into_iter()
|
for (index, tile_slice) in tile_slices.into_iter().enumerate() {
|
||||||
.enumerate()
|
tile_assets.push(
|
||||||
.map(|(index, row)| JumpHopTileAsset {
|
persist_jump_hop_tile_asset(
|
||||||
tile_type: match index {
|
state,
|
||||||
0 => JumpHopTileType::Start,
|
owner_user_id,
|
||||||
1 => JumpHopTileType::Normal,
|
profile_id.as_str(),
|
||||||
2 => JumpHopTileType::Target,
|
index,
|
||||||
3 => JumpHopTileType::Finish,
|
tile_slice,
|
||||||
4 => JumpHopTileType::Bonus,
|
request_context,
|
||||||
_ => JumpHopTileType::Accent,
|
)
|
||||||
},
|
.await?,
|
||||||
image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
|
);
|
||||||
image_object_key: format!("generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
|
}
|
||||||
asset_object_id: format!("{profile_id}-tile-{index}-object"),
|
|
||||||
source_atlas_cell: format!("cell-{index}"),
|
|
||||||
visual_width: 256,
|
|
||||||
visual_height: 192,
|
|
||||||
top_surface_radius: 42.0,
|
|
||||||
landing_radius: 34.0,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
payload.character_asset = Some(character_asset);
|
payload.character_asset = Some(character_asset);
|
||||||
payload.tile_atlas_asset = Some(tile_atlas_asset);
|
payload.tile_atlas_asset = Some(tile_atlas_asset);
|
||||||
payload.tile_assets = Some(tile_assets);
|
payload.tile_assets = Some(tile_assets);
|
||||||
@@ -553,6 +529,153 @@ async fn maybe_generate_jump_hop_assets(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_jump_hop_tile_atlas_prompt(tile_prompt: &str) -> String {
|
||||||
|
let subject_text = tile_prompt.trim();
|
||||||
|
let subject_text = if subject_text.is_empty() {
|
||||||
|
"等距立体地块图集"
|
||||||
|
} else {
|
||||||
|
subject_text
|
||||||
|
};
|
||||||
|
let cell_plan = [
|
||||||
|
"第1行第1列:start 起点地块",
|
||||||
|
"第1行第2列:normal 普通地块",
|
||||||
|
"第1行第3列:target 目标地块",
|
||||||
|
"第2行第1列:finish 终点地块",
|
||||||
|
"第2行第2列:bonus 奖励地块",
|
||||||
|
"第2行第3列:accent 视觉强调地块",
|
||||||
|
]
|
||||||
|
.join(";");
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"生成一张1:1图片。固定生成2行*3列的跳一跳地块素材图集,画面是{subject_text}。严格按六个单元格排布:{cell_plan}。每个单元格只放一个完整等距/俯视角 2D 地块,必须表现顶面、侧面厚度和统一投影,光向一致,地块主体居中且四周保留留白。每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若材质天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子。不要出现文字、水印、UI、边框、网格线、标签、角色或场景。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slice_jump_hop_tile_atlas(
|
||||||
|
image: &crate::openai_image_generation::DownloadedOpenAiImage,
|
||||||
|
) -> Result<Vec<JumpHopTileAtlasSlice>, AppError> {
|
||||||
|
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
"message": format!("跳一跳地块图集解码失败:{error}"),
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let source = apply_generated_asset_sheet_green_screen_alpha(source);
|
||||||
|
let width = source.width();
|
||||||
|
let height = source.height();
|
||||||
|
let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS;
|
||||||
|
let cell_height = height / JUMP_HOP_TILE_ATLAS_ROWS;
|
||||||
|
if cell_width == 0 || cell_height == 0 {
|
||||||
|
return Err(
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
"message": "跳一跳地块图集尺寸过小,无法切割。",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_NAMES.len());
|
||||||
|
for index in 0..JUMP_HOP_TILE_ITEM_NAMES.len() {
|
||||||
|
let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
|
||||||
|
let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS;
|
||||||
|
let x0 = col.saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS;
|
||||||
|
let x1 = (col.saturating_add(1)).saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS;
|
||||||
|
let y0 = row.saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
|
||||||
|
let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
|
||||||
|
let cropped = source.crop_imm(
|
||||||
|
x0,
|
||||||
|
y0,
|
||||||
|
x1.saturating_sub(x0).max(1),
|
||||||
|
y1.saturating_sub(y0).max(1),
|
||||||
|
);
|
||||||
|
let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
|
||||||
|
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||||
|
cleaned
|
||||||
|
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
"message": format!("跳一跳地块图集切割失败:{error}"),
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
slices.push(JumpHopTileAtlasSlice {
|
||||||
|
tile_type: jump_hop_tile_type_by_index(index),
|
||||||
|
source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1),
|
||||||
|
bytes: cursor.into_inner(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(slices)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType {
|
||||||
|
match index {
|
||||||
|
0 => JumpHopTileType::Start,
|
||||||
|
1 => JumpHopTileType::Normal,
|
||||||
|
2 => JumpHopTileType::Target,
|
||||||
|
3 => JumpHopTileType::Finish,
|
||||||
|
4 => JumpHopTileType::Bonus,
|
||||||
|
_ => JumpHopTileType::Accent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jump_hop_tile_asset_slot_name(tile_type: &JumpHopTileType) -> &'static str {
|
||||||
|
match tile_type {
|
||||||
|
JumpHopTileType::Start => "tile-start",
|
||||||
|
JumpHopTileType::Normal => "tile-normal",
|
||||||
|
JumpHopTileType::Target => "tile-target",
|
||||||
|
JumpHopTileType::Finish => "tile-finish",
|
||||||
|
JumpHopTileType::Bonus => "tile-bonus",
|
||||||
|
JumpHopTileType::Accent => "tile-accent",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn persist_jump_hop_tile_asset(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
profile_id: &str,
|
||||||
|
tile_index: usize,
|
||||||
|
tile_slice: JumpHopTileAtlasSlice,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
) -> Result<JumpHopTileAsset, Response> {
|
||||||
|
let slot = jump_hop_tile_asset_slot_name(&tile_slice.tile_type);
|
||||||
|
let image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||||||
|
bytes: tile_slice.bytes,
|
||||||
|
mime_type: "image/png".to_string(),
|
||||||
|
extension: "png".to_string(),
|
||||||
|
};
|
||||||
|
let persisted = persist_jump_hop_generated_image_asset(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
|
profile_id,
|
||||||
|
slot,
|
||||||
|
&format!(
|
||||||
|
"跳一跳地块切片 {}:{}",
|
||||||
|
tile_index + 1,
|
||||||
|
tile_slice.source_atlas_cell
|
||||||
|
),
|
||||||
|
image,
|
||||||
|
LegacyAssetPrefix::JumpHopAssets,
|
||||||
|
256,
|
||||||
|
192,
|
||||||
|
request_context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(JumpHopTileAsset {
|
||||||
|
tile_type: tile_slice.tile_type,
|
||||||
|
image_src: persisted.image_src,
|
||||||
|
image_object_key: persisted.image_object_key,
|
||||||
|
asset_object_id: persisted.asset_object_id,
|
||||||
|
source_atlas_cell: tile_slice.source_atlas_cell,
|
||||||
|
visual_width: 256,
|
||||||
|
visual_height: 192,
|
||||||
|
top_surface_radius: 42.0,
|
||||||
|
landing_radius: 34.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn persist_jump_hop_generated_image_asset(
|
async fn persist_jump_hop_generated_image_asset(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
owner_user_id: &str,
|
owner_user_id: &str,
|
||||||
@@ -882,3 +1005,71 @@ fn current_utc_micros() -> i64 {
|
|||||||
.map(|duration| duration.as_micros().min(i64::MAX as u128) as i64)
|
.map(|duration| duration.as_micros().min(i64::MAX as u128) as i64)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jump_hop_tile_atlas_prompt_uses_dedicated_two_by_three_layout() {
|
||||||
|
let prompt = build_jump_hop_tile_atlas_prompt("森林石块风格等距地块");
|
||||||
|
|
||||||
|
assert!(prompt.contains("2行*3列"));
|
||||||
|
assert!(prompt.contains("第1行第1列:start 起点地块"));
|
||||||
|
assert!(prompt.contains("第2行第3列:accent 视觉强调地块"));
|
||||||
|
assert!(!prompt.contains("每个物品生成"));
|
||||||
|
assert!(!prompt.contains("不同视图"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jump_hop_tile_atlas_slices_one_png_per_tile_type() {
|
||||||
|
let width = 300;
|
||||||
|
let height = 200;
|
||||||
|
let colors = [
|
||||||
|
[220, 24, 24, 255],
|
||||||
|
[240, 150, 32, 255],
|
||||||
|
[248, 220, 72, 255],
|
||||||
|
[52, 168, 84, 255],
|
||||||
|
[38, 132, 255, 255],
|
||||||
|
[156, 92, 220, 255],
|
||||||
|
];
|
||||||
|
let mut atlas = image::RgbaImage::new(width, height);
|
||||||
|
for row in 0..2 {
|
||||||
|
for col in 0..3 {
|
||||||
|
let color = image::Rgba(colors[row * 3 + col]);
|
||||||
|
for y in row as u32 * 100..(row as u32 + 1) * 100 {
|
||||||
|
for x in col as u32 * 100..(col as u32 + 1) * 100 {
|
||||||
|
atlas.put_pixel(x, y, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||||
|
image::DynamicImage::ImageRgba8(atlas)
|
||||||
|
.write_to(&mut encoded, image::ImageFormat::Png)
|
||||||
|
.expect("atlas should encode");
|
||||||
|
let image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||||||
|
bytes: encoded.into_inner(),
|
||||||
|
mime_type: "image/png".to_string(),
|
||||||
|
extension: "png".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
|
||||||
|
|
||||||
|
assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_NAMES.len());
|
||||||
|
for (index, slice) in slices.iter().enumerate() {
|
||||||
|
assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index));
|
||||||
|
assert_eq!(
|
||||||
|
slice.source_atlas_cell,
|
||||||
|
format!("row-{}-col-{}", index / 3 + 1, index % 3 + 1)
|
||||||
|
);
|
||||||
|
let decoded = image::load_from_memory(slice.bytes.as_slice())
|
||||||
|
.expect("tile slice should decode")
|
||||||
|
.to_rgba8();
|
||||||
|
assert!(
|
||||||
|
decoded.pixels().any(|pixel| pixel.0 == colors[index]),
|
||||||
|
"第 {index} 个地块切片应保留对应格子的主体颜色"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use crate::{
|
|||||||
wooden_fish::{
|
wooden_fish::{
|
||||||
checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
|
checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
|
||||||
finish_wooden_fish_run, get_wooden_fish_gallery_detail, get_wooden_fish_runtime_work,
|
finish_wooden_fish_run, get_wooden_fish_gallery_detail, get_wooden_fish_runtime_work,
|
||||||
get_wooden_fish_session, list_wooden_fish_gallery, publish_wooden_fish_work,
|
get_wooden_fish_session, list_wooden_fish_gallery, list_wooden_fish_works,
|
||||||
start_wooden_fish_run,
|
publish_wooden_fish_work, start_wooden_fish_run,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,6 +37,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
|||||||
require_bearer_auth,
|
require_bearer_auth,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/creation/wooden-fish/works",
|
||||||
|
get(list_wooden_fish_works).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/creation/wooden-fish/works/{profile_id}/publish",
|
"/api/creation/wooden-fish/works/{profile_id}/publish",
|
||||||
post(publish_wooden_fish_work).route_layer(middleware::from_fn_with_state(
|
post(publish_wooden_fish_work).route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use shared_contracts::wooden_fish::{
|
|||||||
WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse,
|
WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse,
|
||||||
WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest,
|
WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest,
|
||||||
WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest,
|
WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest,
|
||||||
|
WoodenFishWorksResponse,
|
||||||
};
|
};
|
||||||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||||
use spacetime_client::SpacetimeClientError;
|
use spacetime_client::SpacetimeClientError;
|
||||||
@@ -193,6 +194,31 @@ pub async fn publish_wooden_fish_work(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_wooden_fish_works(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
let works = state
|
||||||
|
.spacetime_client()
|
||||||
|
.list_wooden_fish_works(authenticated.claims().user_id().to_string())
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
wooden_fish_error_response(
|
||||||
|
&request_context,
|
||||||
|
WOODEN_FISH_CREATION_PROVIDER,
|
||||||
|
map_wooden_fish_client_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(json_success_body(
|
||||||
|
Some(&request_context),
|
||||||
|
WoodenFishWorksResponse {
|
||||||
|
items: works.into_iter().map(|work| work.summary).collect(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_wooden_fish_runtime_work(
|
pub async fn get_wooden_fish_runtime_work(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(profile_id): Path<String>,
|
Path(profile_id): Path<String>,
|
||||||
|
|||||||
@@ -203,6 +203,12 @@ pub struct WoodenFishWorkProfileResponse {
|
|||||||
pub floating_words: Vec<String>,
|
pub floating_words: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WoodenFishWorksResponse {
|
||||||
|
pub items: Vec<WoodenFishWorkSummaryResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct WoodenFishWorkDetailResponse {
|
pub struct WoodenFishWorkDetailResponse {
|
||||||
|
|||||||
@@ -131,7 +131,10 @@ describe('CustomWorldGenerationView', () => {
|
|||||||
'justify-start',
|
'justify-start',
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
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('总进度').className).toContain('text-[9px]');
|
expect(screen.getByText('总进度').className).toContain('text-[9px]');
|
||||||
expect(screen.getByText('42%').className).toContain('text-[1.15rem]');
|
expect(screen.getByText('42%').className).toContain('text-[1.15rem]');
|
||||||
@@ -149,7 +152,7 @@ describe('CustomWorldGenerationView', () => {
|
|||||||
screen
|
screen
|
||||||
.getByRole('progressbar', { name: progressTitle })
|
.getByRole('progressbar', { name: progressTitle })
|
||||||
.getAttribute('data-ring-start-degrees'),
|
.getAttribute('data-ring-start-degrees'),
|
||||||
).toBe('225');
|
).toBe('155');
|
||||||
expect(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getByRole('progressbar', { name: progressTitle })
|
.getByRole('progressbar', { name: progressTitle })
|
||||||
@@ -168,6 +171,9 @@ describe('CustomWorldGenerationView', () => {
|
|||||||
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
|
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
|
||||||
'svg',
|
'svg',
|
||||||
);
|
);
|
||||||
|
expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain(
|
||||||
|
'z-0',
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getByTestId('generation-hero-progress-ring')
|
.getByTestId('generation-hero-progress-ring')
|
||||||
@@ -183,6 +189,16 @@ describe('CustomWorldGenerationView', () => {
|
|||||||
.getByTestId('generation-hero-progress-ring-track')
|
.getByTestId('generation-hero-progress-ring-track')
|
||||||
.getAttribute('stroke-width'),
|
.getAttribute('stroke-width'),
|
||||||
).toBe('18');
|
).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(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getByTestId('generation-hero-progress-ring-fill')
|
.getByTestId('generation-hero-progress-ring-fill')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useId, useRef } from 'react';
|
|||||||
|
|
||||||
import generationHeroVideo from '../../media/create_bg_video.mp4';
|
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_SWEEP_DEGREES = 270;
|
||||||
const GENERATION_PROGRESS_RING_VIEWBOX = 400;
|
const GENERATION_PROGRESS_RING_VIEWBOX = 400;
|
||||||
const GENERATION_PROGRESS_RING_CENTER = GENERATION_PROGRESS_RING_VIEWBOX / 2;
|
const GENERATION_PROGRESS_RING_CENTER = GENERATION_PROGRESS_RING_VIEWBOX / 2;
|
||||||
@@ -173,7 +173,7 @@ export function GenerationProgressHero({
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
data-testid="generation-hero-progress-ring"
|
data-testid="generation-hero-progress-ring"
|
||||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
className="pointer-events-none absolute inset-0 z-0 h-full w-full"
|
||||||
viewBox={`0 0 ${GENERATION_PROGRESS_RING_VIEWBOX} ${GENERATION_PROGRESS_RING_VIEWBOX}`}
|
viewBox={`0 0 ${GENERATION_PROGRESS_RING_VIEWBOX} ${GENERATION_PROGRESS_RING_VIEWBOX}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
@@ -220,13 +220,13 @@ export function GenerationProgressHero({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div
|
<div
|
||||||
className="relative z-10 flex h-full w-full flex-col items-center justify-start pt-[4%] text-center sm:pt-[3%]"
|
className="relative z-30 flex h-full w-full flex-col items-center justify-start pt-[2%] text-center sm:pt-[1.5%]"
|
||||||
data-testid="generation-hero-progress-content"
|
data-testid="generation-hero-progress-content"
|
||||||
>
|
>
|
||||||
<div className="text-[9px] font-black tracking-[0.16em] text-[#7f441f] sm:text-[10px]">
|
<div className="relative z-30 text-[9px] font-black tracking-[0.16em] text-[#7f441f] sm:text-[10px]">
|
||||||
总进度
|
总进度
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[1.15rem] font-black leading-none text-[#e45e14] sm:mt-1.5 sm:text-[1.9rem]">
|
<div className="relative z-30 mt-1 text-[1.15rem] font-black leading-none text-[#e45e14] sm:mt-1.5 sm:text-[1.9rem]">
|
||||||
{safeProgress}%
|
{safeProgress}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ describe('BarkBattleGeneratingView', () => {
|
|||||||
'justify-start',
|
'justify-start',
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
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('玩家形象')).toBeTruthy();
|
||||||
expect(screen.getByText('进行中 36%')).toBeTruthy();
|
expect(screen.getByText('进行中 36%')).toBeTruthy();
|
||||||
@@ -142,7 +145,7 @@ describe('BarkBattleGeneratingView', () => {
|
|||||||
screen
|
screen
|
||||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||||
.getAttribute('data-ring-start-degrees'),
|
.getAttribute('data-ring-start-degrees'),
|
||||||
).toBe('225');
|
).toBe('155');
|
||||||
expect(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||||
@@ -161,6 +164,9 @@ describe('BarkBattleGeneratingView', () => {
|
|||||||
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
|
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
|
||||||
'svg',
|
'svg',
|
||||||
);
|
);
|
||||||
|
expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain(
|
||||||
|
'z-0',
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getByTestId('generation-hero-progress-ring')
|
.getByTestId('generation-hero-progress-ring')
|
||||||
@@ -176,6 +182,16 @@ describe('BarkBattleGeneratingView', () => {
|
|||||||
.getByTestId('generation-hero-progress-ring-track')
|
.getByTestId('generation-hero-progress-ring-track')
|
||||||
.getAttribute('stroke-width'),
|
.getAttribute('stroke-width'),
|
||||||
).toBe('18');
|
).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(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getByTestId('generation-hero-progress-ring-fill')
|
.getByTestId('generation-hero-progress-ring-fill')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contract
|
|||||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
|
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
@@ -64,6 +65,9 @@ type CustomWorldCreationHubProps = {
|
|||||||
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
||||||
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
||||||
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
|
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
|
||||||
|
woodenFishItems?: WoodenFishWorkSummaryResponse[];
|
||||||
|
onOpenWoodenFishDetail?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
|
||||||
|
onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
|
||||||
puzzleItems?: PuzzleWorkSummary[];
|
puzzleItems?: PuzzleWorkSummary[];
|
||||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||||
@@ -174,6 +178,9 @@ export function CustomWorldCreationHub({
|
|||||||
jumpHopItems = [],
|
jumpHopItems = [],
|
||||||
onOpenJumpHopDetail,
|
onOpenJumpHopDetail,
|
||||||
onDeleteJumpHop = null,
|
onDeleteJumpHop = null,
|
||||||
|
woodenFishItems = [],
|
||||||
|
onOpenWoodenFishDetail = null,
|
||||||
|
onDeleteWoodenFish = null,
|
||||||
puzzleItems = [],
|
puzzleItems = [],
|
||||||
onOpenPuzzleDetail,
|
onOpenPuzzleDetail,
|
||||||
onDeletePuzzle = null,
|
onDeletePuzzle = null,
|
||||||
@@ -207,6 +214,7 @@ export function CustomWorldCreationHub({
|
|||||||
match3dItems,
|
match3dItems,
|
||||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
|
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
|
||||||
jumpHopItems,
|
jumpHopItems,
|
||||||
|
woodenFishItems,
|
||||||
puzzleItems,
|
puzzleItems,
|
||||||
babyObjectMatchItems,
|
babyObjectMatchItems,
|
||||||
barkBattleItems,
|
barkBattleItems,
|
||||||
@@ -217,6 +225,7 @@ export function CustomWorldCreationHub({
|
|||||||
canDeleteSquareHole:
|
canDeleteSquareHole:
|
||||||
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
|
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
|
||||||
canDeleteJumpHop: Boolean(onDeleteJumpHop),
|
canDeleteJumpHop: Boolean(onDeleteJumpHop),
|
||||||
|
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
|
||||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||||
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
|
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
|
||||||
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
|
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
|
||||||
@@ -232,6 +241,8 @@ export function CustomWorldCreationHub({
|
|||||||
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
|
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
|
||||||
onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined,
|
onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined,
|
||||||
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
|
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
|
||||||
|
onOpenWoodenFishDetail: onOpenWoodenFishDetail ?? undefined,
|
||||||
|
onDeleteWoodenFish: onDeleteWoodenFish ?? undefined,
|
||||||
onOpenPuzzleDetail,
|
onOpenPuzzleDetail,
|
||||||
onDeletePuzzle: onDeletePuzzle ?? undefined,
|
onDeletePuzzle: onDeletePuzzle ?? undefined,
|
||||||
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
|
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
|
||||||
@@ -259,6 +270,7 @@ export function CustomWorldCreationHub({
|
|||||||
onDeleteBarkBattle,
|
onDeleteBarkBattle,
|
||||||
onDeleteVisualNovel,
|
onDeleteVisualNovel,
|
||||||
onDeleteJumpHop,
|
onDeleteJumpHop,
|
||||||
|
onDeleteWoodenFish,
|
||||||
onClaimPuzzlePointIncentive,
|
onClaimPuzzlePointIncentive,
|
||||||
onOpenBigFishDetail,
|
onOpenBigFishDetail,
|
||||||
onOpenDraft,
|
onOpenDraft,
|
||||||
@@ -268,6 +280,7 @@ export function CustomWorldCreationHub({
|
|||||||
onOpenPuzzleDetail,
|
onOpenPuzzleDetail,
|
||||||
onOpenSquareHoleDetail,
|
onOpenSquareHoleDetail,
|
||||||
onOpenVisualNovelDetail,
|
onOpenVisualNovelDetail,
|
||||||
|
onOpenWoodenFishDetail,
|
||||||
onEnterPublished,
|
onEnterPublished,
|
||||||
getWorkState,
|
getWorkState,
|
||||||
puzzleItems,
|
puzzleItems,
|
||||||
@@ -275,6 +288,7 @@ export function CustomWorldCreationHub({
|
|||||||
onOpenSquareHoleDetail,
|
onOpenSquareHoleDetail,
|
||||||
onOpenJumpHopDetail,
|
onOpenJumpHopDetail,
|
||||||
jumpHopItems,
|
jumpHopItems,
|
||||||
|
woodenFishItems,
|
||||||
visualNovelItems,
|
visualNovelItems,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -325,6 +339,9 @@ export function CustomWorldCreationHub({
|
|||||||
case 'jump-hop':
|
case 'jump-hop':
|
||||||
onOpenJumpHopDetail?.(item.source.item);
|
onOpenJumpHopDetail?.(item.source.item);
|
||||||
return;
|
return;
|
||||||
|
case 'wooden-fish':
|
||||||
|
onOpenWoodenFishDetail?.(item.source.item);
|
||||||
|
return;
|
||||||
case 'rpg':
|
case 'rpg':
|
||||||
if (item.status === 'draft') {
|
if (item.status === 'draft') {
|
||||||
onOpenDraft(item.source.item);
|
onOpenDraft(item.source.item);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
|
|||||||
match3d: '/creation-type-references/match3d.webp',
|
match3d: '/creation-type-references/match3d.webp',
|
||||||
'square-hole': '/creation-type-references/square-hole.webp',
|
'square-hole': '/creation-type-references/square-hole.webp',
|
||||||
'jump-hop': '/creation-type-references/jump-hop.webp',
|
'jump-hop': '/creation-type-references/jump-hop.webp',
|
||||||
|
'wooden-fish': '/wooden-fish/default-hit-object.png',
|
||||||
puzzle: '/creation-type-references/puzzle.webp',
|
puzzle: '/creation-type-references/puzzle.webp',
|
||||||
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
||||||
'bark-battle': '/creation-type-references/bark-battle.webp',
|
'bark-battle': '/creation-type-references/bark-battle.webp',
|
||||||
|
|||||||
@@ -56,6 +56,47 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
|
|||||||
expect(items[1]?.publicWorkCode).toBeNull();
|
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', () => {
|
test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => {
|
||||||
const items = buildCreationWorkShelfItems({
|
const items = buildCreationWorkShelfItems({
|
||||||
rpgItems: [],
|
rpgItems: [],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contr
|
|||||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
|
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||||
import {
|
import {
|
||||||
buildBabyObjectMatchPublicWorkCode,
|
buildBabyObjectMatchPublicWorkCode,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
buildPuzzlePublicWorkCode,
|
buildPuzzlePublicWorkCode,
|
||||||
buildSquareHolePublicWorkCode,
|
buildSquareHolePublicWorkCode,
|
||||||
buildVisualNovelPublicWorkCode,
|
buildVisualNovelPublicWorkCode,
|
||||||
|
buildWoodenFishPublicWorkCode,
|
||||||
} from '../../services/publicWorkCode';
|
} from '../../services/publicWorkCode';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ export type CreationWorkShelfKind =
|
|||||||
| 'match3d'
|
| 'match3d'
|
||||||
| 'square-hole'
|
| 'square-hole'
|
||||||
| 'jump-hop'
|
| 'jump-hop'
|
||||||
|
| 'wooden-fish'
|
||||||
| 'puzzle'
|
| 'puzzle'
|
||||||
| 'baby-object-match'
|
| 'baby-object-match'
|
||||||
| 'bark-battle'
|
| 'bark-battle'
|
||||||
@@ -90,6 +93,10 @@ export type CreationWorkShelfSource =
|
|||||||
kind: 'jump-hop';
|
kind: 'jump-hop';
|
||||||
item: JumpHopWorkSummaryResponse;
|
item: JumpHopWorkSummaryResponse;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
kind: 'wooden-fish';
|
||||||
|
item: WoodenFishWorkSummaryResponse;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'puzzle';
|
kind: 'puzzle';
|
||||||
item: PuzzleWorkSummary;
|
item: PuzzleWorkSummary;
|
||||||
@@ -145,6 +152,7 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
match3dItems?: Match3DWorkSummary[];
|
match3dItems?: Match3DWorkSummary[];
|
||||||
squareHoleItems?: SquareHoleWorkSummary[];
|
squareHoleItems?: SquareHoleWorkSummary[];
|
||||||
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
||||||
|
woodenFishItems?: WoodenFishWorkSummaryResponse[];
|
||||||
puzzleItems: PuzzleWorkSummary[];
|
puzzleItems: PuzzleWorkSummary[];
|
||||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||||
barkBattleItems?: BarkBattleWorkSummary[];
|
barkBattleItems?: BarkBattleWorkSummary[];
|
||||||
@@ -154,6 +162,7 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
canDeleteMatch3D?: boolean;
|
canDeleteMatch3D?: boolean;
|
||||||
canDeleteSquareHole?: boolean;
|
canDeleteSquareHole?: boolean;
|
||||||
canDeleteJumpHop?: boolean;
|
canDeleteJumpHop?: boolean;
|
||||||
|
canDeleteWoodenFish?: boolean;
|
||||||
canDeletePuzzle?: boolean;
|
canDeletePuzzle?: boolean;
|
||||||
canDeleteBabyObjectMatch?: boolean;
|
canDeleteBabyObjectMatch?: boolean;
|
||||||
canDeleteBarkBattle?: boolean;
|
canDeleteBarkBattle?: boolean;
|
||||||
@@ -169,6 +178,8 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void;
|
onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void;
|
||||||
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
||||||
onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void;
|
onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void;
|
||||||
|
onOpenWoodenFishDetail?: (item: WoodenFishWorkSummaryResponse) => void;
|
||||||
|
onDeleteWoodenFish?: (item: WoodenFishWorkSummaryResponse) => void;
|
||||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||||
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
|
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
|
||||||
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
|
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
|
||||||
@@ -189,6 +200,7 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
match3dItems = [],
|
match3dItems = [],
|
||||||
squareHoleItems = [],
|
squareHoleItems = [],
|
||||||
jumpHopItems = [],
|
jumpHopItems = [],
|
||||||
|
woodenFishItems = [],
|
||||||
puzzleItems,
|
puzzleItems,
|
||||||
babyObjectMatchItems = [],
|
babyObjectMatchItems = [],
|
||||||
barkBattleItems = [],
|
barkBattleItems = [],
|
||||||
@@ -198,6 +210,7 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
canDeleteMatch3D = false,
|
canDeleteMatch3D = false,
|
||||||
canDeleteSquareHole = false,
|
canDeleteSquareHole = false,
|
||||||
canDeleteJumpHop = false,
|
canDeleteJumpHop = false,
|
||||||
|
canDeleteWoodenFish = false,
|
||||||
canDeletePuzzle = false,
|
canDeletePuzzle = false,
|
||||||
canDeleteBabyObjectMatch = false,
|
canDeleteBabyObjectMatch = false,
|
||||||
canDeleteBarkBattle = false,
|
canDeleteBarkBattle = false,
|
||||||
@@ -213,6 +226,8 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
onDeleteSquareHole,
|
onDeleteSquareHole,
|
||||||
onOpenJumpHopDetail,
|
onOpenJumpHopDetail,
|
||||||
onDeleteJumpHop,
|
onDeleteJumpHop,
|
||||||
|
onOpenWoodenFishDetail,
|
||||||
|
onDeleteWoodenFish,
|
||||||
onOpenPuzzleDetail,
|
onOpenPuzzleDetail,
|
||||||
onDeletePuzzle,
|
onDeletePuzzle,
|
||||||
onClaimPuzzlePointIncentive,
|
onClaimPuzzlePointIncentive,
|
||||||
@@ -257,6 +272,12 @@ export function buildCreationWorkShelfItems(params: {
|
|||||||
onDelete: onDeleteJumpHop,
|
onDelete: onDeleteJumpHop,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
...woodenFishItems.map((item) =>
|
||||||
|
mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, {
|
||||||
|
onOpen: onOpenWoodenFishDetail,
|
||||||
|
onDelete: onDeleteWoodenFish,
|
||||||
|
}),
|
||||||
|
),
|
||||||
...puzzleItems.map((item) =>
|
...puzzleItems.map((item) =>
|
||||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
|
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
|
||||||
onOpen: onOpenPuzzleDetail,
|
onOpen: onOpenPuzzleDetail,
|
||||||
@@ -815,6 +836,54 @@ function mapJumpHopWorkToShelfItem(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapWoodenFishWorkToShelfItem(
|
||||||
|
item: WoodenFishWorkSummaryResponse,
|
||||||
|
canDelete: boolean,
|
||||||
|
adapter: WorkShelfAdapter<WoodenFishWorkSummaryResponse>,
|
||||||
|
): 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(
|
function resolveAuthorDisplayName(
|
||||||
...sources: Array<unknown>
|
...sources: Array<unknown>
|
||||||
@@ -1026,6 +1095,8 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
|
|||||||
return item.source.item.generationStatus === 'generating';
|
return item.source.item.generationStatus === 'generating';
|
||||||
case 'puzzle':
|
case 'puzzle':
|
||||||
return isPersistedPuzzleDraftGenerating(item.source.item);
|
return isPersistedPuzzleDraftGenerating(item.source.item);
|
||||||
|
case 'wooden-fish':
|
||||||
|
return item.source.item.generationStatus === 'generating';
|
||||||
case 'bark-battle':
|
case 'bark-battle':
|
||||||
return isPersistedBarkBattleDraftGenerating(item.source.item);
|
return isPersistedBarkBattleDraftGenerating(item.source.item);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ import {
|
|||||||
type WoodenFishWorkProfileResponse,
|
type WoodenFishWorkProfileResponse,
|
||||||
type WoodenFishWorkspaceCreateRequest,
|
type WoodenFishWorkspaceCreateRequest,
|
||||||
} from '../../services/wooden-fish/woodenFishClient';
|
} from '../../services/wooden-fish/woodenFishClient';
|
||||||
|
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { PublishShareModal } from '../common/PublishShareModal';
|
import { PublishShareModal } from '../common/PublishShareModal';
|
||||||
@@ -2384,6 +2385,15 @@ function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] {
|
|||||||
item.source.item.workId,
|
item.source.item.workId,
|
||||||
item.source.item.draftId,
|
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 [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3173,6 +3183,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
>(null);
|
>(null);
|
||||||
const [woodenFishWork, setWoodenFishWork] =
|
const [woodenFishWork, setWoodenFishWork] =
|
||||||
useState<WoodenFishWorkProfileResponse | null>(null);
|
useState<WoodenFishWorkProfileResponse | null>(null);
|
||||||
|
const [woodenFishWorks, setWoodenFishWorks] = useState<
|
||||||
|
WoodenFishWorkSummaryResponse[]
|
||||||
|
>([]);
|
||||||
const [woodenFishGalleryEntries, setWoodenFishGalleryEntries] = useState<
|
const [woodenFishGalleryEntries, setWoodenFishGalleryEntries] = useState<
|
||||||
WoodenFishGalleryCardResponse[]
|
WoodenFishGalleryCardResponse[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -3920,6 +3933,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 () => {
|
const refreshPuzzleShelf = useCallback(async () => {
|
||||||
setIsPuzzleLoadingLibrary(true);
|
setIsPuzzleLoadingLibrary(true);
|
||||||
|
|
||||||
@@ -4499,6 +4526,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
],
|
],
|
||||||
[jumpHopWorks, pendingDraftShelfItems],
|
[jumpHopWorks, pendingDraftShelfItems],
|
||||||
);
|
);
|
||||||
|
const woodenFishShelfItems = useMemo(
|
||||||
|
() => woodenFishWorks,
|
||||||
|
[woodenFishWorks],
|
||||||
|
);
|
||||||
const match3dShelfItems = useMemo(
|
const match3dShelfItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks),
|
...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks),
|
||||||
@@ -4581,6 +4612,13 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
item.sourceSessionId,
|
item.sourceSessionId,
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
...woodenFishShelfItems.flatMap((item) =>
|
||||||
|
collectDraftNoticeKeys('wooden-fish', [
|
||||||
|
item.workId,
|
||||||
|
item.profileId,
|
||||||
|
item.sourceSessionId,
|
||||||
|
]),
|
||||||
|
),
|
||||||
...match3dShelfItems.flatMap((item) =>
|
...match3dShelfItems.flatMap((item) =>
|
||||||
collectDraftNoticeKeys('match3d', [
|
collectDraftNoticeKeys('match3d', [
|
||||||
item.workId,
|
item.workId,
|
||||||
@@ -4624,6 +4662,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
barkBattleShelfItems,
|
barkBattleShelfItems,
|
||||||
bigFishShelfItems,
|
bigFishShelfItems,
|
||||||
jumpHopShelfItems,
|
jumpHopShelfItems,
|
||||||
|
woodenFishShelfItems,
|
||||||
creationHubItems,
|
creationHubItems,
|
||||||
isSquareHoleCreationVisible,
|
isSquareHoleCreationVisible,
|
||||||
match3dShelfItems,
|
match3dShelfItems,
|
||||||
@@ -9088,6 +9127,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
try {
|
try {
|
||||||
const response = await woodenFishClient.publishWork(profileId);
|
const response = await woodenFishClient.publishWork(profileId);
|
||||||
setWoodenFishWork(response.item);
|
setWoodenFishWork(response.item);
|
||||||
|
void refreshWoodenFishShelf();
|
||||||
|
void refreshWoodenFishGallery();
|
||||||
openPublishShareModal({
|
openPublishShareModal({
|
||||||
title: response.item.summary.workTitle || '敲木鱼',
|
title: response.item.summary.workTitle || '敲木鱼',
|
||||||
publicWorkCode: buildWoodenFishPublicWorkCode(
|
publicWorkCode: buildWoodenFishPublicWorkCode(
|
||||||
@@ -9105,6 +9146,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
openPublishShareModal,
|
openPublishShareModal,
|
||||||
|
refreshWoodenFishGallery,
|
||||||
|
refreshWoodenFishShelf,
|
||||||
setSelectionStage,
|
setSelectionStage,
|
||||||
woodenFishWork?.summary.profileId,
|
woodenFishWork?.summary.profileId,
|
||||||
]);
|
]);
|
||||||
@@ -11750,6 +11793,48 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
[openPublicWorkDetail, setSelectionStage],
|
[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(
|
const openPublicGalleryDetail = useCallback(
|
||||||
(entry: PlatformPublicGalleryCard) => {
|
(entry: PlatformPublicGalleryCard) => {
|
||||||
if (isBigFishGalleryEntry(entry)) {
|
if (isBigFishGalleryEntry(entry)) {
|
||||||
@@ -14622,6 +14707,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
if (isVisualNovelCreationOpen) {
|
if (isVisualNovelCreationOpen) {
|
||||||
void refreshVisualNovelShelf();
|
void refreshVisualNovelShelf();
|
||||||
}
|
}
|
||||||
|
void refreshWoodenFishShelf();
|
||||||
void refreshBabyObjectMatchShelf();
|
void refreshBabyObjectMatchShelf();
|
||||||
void refreshBarkBattleShelf();
|
void refreshBarkBattleShelf();
|
||||||
}
|
}
|
||||||
@@ -14634,6 +14720,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
refreshBarkBattleShelf,
|
refreshBarkBattleShelf,
|
||||||
refreshMatch3DShelf,
|
refreshMatch3DShelf,
|
||||||
refreshPuzzleShelf,
|
refreshPuzzleShelf,
|
||||||
|
refreshWoodenFishShelf,
|
||||||
refreshSquareHoleShelf,
|
refreshSquareHoleShelf,
|
||||||
refreshVisualNovelShelf,
|
refreshVisualNovelShelf,
|
||||||
selectionStage,
|
selectionStage,
|
||||||
@@ -14728,6 +14815,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
void refreshSquareHoleShelf();
|
void refreshSquareHoleShelf();
|
||||||
}
|
}
|
||||||
void refreshPuzzleShelf();
|
void refreshPuzzleShelf();
|
||||||
|
void refreshWoodenFishShelf();
|
||||||
if (isVisualNovelCreationOpen) {
|
if (isVisualNovelCreationOpen) {
|
||||||
void refreshVisualNovelShelf();
|
void refreshVisualNovelShelf();
|
||||||
}
|
}
|
||||||
@@ -14781,6 +14869,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
|
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
|
||||||
bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []}
|
bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []}
|
||||||
jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []}
|
jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []}
|
||||||
|
woodenFishItems={woodenFishShelfItems}
|
||||||
onOpenBigFishDetail={
|
onOpenBigFishDetail={
|
||||||
isBigFishCreationVisible
|
isBigFishCreationVisible
|
||||||
? (item) => {
|
? (item) => {
|
||||||
@@ -14809,6 +14898,13 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onDeleteJumpHop={null}
|
onDeleteJumpHop={null}
|
||||||
|
onOpenWoodenFishDetail={(item) => {
|
||||||
|
runProtectedAction(() => {
|
||||||
|
markCreationFlowReturnToDraftShelf();
|
||||||
|
void openWoodenFishDraft(item);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDeleteWoodenFish={null}
|
||||||
match3dItems={match3dShelfItems}
|
match3dItems={match3dShelfItems}
|
||||||
onOpenMatch3DDetail={(item) => {
|
onOpenMatch3DDetail={(item) => {
|
||||||
runProtectedAction(() => {
|
runProtectedAction(() => {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { beforeEach, expect, test, vi } from 'vitest';
|
import { beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
const { createCreationAgentClientMock } = vi.hoisted(() => ({
|
const { createCreationAgentClientMock } = vi.hoisted(() => ({
|
||||||
createCreationAgentClientMock: vi.fn(),
|
createCreationAgentClientMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -9,7 +11,7 @@ vi.mock('../creation-agent', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../apiClient', () => ({
|
vi.mock('../apiClient', () => ({
|
||||||
requestJson: vi.fn(),
|
requestJson: requestJsonMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -22,6 +24,7 @@ beforeEach(() => {
|
|||||||
streamMessage: vi.fn(),
|
streamMessage: vi.fn(),
|
||||||
executeAction: vi.fn(),
|
executeAction: vi.fn(),
|
||||||
});
|
});
|
||||||
|
requestJsonMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wooden fish creation keeps image2 generation requests alive long enough', async () => {
|
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' },
|
||||||
|
'读取敲木鱼作品列表失败',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
WoodenFishWorkDetailResponse,
|
WoodenFishWorkDetailResponse,
|
||||||
WoodenFishWorkMutationResponse,
|
WoodenFishWorkMutationResponse,
|
||||||
WoodenFishWorkProfileResponse,
|
WoodenFishWorkProfileResponse,
|
||||||
|
WoodenFishWorksResponse,
|
||||||
WoodenFishWorkspaceCreateRequest,
|
WoodenFishWorkspaceCreateRequest,
|
||||||
WoodenFishWorkSummaryResponse,
|
WoodenFishWorkSummaryResponse,
|
||||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||||
@@ -57,6 +58,7 @@ export type {
|
|||||||
WoodenFishWorkDetailResponse,
|
WoodenFishWorkDetailResponse,
|
||||||
WoodenFishWorkMutationResponse,
|
WoodenFishWorkMutationResponse,
|
||||||
WoodenFishWorkProfileResponse,
|
WoodenFishWorkProfileResponse,
|
||||||
|
WoodenFishWorksResponse,
|
||||||
WoodenFishWorkspaceCreateRequest,
|
WoodenFishWorkspaceCreateRequest,
|
||||||
};
|
};
|
||||||
export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest;
|
export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest;
|
||||||
@@ -186,6 +188,15 @@ export async function getWoodenFishWorkDetail(profileId: string) {
|
|||||||
return normalizeWoodenFishWorkDetailResponse(response);
|
return normalizeWoodenFishWorkDetailResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listWoodenFishWorks() {
|
||||||
|
const response = await requestJson<WoodenFishWorksResponse>(
|
||||||
|
WOODEN_FISH_WORKS_API_BASE,
|
||||||
|
{ method: 'GET' },
|
||||||
|
'读取敲木鱼作品列表失败',
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listWoodenFishGallery() {
|
export async function listWoodenFishGallery() {
|
||||||
return requestJson<WoodenFishGalleryResponse>(
|
return requestJson<WoodenFishGalleryResponse>(
|
||||||
`${WOODEN_FISH_RUNTIME_API_BASE}/gallery`,
|
`${WOODEN_FISH_RUNTIME_API_BASE}/gallery`,
|
||||||
@@ -312,6 +323,7 @@ export const woodenFishClient = {
|
|||||||
getSession: getWoodenFishCreationSession,
|
getSession: getWoodenFishCreationSession,
|
||||||
getWorkDetail: getWoodenFishWorkDetail,
|
getWorkDetail: getWoodenFishWorkDetail,
|
||||||
listGallery: listWoodenFishGallery,
|
listGallery: listWoodenFishGallery,
|
||||||
|
listWorks: listWoodenFishWorks,
|
||||||
publishWork: publishWoodenFishWork,
|
publishWork: publishWoodenFishWork,
|
||||||
startRun: startWoodenFishRuntimeRun,
|
startRun: startWoodenFishRuntimeRun,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user