feat: 完善敲木鱼结果页元信息补录

This commit is contained in:
2026-05-24 20:34:36 +08:00
parent 8638397faa
commit 838c74d8fe
14 changed files with 757 additions and 215 deletions

View File

@@ -27,7 +27,7 @@
## 2026-05-22 敲木鱼图片创作采用三图 image2 链路
- 背景:敲木鱼自定义题材只生成中央敲击物时,运行态缺少与新主题匹配的竖屏背景和主题化返回按钮;若直接让背景 prompt 自由发挥,又容易把敲击物或木槌画进背景里。
- 决策:敲木鱼 `compile-draft` / `regenerate-hit-object` 图片链路固定为三步 image2 edits。第一步调用 VectorEngine `/v1/images/edits` + `gpt-image-2`,以默认木鱼图作为结构和画风参考,用户上传参考图只作为同次请求的新主题参考,结合用户题材关键词或参考图主题生成 `1:1` 绿色背景主体图;`api-server` 先对这张绿幕图执行去绿背景处理并写回 `hitObjectAsset`。第二步必须以第一步抠图完成后的透明敲击物图作为参考,结合用户原始题材生成 `9:16` 背景环境图并写回 `backgroundAsset`,避免背景图继承绿幕或纯绿色画布。第三步必须以去绿后的敲击物主体图和背景环境图为参考,生成 `1:1` 绿色背景返回按钮图,服务端去绿后写回 `backButtonAsset`。三步 prompt 使用 PRD 中固定隐藏关键词,不追加额外 negative prompt返回按钮只允许参考图约束圆形底色和箭头配色不允许继承复杂造型、花纹、浮雕边、异形外框或装饰图案背景图不得包含敲击物本体或木槌互动物品返回按钮图不得包含文字、数字、水印或额外 UI 面板。
- 决策:敲木鱼 `compile-draft` / `regenerate-hit-object` 图片链路固定为三步 image2 edits。第一步调用 VectorEngine `/v1/images/edits` + `gpt-image-2`,以默认木鱼图作为结构和画风参考,用户上传参考图只作为同次请求的新主题参考,结合用户题材关键词或参考图主题生成 `1:1` 绿色背景主体图;`api-server` 先对这张绿幕图执行去绿背景处理并写回 `hitObjectAsset`。第二步必须以第一步抠图完成后的透明敲击物图作为参考,结合用户原始题材生成 `9:16` 背景环境图并写回 `backgroundAsset`,避免背景图继承绿幕或纯绿色画布。第三步必须以去绿后的敲击物主体图和背景环境图为参考,生成 `1:1` 绿色背景返回按钮图,服务端去绿后写回 `backButtonAsset`。三步 prompt 使用 PRD 中固定隐藏关键词,不追加额外 negative prompt返回按钮只允许参考图约束圆形底色和箭头配色不允许继承复杂造型、花纹、浮雕边、异形外框或装饰图案,主体视觉尺寸比当前模板再放大约 50%,并带主题色外描边;背景图不得包含敲击物本体或木槌互动物品,返回按钮图不得包含文字、数字、水印或额外 UI 面板。
- 影响范围:`api-server` 木鱼图片生成编排、`wooden_fish_work_profile.background_asset_json``wooden_fish_work_profile.back_button_asset_json`、shared contracts、前端结果页 / 运行态背景与返回按钮展示、敲木鱼 PRD 和平台链路文档。
- 验证方式:执行 `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`
@@ -855,3 +855,11 @@
- 背景:结果页承载预览、修补和发布,若继续放“一次生成”按钮会把初始生成和结果修补职责混在一起。
- 决策:初始三图生成改由 `bark-battle-generating` 独立生成页自动执行,目标槽位只有玩家形象、对手形象和竞技背景;表单术语统一为 `themeDescription`、玩家形象描述和对手形象描述,不再回退 `themePreset`、狗狗皮肤预设或“角色设定”。部分失败也进入结果页。结果页不再提供一次生成按钮,音频配置和排名配置不进入 v1 公开闭环;结果页只保留单槽重试、重新生成和上传。发布时 SpacetimeDB `bark_battle_published_config.config_json` 使用规范化后的最终 `publishedSnapshot``published_snapshot_json` 同步保存同一份快照。
- 验证方式:表单提交后进入 `bark-battle-generating`结果页不会出现一次生成按钮、音频槽、皮肤预设入口或排名配置Bark Battle 发布后正式 runtime 应读取结果页最终图片素材而不是初始草稿素材。
## 2026-05-24 敲木鱼结果页先补录作品信息再试玩 / 发布
- 背景:敲木鱼工作台只应保留生成所需输入,作品标题、简介和主题标签适合放在生成草稿后的补录阶段。
- 决策:敲木鱼的 `workTitle``workDescription``themeTags` 从工作台首屏移到结果页;结果页编辑后在试玩或发布前先调用 `update-work-meta` 写回当前作品信息。主题标签编辑样式对齐拼图结果页的胶囊标签编辑器。
- 影响范围:`WoodenFishWorkspace``WoodenFishResultView``PlatformEntryFlowShellImpl`、敲木鱼 PRD 和平台入口链路文档。
- 验证方式:工作台首屏不再出现标题 / 简介 / 标签输入;结果页修改后点试玩或发布会先写回当前作品信息。
- 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`

View File

@@ -51,7 +51,7 @@
- 现象:返回按钮试玩图有时会被画成徽章、花盘、浮雕圆牌,甚至出现复杂外圈和装饰花纹,左箭头反而不够突出。
- 原因prompt 只说“主题化返回按钮”时image2 会把参考图里的装饰语言一起学进去;如果没有把形状收束到“标准圆形 + 单个居中左箭头”,模型会优先补造型而不是补图标。
- 处理:返回按钮生成 prompt 必须只允许参考图约束圆形底色与箭头配色,明确禁止复杂造型、花纹、浮雕边、异形外框和装饰图案,按钮本体固定为标准圆形。
- 处理:返回按钮生成 prompt 必须只允许参考图约束圆形底色与箭头配色,明确禁止复杂造型、花纹、浮雕边、异形外框和装饰图案,按钮本体固定为标准圆形,视觉尺寸比当前模板再放大约 50%,圆形外沿需要一圈与主题色搭配的干净外描边
- 验证:`cargo test -p api-server wooden_fish --manifest-path server-rs\Cargo.toml`,并重新试玩确认返回按钮只剩圆形底色和中央左箭头。
- 关联:`server-rs/crates/api-server/src/wooden_fish.rs``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`.
@@ -63,6 +63,14 @@
- 验证:`npm run test -- src/services/wooden-fish/woodenFishClient.test.ts`,并在本地触发一次木鱼创作确认不再出现 15 秒前端超时。
- 关联:`src/services/wooden-fish/woodenFishClient.ts``src/services/creation-agent/creationAgentClientFactory.ts``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`
## 敲木鱼创作“卡住”先查 2xx 慢请求
- 现象:敲木鱼工作台点击生成后长时间停留在生成页,看起来像卡住;`api-server` 日志可能出现 `/api/creation/wooden-fish/sessions/{sessionId}/actions``2xx` 慢请求,耗时可达数分钟,例如 `latency_ms=525473`
- 原因:当前 `compile-draft` 是同步 action会串行等待敲击物、背景环境图、返回按钮图三次 image2 edits、去绿处理、OSS 写入和 SpacetimeDB 草稿写回;提示词生成音效已关闭,不应作为生成阶段。
- 处理:先确认日志中该 action 是不是最终 200若是 200 慢请求,不要优先排查 WebSocket 或 SpacetimeDB procedure。前端生成页进度必须按“整理草稿 -> 生成敲击物 -> 生成背景环境图 -> 生成返回按钮图 -> 写入正式草稿”展示,并在未收到 action 回包前保持等待态,不宣称完成。
- 验证:`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts -t "wooden fish"`,并观察木鱼生成页在 5 分钟以上等待时仍停留在合理阶段。
- 关联:`src/services/miniGameDraftGenerationProgress.ts``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 敲木鱼点击生成出现 SpacetimeDB procedure 超时先查版本错配
- 现象:敲木鱼创作时点击“生成”,前端提示 `SpacetimeDB procedure 调用超时`,但服务端日志更早出现 `Failed to BSATN deserialize procedure return value` 或类似反序列化错误。

View File

@@ -113,31 +113,29 @@ WF-*
必填字段:
1. `templateId = "wooden-fish"`
2. `workTitle`:作品标题
3. `hitObjectPrompt`:用户想敲的对象关键词或描述,默认“默认敲击物图案,圆润木质质感,透明背景”;
4. `floatingWords[]`:祝福词,最多 8 条,不填或清空时使用默认祝福词。
2. `hitObjectPrompt`:用户想敲的对象关键词或描述,默认“默认敲击物图案,圆润木质质感,透明背景”
3. `floatingWords[]`:祝福词,最多 8 条,不填或清空时使用默认祝福词。
可选字段:
1. `workDescription`:作品简介
2. `themeTags[]`:最多 6 个标签
3. `hitObjectReferenceImageSrc`:上传或历史图引用,只能作为 image2 参考,不可直接进入运行态;
4. `hitSoundPrompt`:历史兼容字段,当前创作流程不再使用;
5. `hitSoundAsset`:用户上传、录音或默认音频资产。
1. `hitObjectReferenceImageSrc`:上传或历史图引用,只能作为 image2 参考,不可直接进入运行态
2. `hitSoundPrompt`:历史兼容字段,当前创作流程不再使用
3. `hitSoundAsset`:用户上传、录音或默认音频资产。
默认祝福词
结果页补录字段
1. `workTitle`:作品标题,默认值在结果页可编辑;
2. `workDescription`:作品简介;
3. `themeTags[]`:最多 6 个标签,样式对齐拼图结果页标签编辑器。
创作界面默认祝福词:
```text
幸运
健康
财富
姻缘
幸福
事业
成功
功德
```
用户可通过加号继续新增 7 个词条,总数最多 8 条。新增词条右侧提供减号 / 删除小按钮;默认的第一个词条保留为普通输入格。
`floatingWords[]` 保存词条名本身,不保存 `+1` 后缀;运行态每次敲击时再把飘字展示为“词条+1”。
## 6. 生成规则
@@ -181,11 +179,11 @@ WF-*
1. 调用 VectorEngine `/v1/images/edits`,模型固定为 `gpt-image-2`
2. multipart 参考图固定包含第一步去除绿色背景后的敲击物主体图,以及第二步生成的背景环境图;
3. 尺寸固定 `1:1`,必须输出绿色背景主体图(纯绿色绿幕),后端落库前执行同一套去绿背景处理;
4. 按主题、画风、材质和配色生成左上角返回按钮图,但参考图只用于约束圆形底色和中央左箭头的颜色搭配,不得借鉴复杂造型、花纹、浮雕边、异形外框或装饰图案;按钮必须始终是标准圆形,中央只保留单个清晰左箭头或返回箭头,不得包含文字、数字、水印、额外 UI 面板、木槌或敲击道具;
4. 按主题、画风、材质和配色生成左上角返回按钮图,但参考图只用于约束圆形底色和中央左箭头的颜色搭配,不得借鉴复杂造型、花纹、浮雕边、异形外框或装饰图案;按钮必须始终是标准圆形,主体视觉尺寸比当前模板再放大约 50%,圆形外沿必须有与主题色搭配的干净外描边,中央只保留单个清晰左箭头或返回箭头,不得包含文字、数字、水印、额外 UI 面板、木槌或敲击道具;
5. 提示词严格使用:
```text
生成敲木鱼左上角返回按钮图。要求以参考图-去除绿色背景后的敲击物主体和背景环境图为主题、画风、材质和配色参考,但参考图只用来约束圆形底色和中央左箭头的颜色搭配,不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案。按钮必须始终是标准圆形,整体像单个圆形图标,圆心居中,圆形内部只保留一个清晰、简洁、居中的向左返回箭头,不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具。尺寸1:1输出绿色背景主体图纯绿色绿幕背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影。按钮主体边缘干净,后续由服务端扣除绿色背景;按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请仅在圆形底色上使用偏深、偏黄或偏蓝的主题绿色,并用更高对比的箭头颜色区分。
生成敲木鱼左上角返回按钮图。要求以参考图-去除绿色背景后的敲击物主体和背景环境图为主题、画风、材质和配色参考,但参考图只用来约束圆形底色和中央左箭头的颜色搭配,不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案。按钮必须始终是标准圆形,整体像单个圆形图标,按钮主体在画布中的视觉尺寸比当前模板再放大约 50%,圆心居中,圆形外沿加一圈和主题色搭配的干净外描边,让它更像一个按钮,但仍然只保留一个清晰、简洁、居中的向左返回箭头,不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具。尺寸1:1输出绿色背景主体图纯绿色绿幕背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影。按钮主体边缘干净,后续由服务端扣除绿色背景;按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请仅在圆形底色上使用偏深、偏黄或偏蓝的主题绿色,并用更高对比的箭头颜色区分。
主题为:(用户提供参考图或用户输入关键词)
```
@@ -304,7 +302,7 @@ finish
`compile-draft` 是长耗时动作。前端进入生成页后应展示可恢复进度;如果请求失败,标记失败前必须复读 session确认后端是否已经生成并写回草稿。
敲木鱼创作请求在前端必须使用长等待窗口,避免 `createSession``executeAction` 仍沿用共享创作工厂默认的 15 秒超时。因为 `compile-draft` 会串行等待敲击物、背景、返回按钮和 OSS 落库,木鱼 client 需要单独配置与整条 image2 链路匹配的超时。
敲木鱼创作请求在前端必须使用长等待窗口,避免 `createSession``executeAction` 仍沿用共享创作工厂默认的 15 秒超时。因为 `compile-draft` 会串行等待敲击物、背景、返回按钮三次 image2 和 OSS 落库,木鱼 client 需要单独配置与整条 image2 链路匹配的超时。本地测试中该 action 可能达到数分钟级;生成页进度必须按“整理草稿 -> 生成敲击物 -> 生成背景环境图 -> 生成返回按钮图 -> 写入正式草稿”展示,不展示“提示词生成音效”阶段,因为当前木鱼音效只支持上传、录音或默认音。
## 9. SpacetimeDB 表和 view
@@ -340,7 +338,7 @@ finish
1. 重生成敲击物图案;
2. 上传、录制或替换敲击音效;未提供时使用默认木鱼音;
3. 修改标题、简介和标签;
3. 修改标题、简介和标签,并在试玩或发布前写回当前作品信息
4. 修改祝福词,最多 8 条。
图案重生成是独立局部生成态,不得把已有可查看结果重新变成不可打开的全局生成中。音效替换只接受上传或录音资产,不触发提示词音效生成。

View File

@@ -134,9 +134,12 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 >
1. `敲什么`:敲击物单图资产槽位。默认模板使用内置透明 PNG `/wooden-fish/default-hit-object.png` 作为 `bundled-default` 敲击物资产,避免默认关键词被重新语义化改形;用户输入自定义关键词或上传参考图时,后端必须以默认木鱼图作为基础结构和画风参考,使用 image2 生成最终敲击物图案,上传图只作为新主题参考,不直接进入运行态。自定义 `compile-draft` / `regenerate-hit-object` 必须完成 image2 -> OSS 私有对象 -> asset object 登记和绑定后,再由 `api-server` 注入真实 `hitObjectAsset.imageSrc`,不能只写 `/generated-wooden-fish-assets/...` 占位路径,也不能接受前端请求自带的 `hitObjectAsset` 短路生成。
2. `敲击音效`:音频资产槽位,当前创作阶段只支持用户上传或麦克风录制;未提供音频时统一写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。提示词生成音效入口临时关闭,通用 `/api/creation/audio/sound-effect` 对木鱼 `hit_sound` 目标也返回 `410 Gone``hitSoundPrompt` 只作为历史兼容字段保留,不参与当前创作流程,也不得由 `spacetime-client` 合成假音频路径。
3. `功德有什么`:最多 8 条飘字,默认 `幸运、健康、财富、姻缘、幸福、事业、成功、功德`;创作态只保存词条名,运行态飘字展示时再追加 `+1`。运行态顶部总数卡采用品牌化徽标样式,子项计数器预置展示在可展开面板中,未出现词条初始值为 0。
3. `功德有什么`:最多 8 条飘字,创作态首屏只保留一个默认词条 `幸运`,其下提供加号格继续追加词条;创作态只保存词条名,运行态飘字展示时再追加 `+1`。运行态顶部总数卡采用品牌化徽标样式,子项计数器预置展示在可展开面板中,未出现词条初始值为 0。
4. `作品标题 / 作品简介 / 主题标签`:不再放在创作工作台首屏,改为生成草稿后的结果页补录区,提交试玩或发布前必须先写回当前作品信息。主题标签编辑样式对齐拼图结果页的胶囊标签编辑器。
图片生成链路固定为三图 image2 流程:第一步用默认木鱼图作为结构和画风参考,按用户题材关键词或参考图主题生成 `1:1` 绿色背景主体图纯绿色绿幕prompt 必须显式要求背景为单一纯绿色 `#00FF00` 且平整无纹理、无渐变、无阴影、无道具,主体完整居中,且禁止黑底、白底、棋盘格和任何实底背景;后端在落库前只对这张绿幕主体图执行去绿背景处理,不做泛抠图,避免误伤玉米等主体像素。第二步必须使用第一步抠图完成后的透明图作为参考图,再用新敲击物作为主题和画风参考生成 `9:16` 背景环境图,背景图只适配主题和画风,不能包含新敲击物本体,也不能增加木槌互动物品;画面中央主体预留区必须干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围。第三步必须使用去绿后的敲击物主体图和背景环境图作为参考图生成 `1:1` 返回按钮图,返回按钮必须始终是标准圆形,中央只保留单个左箭头,参考图只约束圆形底色和箭头配色,不得延伸到复杂造型和花纹;按钮不得出现文字、数字、水印、额外 UI 面板或木槌物品。三个资产分别写回 `hitObjectAsset``backgroundAsset``backButtonAsset`,并绑定到 `wooden_fish_work``hit_object` / `background` / `back_button` 槽位。运行态和结果页消费 `backgroundAsset` 做竖屏背景,中央再叠加 `hitObjectAsset`,左上角返回按钮消费 `backButtonAsset`
图片生成链路固定为三图 image2 流程:第一步用默认木鱼图作为结构和画风参考,按用户题材关键词或参考图主题生成 `1:1` 绿色背景主体图纯绿色绿幕prompt 必须显式要求背景为单一纯绿色 `#00FF00` 且平整无纹理、无渐变、无阴影、无道具,主体完整居中,且禁止黑底、白底、棋盘格和任何实底背景;后端在落库前只对这张绿幕主体图执行去绿背景处理,不做泛抠图,避免误伤玉米等主体像素。第二步必须使用第一步抠图完成后的透明图作为参考图,再用新敲击物作为主题和画风参考生成 `9:16` 背景环境图,背景图只适配主题和画风,不能包含新敲击物本体,也不能增加木槌互动物品;画面中央主体预留区必须干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围。第三步必须使用去绿后的敲击物主体图和背景环境图作为参考图生成 `1:1` 返回按钮图,返回按钮必须始终是标准圆形,主体视觉尺寸比当前模板再放大约 50%,圆形外沿必须有与主题色搭配的干净外描边,中央只保留单个左箭头,参考图只约束圆形底色和箭头配色,不得延伸到复杂造型和花纹;按钮不得出现文字、数字、水印、额外 UI 面板或木槌物品。三个资产分别写回 `hitObjectAsset``backgroundAsset``backButtonAsset`,并绑定到 `wooden_fish_work``hit_object` / `background` / `back_button` 槽位。运行态和结果页消费 `backgroundAsset` 做竖屏背景,中央再叠加 `hitObjectAsset`,左上角返回按钮消费 `backButtonAsset`
木鱼初始 `compile-draft` 是长耗时同步 action生成页必须按上述三图 image2 链路展示进度:整理草稿、生成敲击物、生成背景环境图、生成返回按钮图、写入正式草稿。本地或供应商慢时一次 action 可能持续数分钟;前端不得把已关闭的提示词生成音效当成进度阶段,也不得在未收到 action 回包前宣称生成完成。
运行态规则真相以后端 run 摘要为准,前端只做点击低延迟表现、敲击动画、音频播放和飘字渲染。每次非功能区点击在当前 run 内累计 `totalTapCount``wordCounters`;计数不进入账号长期账本,不做排行榜。顶部总数卡点击后展开子项计数器面板,子项计数在面板中按词条纵列预置展示,未出现词条初始值为 0后续同词条继续累加运行态左上角使用主题化返回按钮图不提供右上角重开按钮。

View File

@@ -874,6 +874,7 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
});
let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓");
generated_asset.image_src =
@@ -1062,6 +1063,7 @@ fn match3d_background_asset_requires_background_and_container_images() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
};
let with_container = Match3DGeneratedBackgroundAsset {
container_prompt: Some("果园容器".to_string()),
@@ -1108,6 +1110,7 @@ fn match3d_default_cover_prefers_generated_container_ui_image() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1169,7 +1172,7 @@ fn match3d_cover_reference_prompt_marks_reference_images() {
#[test]
fn match3d_cover_edit_prompt_preserves_uploaded_image() {
let prompt = build_match3d_cover_edit_prompt("水果封面");
let prompt = build_match3d_cover_uploaded_reference_prompt("水果封面");
assert!(prompt.contains("上传的封面图作为第一优先级"));
assert!(prompt.contains("保留主图的主体、构图、视角和主要配色"));
@@ -1212,6 +1215,7 @@ fn match3d_fallback_work_profile_keeps_generated_background_asset() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1349,6 +1353,7 @@ fn match3d_agent_session_response_hydrates_persisted_ui_assets() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1424,6 +1429,7 @@ fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydr
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1807,6 +1813,7 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
..test_match3d_generated_item_asset(1, "草莓")
}];

View File

@@ -747,7 +747,7 @@ fn build_wooden_fish_background_prompt(prompt: &str) -> String {
fn build_wooden_fish_back_button_prompt(prompt: &str) -> String {
format!(
"生成敲木鱼左上角返回按钮图。要求以参考图-去除绿色背景后的敲击物主体和背景环境图为主题、画风、材质和配色参考,但参考图只用来约束圆形底色和中央左箭头的颜色搭配,不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案。按钮必须始终是标准圆形,整体像单个圆形图标,圆心居中,圆形内部只保留一个清晰、简洁、居中的向左返回箭头,不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具。尺寸1:1输出绿色背景主体图纯绿色绿幕背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影。按钮主体边缘干净,后续由服务端扣除绿色背景;按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请仅在圆形底色上使用偏深、偏黄或偏蓝的主题绿色,并用更高对比的箭头颜色区分。\n主题为:{}",
"生成敲木鱼左上角返回按钮图。要求以参考图-去除绿色背景后的敲击物主体和背景环境图为主题、画风、材质和配色参考,但参考图只用来约束圆形底色和中央左箭头的颜色搭配,不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案。按钮必须始终是标准圆形,整体像单个圆形图标,按钮主体在画布中的视觉尺寸比当前模板再放大约 50%,圆心居中,圆形外沿加一圈和主题色搭配的干净外描边,让它更像一个按钮,但仍然只保留一个清晰、简洁、居中的向左返回箭头,不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具。尺寸1:1输出绿色背景主体图纯绿色绿幕背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影。按钮主体边缘干净,后续由服务端扣除绿色背景;按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请仅在圆形底色上使用偏深、偏黄或偏蓝的主题绿色,并用更高对比的箭头颜色区分。\n主题为:{}",
clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT)
)
}
@@ -1230,7 +1230,9 @@ mod tests {
assert!(prompt.contains("参考图只用来约束圆形底色和中央左箭头的颜色搭配"));
assert!(prompt.contains("按钮必须始终是标准圆形"));
assert!(prompt.contains("圆形内部只保留一个清晰、简洁、居中的向左返回箭头"));
assert!(prompt.contains("按钮主体在画布中的视觉尺寸比当前模板再放大约 50%"));
assert!(prompt.contains("圆形外沿加一圈和主题色搭配的干净外描边"));
assert!(prompt.contains("只保留一个清晰、简洁、居中的向左返回箭头"));
assert!(prompt.contains("不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案"));
assert!(prompt.contains("不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具"));
assert!(prompt.contains("按钮底色不要使用与绿幕接近的纯绿色"));

View File

@@ -56,6 +56,7 @@ export type CreativeImageInputPanelProps = {
imageModelPicker?: ReactNode;
error?: string | null;
inputError?: string | null;
showSubmitButton?: boolean;
submitLabel: string;
submitCostLabel?: string | null;
submitDisabled: boolean;
@@ -98,6 +99,7 @@ export function CreativeImageInputPanel({
imageModelPicker = null,
error = null,
inputError = null,
showSubmitButton = true,
submitLabel,
submitCostLabel = null,
submitDisabled,
@@ -382,27 +384,31 @@ export function CreativeImageInputPanel({
</section>
</div>
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={disabled || submitDisabled}
onClick={onSubmit}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${
submitDisabled ? 'cursor-not-allowed opacity-55' : ''
}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
<span>{submitLabel}</span>
{submitCostLabel ? (
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
{submitCostLabel}
</span>
) : null}
</span>
</button>
</div>
{showSubmitButton ? (
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={disabled || submitDisabled}
onClick={onSubmit}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${
submitDisabled ? 'cursor-not-allowed opacity-55' : ''
}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
<Sparkles className="h-4 w-4" />
<span>{submitLabel}</span>
{submitCostLabel ? (
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
{submitCostLabel}
</span>
) : null}
</span>
</button>
</div>
) : null}
{previewReferenceImage ? (
<div

View File

@@ -7699,6 +7699,54 @@ export function PlatformEntryFlowShellImpl({
],
);
const updateWoodenFishWorkMeta = useCallback(
async (payload: {
workTitle: string;
workDescription: string;
themeTags: string[];
}) => {
const sessionId = woodenFishSession?.sessionId?.trim();
const profileId =
woodenFishWork?.summary.profileId?.trim() ||
woodenFishSession?.draft?.profileId?.trim() ||
'';
if (!sessionId || !profileId) {
setWoodenFishError('敲木鱼草稿尚未生成可保存作品信息。');
setSelectionStage('wooden-fish-result');
return false;
}
setIsWoodenFishBusy(true);
setWoodenFishError(null);
try {
const response = await woodenFishClient.executeAction(sessionId, {
actionType: 'update-work-meta',
profileId,
workTitle: payload.workTitle,
workDescription: payload.workDescription,
themeTags: payload.themeTags,
});
setWoodenFishSession(response.session);
setWoodenFishWork(response.work ?? woodenFishWork);
return true;
} catch (error) {
setWoodenFishError(
resolveRpgCreationErrorMessage(error, '保存敲木鱼作品信息失败。'),
);
setSelectionStage('wooden-fish-result');
return false;
} finally {
setIsWoodenFishBusy(false);
}
},
[
setSelectionStage,
woodenFishSession?.draft?.profileId,
woodenFishSession?.sessionId,
woodenFishWork,
],
);
const publishWoodenFishDraft = useCallback(async () => {
const profileId = woodenFishWork?.summary.profileId?.trim();
if (!profileId) {
@@ -14386,6 +14434,7 @@ export function PlatformEntryFlowShellImpl({
onRegenerateHitObject={() => {
void regenerateWoodenFishAsset('regenerate-hit-object');
}}
onUpdateWorkMeta={updateWoodenFishWorkMeta}
/>
</Suspense>
</motion.div>

View File

@@ -1,10 +1,51 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { expect, test } from 'vitest';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient';
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../services/wooden-fish/woodenFishDefaults';
import { WoodenFishWorkspace } from './WoodenFishWorkspace';
vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
woodenFishClient: {
createSession: vi.fn(),
},
}));
beforeEach(() => {
vi.mocked(woodenFishClient.createSession).mockReset();
vi.mocked(woodenFishClient.createSession).mockResolvedValue({
session: {
sessionId: 'wooden-fish-session-test',
ownerUserId: 'user-test',
status: 'draft',
draft: null,
createdAt: '2026-05-24T00:00:00Z',
updatedAt: '2026-05-24T00:00:00Z',
},
});
});
test('敲什么输入栏初始置空但提交时仍使用默认生成提示词', async () => {
const onSubmitted = vi.fn();
render(
<WoodenFishWorkspace
onBack={() => {}}
onSubmitted={onSubmitted}
/>,
);
expect(screen.getByLabelText('敲什么')).toHaveProperty('value', '');
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
expect(onSubmitted.mock.calls[0]?.[1]).toMatchObject({
hitObjectPrompt: WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
});
});
test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀', () => {
render(
<WoodenFishWorkspace
@@ -18,10 +59,35 @@ test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀'
expect(section).not.toBeNull();
expect(within(section as HTMLElement).getByDisplayValue('幸运')).toBeTruthy();
expect(within(section as HTMLElement).getByDisplayValue('健康')).toBeTruthy();
expect(within(section as HTMLElement).getByDisplayValue('财富')).toBeTruthy();
expect(within(section as HTMLElement).queryByDisplayValue('健康')).toBeNull();
expect(within(section as HTMLElement).queryByDisplayValue('财富')).toBeNull();
expect(within(section as HTMLElement).queryByDisplayValue('幸运+1')).toBeNull();
expect(within(section as HTMLElement).queryByDisplayValue('功德+1')).toBeNull();
expect(
within(section as HTMLElement).getByRole('button', {
name: '新增功德词条',
}),
).toBeTruthy();
});
test('功德有什么支持通过加号新增词条并移除新增格子', () => {
render(
<WoodenFishWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增功德词条' }));
const secondInput = screen.getByLabelText('功德词条 2');
fireEvent.change(secondInput, { target: { value: '健康' } });
expect(screen.getByDisplayValue('幸运')).toBeTruthy();
expect(screen.getByDisplayValue('健康')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '删除功德词条 2' }));
expect(screen.queryByDisplayValue('健康')).toBeNull();
expect(screen.getByDisplayValue('幸运')).toBeTruthy();
});
test('敲击音效临时关闭提示词生成入口,仅保留上传和录音', () => {
@@ -40,3 +106,14 @@ test('敲击音效临时关闭提示词生成入口,仅保留上传和录音',
expect(within(section as HTMLElement).getByText('上传')).toBeTruthy();
expect(within(section as HTMLElement).getByText('录音')).toBeTruthy();
});
test('工作台只保留一个生成按钮', () => {
render(
<WoodenFishWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
expect(screen.getAllByRole('button', { name: '生成' })).toHaveLength(1);
});

View File

@@ -3,7 +3,9 @@ import {
Loader2,
Mic,
Pause,
Plus,
Send,
X,
Upload,
} from 'lucide-react';
import { useMemo, useRef, useState } from 'react';
@@ -32,44 +34,24 @@ type WoodenFishWorkspaceProps = {
};
type WoodenFishWorkspaceFormState = {
workTitle: string;
workDescription: string;
themeTags: string;
hitObjectPrompt: string;
hitObjectReferenceImageSrc: string;
hitSoundAsset: WoodenFishAudioAsset | null;
floatingWords: string[];
};
const DEFAULT_FLOATING_WORDS = [
'幸运',
'健康',
'财富',
'姻缘',
'幸福',
'事业',
'成功',
'功德',
];
const DEFAULT_WORK_TITLE = '今日敲木鱼';
const DEFAULT_THEME_TAGS = ['敲木鱼', '解压'];
const DEFAULT_FLOATING_WORDS = ['幸运'];
const MAX_FLOATING_WORD_COUNT = 8;
const DEFAULT_FORM_STATE: WoodenFishWorkspaceFormState = {
workTitle: '今日敲木鱼',
workDescription: '',
themeTags: '敲木鱼 解压',
hitObjectPrompt: WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
hitObjectPrompt: '',
hitObjectReferenceImageSrc: '',
hitSoundAsset: null,
floatingWords: DEFAULT_FLOATING_WORDS,
};
function splitTags(value: string) {
return value
.split(/[,\s]+/u)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 6);
}
function normalizeFloatingWords(words: string[]) {
const seen = new Set<string>();
const normalized: string[] = [];
@@ -84,7 +66,7 @@ function normalizeFloatingWords(words: string[]) {
break;
}
}
return normalized.length > 0 ? normalized : DEFAULT_FLOATING_WORDS;
return normalized.length > 0 ? normalized : [...DEFAULT_FLOATING_WORDS];
}
function readAudioFileAsAsset(file: File, source: 'uploaded' | 'recorded') {
@@ -278,11 +260,42 @@ export function WoodenFishWorkspace({
() => normalizeFloatingWords(formState.floatingWords),
[formState.floatingWords],
);
const canSubmit = Boolean(
formState.workTitle.trim() &&
formState.hitObjectPrompt.trim() &&
normalizedFloatingWords.length > 0,
);
const canSubmit = normalizedFloatingWords.length > 0;
const updateFloatingWord = (index: number, value: string) => {
setFormState((current) => {
const nextWords = [...current.floatingWords];
nextWords[index] = value;
return {
...current,
floatingWords: nextWords.slice(0, MAX_FLOATING_WORD_COUNT),
};
});
};
const addFloatingWord = () => {
setFormState((current) => {
if (current.floatingWords.length >= MAX_FLOATING_WORD_COUNT) {
return current;
}
return {
...current,
floatingWords: [...current.floatingWords, ''],
};
});
};
const removeFloatingWord = (index: number) => {
if (index <= 0) {
return;
}
setFormState((current) => ({
...current,
floatingWords: current.floatingWords.filter(
(_word, currentIndex) => currentIndex !== index,
),
}));
};
const handleSubmit = async () => {
if (!canSubmit || isSubmitting || isBusy) {
@@ -296,10 +309,11 @@ export function WoodenFishWorkspace({
try {
const payload: WoodenFishWorkspaceCreateRequest = {
templateId: 'wooden-fish',
workTitle: formState.workTitle.trim(),
workDescription: formState.workDescription.trim(),
themeTags: splitTags(formState.themeTags),
hitObjectPrompt: formState.hitObjectPrompt.trim(),
workTitle: DEFAULT_WORK_TITLE,
workDescription: '',
themeTags: DEFAULT_THEME_TAGS,
hitObjectPrompt:
formState.hitObjectPrompt.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
hitObjectReferenceImageSrc:
formState.hitObjectReferenceImageSrc.trim() || null,
hitSoundPrompt: null,
@@ -345,6 +359,7 @@ export function WoodenFishWorkspace({
promptRows={4}
aiRedraw={aiRedraw}
promptReferenceImages={[]}
showSubmitButton={false}
submitLabel="生成"
submitDisabled={!canSubmit || isSubmitting || isBusy}
labels={{
@@ -395,55 +410,6 @@ export function WoodenFishWorkspace({
</div>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
<section className="platform-subpanel rounded-[1.25rem] p-4">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.workTitle}
onChange={(event) =>
setFormState((current) => ({
...current,
workTitle: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="mt-3 block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.workDescription}
onChange={(event) =>
setFormState((current) => ({
...current,
workDescription: event.target.value,
}))
}
rows={2}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="mt-3 block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.themeTags}
onChange={(event) =>
setFormState((current) => ({
...current,
themeTags: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
</section>
<WoodenFishAudioInputPanel
disabled={isBusy || isSubmitting}
asset={formState.hitSoundAsset}
@@ -460,24 +426,45 @@ export function WoodenFishWorkspace({
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2">
{formState.floatingWords.map((word, index) => (
<input
key={index}
value={word}
maxLength={16}
disabled={isBusy || isSubmitting}
onChange={(event) => {
const nextWords = [...formState.floatingWords];
nextWords[index] = event.target.value;
setFormState((current) => ({
...current,
floatingWords: nextWords.slice(0, 8),
}));
}}
className="w-full rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2.5 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
<div key={index} className="relative">
<input
value={word}
maxLength={16}
disabled={isBusy || isSubmitting}
aria-label={`功德词条 ${index + 1}`}
onChange={(event) =>
updateFloatingWord(index, event.target.value)
}
className="h-12 w-full rounded-[0.95rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 pr-10 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
{index > 0 ? (
<button
type="button"
disabled={isBusy || isSubmitting}
onClick={() => removeFloatingWord(index)}
className="absolute right-2 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full bg-white/92 text-[var(--platform-text-soft)] shadow-sm transition hover:text-[var(--platform-accent)] disabled:opacity-45"
aria-label={`删除功德词条 ${index + 1}`}
title="删除词条"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
))}
{formState.floatingWords.length < MAX_FLOATING_WORD_COUNT ? (
<button
type="button"
disabled={isBusy || isSubmitting}
onClick={addFloatingWord}
className="grid h-12 place-items-center rounded-[0.95rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/55 text-[var(--platform-text-soft)] transition hover:border-[var(--platform-accent)] hover:bg-white/78 hover:text-[var(--platform-accent)] disabled:opacity-45"
aria-label="新增功德词条"
title="新增词条"
>
<Plus className="h-5 w-5" />
</button>
) : null}
</div>
</section>
</div>

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { WoodenFishDraftResponse } from '../../../packages/shared/src/contracts/woodenFish';
@@ -64,3 +64,42 @@ test('结果页缺少音频资产时使用默认木鱼音且不展示生成音
'/wooden-fish/default-hit-sound.mp3',
);
});
test('结果页支持在试玩前编辑并保存主题信息', async () => {
const onStartTestRun = vi.fn();
const onPublish = vi.fn();
const onUpdateWorkMeta = vi.fn().mockResolvedValue(true);
render(
<WoodenFishResultView
profile={createDraft()}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={onStartTestRun}
onPublish={onPublish}
onRegenerateHitObject={() => {}}
onUpdateWorkMeta={onUpdateWorkMeta}
/>,
);
fireEvent.change(screen.getByLabelText('作品标题'), {
target: { value: '新的木鱼作品' },
});
fireEvent.change(screen.getByLabelText('作品简介'), {
target: { value: '敲一下,心静一下。' },
});
fireEvent.click(screen.getByRole('button', { name: '新增主题标签' }));
fireEvent.change(screen.getByLabelText('新题材标签'), {
target: { value: '治愈' },
});
fireEvent.click(screen.getByRole('button', { name: '添加' }));
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onUpdateWorkMeta).toHaveBeenCalledWith({
workTitle: '新的木鱼作品',
workDescription: '敲一下,心静一下。',
themeTags: ['敲木鱼', '治愈'],
});
await waitFor(() => expect(onStartTestRun).toHaveBeenCalledTimes(1));
});

View File

@@ -2,10 +2,12 @@ import {
ArrowLeft,
Loader2,
Play,
Plus,
RefreshCcw,
Send,
X,
} from 'lucide-react';
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type {
WoodenFishDraftResponse,
@@ -27,6 +29,13 @@ type WoodenFishResultViewProps = {
onStartTestRun: () => void;
onPublish: () => void;
onRegenerateHitObject: () => void;
onUpdateWorkMeta?: (payload: WoodenFishWorkMetaEditState) => Promise<boolean>;
};
export type WoodenFishWorkMetaEditState = {
workTitle: string;
workDescription: string;
themeTags: string[];
};
function isWoodenFishWorkProfile(
@@ -35,6 +44,184 @@ function isWoodenFishWorkProfile(
return 'summary' in profile;
}
function normalizeThemeTags(tags: string[]) {
const seen = new Set<string>();
const normalized: string[] = [];
for (const tag of tags) {
const trimmed = tag.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
normalized.push(trimmed);
if (normalized.length >= 6) {
break;
}
}
return normalized;
}
function normalizeThemeTagInput(value: string) {
return normalizeThemeTags(value.split(/[,\s]+/u));
}
function buildMetaEditState(
profile: WoodenFishResultViewProps['profile'],
): WoodenFishWorkMetaEditState {
const isWorkProfile = isWoodenFishWorkProfile(profile);
const draft = isWorkProfile ? profile.draft : profile;
const summary = isWorkProfile ? profile.summary : null;
return {
workTitle:
summary?.workTitle?.trim() || draft.workTitle.trim() || '今日敲木鱼',
workDescription:
summary?.workDescription?.trim() || draft.workDescription.trim(),
themeTags: normalizeThemeTags(
summary?.themeTags?.length ? summary.themeTags : draft.themeTags,
),
};
}
function areMetaStatesEqual(
left: WoodenFishWorkMetaEditState,
right: WoodenFishWorkMetaEditState,
) {
return (
left.workTitle.trim() === right.workTitle.trim() &&
left.workDescription.trim() === right.workDescription.trim() &&
normalizeThemeTags(left.themeTags).join('\u0000') ===
normalizeThemeTags(right.themeTags).join('\u0000')
);
}
function WoodenFishThemeTagEditor({
editState,
isBusy,
onChange,
}: {
editState: WoodenFishWorkMetaEditState;
isBusy: boolean;
onChange: (nextState: WoodenFishWorkMetaEditState) => void;
}) {
const [newTagText, setNewTagText] = useState('');
const [isAddingTag, setIsAddingTag] = useState(false);
const addTags = () => {
const nextTags = normalizeThemeTagInput(newTagText);
if (nextTags.length <= 0) {
setNewTagText('');
setIsAddingTag(false);
return;
}
onChange({
...editState,
themeTags: normalizeThemeTags([...editState.themeTags, ...nextTags]),
});
setNewTagText('');
setIsAddingTag(false);
};
return (
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{!isAddingTag && editState.themeTags.length < 6 ? (
<button
type="button"
disabled={isBusy}
onClick={() => setIsAddingTag(true)}
className="platform-icon-button h-9 w-9"
aria-label="新增主题标签"
title="新增主题标签"
>
<Plus className="h-4 w-4" />
</button>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{editState.themeTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1.5 text-xs font-semibold text-amber-700"
>
{tag}
<button
type="button"
disabled={isBusy}
onClick={() => {
onChange({
...editState,
themeTags: editState.themeTags.filter(
(currentTag) => currentTag !== tag,
),
});
}}
className="rounded-full text-amber-800/70 transition hover:text-amber-950 disabled:opacity-45"
aria-label={`删除标签 ${tag}`}
title="删除标签"
>
<X className="h-3.5 w-3.5" />
</button>
</span>
))}
{editState.themeTags.length <= 0 ? (
<span className="text-sm text-[var(--platform-text-soft)]">
</span>
) : null}
</div>
{isAddingTag ? (
<div className="mt-3 flex flex-col gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2 sm:flex-row">
<input
autoFocus
value={newTagText}
disabled={isBusy}
onChange={(event) => setNewTagText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addTags();
}
if (event.key === 'Escape') {
setIsAddingTag(false);
setNewTagText('');
}
}}
className="min-h-10 flex-1 rounded-[0.8rem] border border-transparent bg-white/92 px-3 text-sm text-[var(--platform-text-strong)] outline-none"
placeholder="输入新标签"
aria-label="新题材标签"
/>
<div className="flex gap-2">
<button
type="button"
disabled={isBusy}
onClick={addTags}
className="platform-button platform-button--primary min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsAddingTag(false);
setNewTagText('');
}}
className="platform-button platform-button--ghost min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
</div>
</div>
) : null}
</section>
);
}
export function WoodenFishResultView({
profile,
isBusy = false,
@@ -44,11 +231,14 @@ export function WoodenFishResultView({
onStartTestRun,
onPublish,
onRegenerateHitObject,
onUpdateWorkMeta,
}: WoodenFishResultViewProps) {
const [isPublishing, setIsPublishing] = useState(false);
const [isSavingMeta, setIsSavingMeta] = useState(false);
const isWorkProfile = isWoodenFishWorkProfile(profile);
const draft = isWorkProfile ? profile.draft : profile;
const summary = isWorkProfile ? profile.summary : null;
const canonicalMeta = useMemo(() => buildMetaEditState(profile), [profile]);
const [metaEditState, setMetaEditState] = useState(canonicalMeta);
const hitObjectAsset = isWorkProfile
? profile.hitObjectAsset
: draft.hitObjectAsset;
@@ -62,18 +252,52 @@ export function WoodenFishResultView({
? profile.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET
: draft.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET;
const floatingWords = isWorkProfile ? profile.floatingWords : draft.floatingWords;
const title =
summary?.workTitle?.trim() || draft.workTitle.trim() || '敲木鱼';
const description =
summary?.workDescription?.trim() || draft.workDescription.trim();
const title = metaEditState.workTitle.trim() || '敲木鱼';
const description = metaEditState.workDescription.trim();
const isMetaDirty = !areMetaStatesEqual(metaEditState, canonicalMeta);
const { resolvedUrl: resolvedAudioUrl } = useResolvedAssetReadUrl(
hitSoundAsset?.audioSrc,
);
useEffect(() => {
setMetaEditState(canonicalMeta);
}, [canonicalMeta]);
const saveMetaIfNeeded = async () => {
const normalizedMeta: WoodenFishWorkMetaEditState = {
workTitle: metaEditState.workTitle.trim() || '今日敲木鱼',
workDescription: metaEditState.workDescription.trim(),
themeTags: normalizeThemeTags(metaEditState.themeTags),
};
if (!onUpdateWorkMeta || areMetaStatesEqual(normalizedMeta, canonicalMeta)) {
setMetaEditState(normalizedMeta);
return true;
}
setIsSavingMeta(true);
try {
const saved = await onUpdateWorkMeta(normalizedMeta);
if (saved) {
setMetaEditState(normalizedMeta);
}
return saved;
} finally {
setIsSavingMeta(false);
}
};
const handleStartTestRun = async () => {
if (await saveMetaIfNeeded()) {
onStartTestRun();
}
};
const handlePublish = async () => {
setIsPublishing(true);
try {
await Promise.resolve(onPublish());
if (await saveMetaIfNeeded()) {
await Promise.resolve(onPublish());
}
} finally {
setIsPublishing(false);
}
@@ -132,10 +356,54 @@ export function WoodenFishResultView({
</div>
</section>
<section className="platform-subpanel flex min-h-0 flex-col rounded-[1.25rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
value={metaEditState.workTitle}
disabled={isBusy || isSavingMeta}
onChange={(event) =>
setMetaEditState((current) => ({
...current,
workTitle: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="作品标题"
/>
</section>
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<textarea
value={metaEditState.workDescription}
disabled={isBusy || isSavingMeta}
rows={4}
onChange={(event) =>
setMetaEditState((current) => ({
...current,
workDescription: event.target.value,
}))
}
className="mt-3 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="作品简介"
/>
</section>
<WoodenFishThemeTagEditor
editState={metaEditState}
isBusy={isBusy || isSavingMeta}
onChange={setMetaEditState}
/>
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 flex flex-wrap gap-2">
{floatingWords.map((word) => (
<span
@@ -146,8 +414,9 @@ export function WoodenFishResultView({
</span>
))}
</div>
</section>
<div className="mt-4">
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
@@ -158,7 +427,7 @@ export function WoodenFishResultView({
</div>
)}
</div>
</section>
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
@@ -166,7 +435,7 @@ export function WoodenFishResultView({
</div>
) : null}
<div className="mt-auto grid gap-2 pt-4">
<div className="mt-auto grid gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-1">
<button
type="button"
onClick={onEdit}
@@ -178,11 +447,17 @@ export function WoodenFishResultView({
</button>
<button
type="button"
onClick={onStartTestRun}
disabled={isBusy}
onClick={() => {
void handleStartTestRun();
}}
disabled={isBusy || isSavingMeta}
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
>
<Play className="h-4 w-4" />
{isSavingMeta ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</button>
<button
@@ -190,18 +465,18 @@ export function WoodenFishResultView({
onClick={() => {
void handlePublish();
}}
disabled={isBusy || isPublishing}
disabled={isBusy || isPublishing || isSavingMeta}
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
>
{isPublishing ? (
{isPublishing || isSavingMeta ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
{isMetaDirty ? '保存并发布' : '发布'}
</button>
</div>
</section>
</div>
</div>
</div>
);

View File

@@ -407,7 +407,7 @@ describe('miniGameDraftGenerationProgress', () => {
]);
});
test('wooden fish draft generation exposes hit object, background and sound pipeline', () => {
test('wooden fish draft generation exposes hit object, background and back button pipeline', () => {
const state = createMiniGameDraftGenerationState('wooden-fish');
const progress = buildMiniGameDraftGenerationProgress(
@@ -419,12 +419,40 @@ describe('miniGameDraftGenerationProgress', () => {
'wooden-fish-draft',
'wooden-fish-hit-object',
'wooden-fish-background',
'wooden-fish-hit-sound',
'wooden-fish-back-button',
'wooden-fish-write-draft',
]);
expect(progress?.phaseId).toBe('wooden-fish-hit-object');
expect(progress?.phaseLabel).toBe('生成敲击物图案');
expect(progress?.estimatedRemainingMs).toBe(272_000);
expect(progress?.estimatedRemainingMs).toBe(530_000);
});
test('wooden fish draft generation follows hit object, background, back button and writeback', () => {
const state = createMiniGameDraftGenerationState('wooden-fish');
const hitObjectProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 20_000,
);
const backgroundProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 200_000,
);
const backButtonProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 390_000,
);
const writeBackProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 575_000,
);
expect(hitObjectProgress?.phaseId).toBe('wooden-fish-hit-object');
expect(backgroundProgress?.phaseId).toBe('wooden-fish-background');
expect(backButtonProgress?.phaseId).toBe('wooden-fish-back-button');
expect(writeBackProgress?.phaseId).toBe('wooden-fish-write-draft');
expect(writeBackProgress?.estimatedRemainingMs).toBe(0);
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
});
test('wooden fish generation anchors expose hit object, sound and words', () => {

View File

@@ -70,7 +70,7 @@ export type MiniGameDraftGenerationPhase =
| 'wooden-fish-draft'
| 'wooden-fish-hit-object'
| 'wooden-fish-background'
| 'wooden-fish-hit-sound'
| 'wooden-fish-back-button'
| 'wooden-fish-write-draft'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
@@ -415,30 +415,36 @@ const WOODEN_FISH_STEPS = [
{
id: 'wooden-fish-hit-object',
label: '生成敲击物图案',
detail: '使用 image2 生成最终运行态敲击物图案。',
weight: 34,
detail: '用 image2 生成绿幕敲击物并去绿透明化,预计约 3 分钟。',
weight: 32,
},
{
id: 'wooden-fish-background',
label: '生成背景环境图',
detail: '使用 image2 生成敲击背景环境图。',
weight: 34,
detail: '使用透明敲击物作参考生成 9:16 背景环境图,预计约 3 分钟。',
weight: 32,
},
{
id: 'wooden-fish-hit-sound',
label: '准备敲击音效',
detail: '写回上传、录音或默认短促敲击音效资产。',
weight: 16,
id: 'wooden-fish-back-button',
label: '生成返回按钮图',
detail: '使用敲击物和背景作参考生成主题圆形返回按钮,预计约 3 分钟。',
weight: 20,
},
{
id: 'wooden-fish-write-draft',
label: '写入正式草稿',
detail: '保存图案、背景、音效、飘字和封面摘要。',
detail: '保存图案、背景、返回按钮、音效、飘字和封面摘要。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const WOODEN_FISH_ESTIMATED_WAIT_MS = 5 * 60_000;
const WOODEN_FISH_COMPILE_EXPECTED_MS = 8_000;
const WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS = 180_000;
const WOODEN_FISH_WRITE_DRAFT_EXPECTED_MS = 10_000;
const WOODEN_FISH_ESTIMATED_WAIT_MS =
WOODEN_FISH_COMPILE_EXPECTED_MS +
WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS * 3 +
WOODEN_FISH_WRITE_DRAFT_EXPECTED_MS;
function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
@@ -486,15 +492,16 @@ function buildMiniGameProgressSteps(
return steps.map((step, index) => {
// 中文注释:拼图草稿编译的 action 回包才代表可进入结果页;
// 但预计写入时长已耗尽时,最后一步自身应呈现已完成,避免出现“进行中 100%”。
const isPuzzleWriteStepCompleted =
state.kind === 'puzzle' &&
const isTimedWriteStepCompleted =
(state.kind === 'puzzle' || state.kind === 'wooden-fish') &&
state.phase !== 'failed' &&
step.id === 'puzzle-select-image' &&
(step.id === 'puzzle-select-image' ||
step.id === 'wooden-fish-write-draft') &&
clampProgress(activeStepProgressRatio * 100) >= 100;
const isCompleted =
state.phase === 'ready' ||
index < activeStepIndex ||
isPuzzleWriteStepCompleted;
isTimedWriteStepCompleted;
const isActive =
state.phase !== 'failed' && !isCompleted && index === activeStepIndex;
const isAssetStep = step.id === state.phase && state.totalAssetCount > 0;
@@ -618,22 +625,64 @@ function resolveJumpHopPhaseByElapsedMs(
return 'jump-hop-draft';
}
function resolveWoodenFishPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 270_000) {
return 'wooden-fish-write-draft';
function buildWoodenFishPhaseTimeline(): Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
| 'wooden-fish-draft'
| 'wooden-fish-hit-object'
| 'wooden-fish-background'
| 'wooden-fish-back-button'
| 'wooden-fish-write-draft'
>;
durationMs: number;
}> {
return [
{
phase: 'wooden-fish-draft',
durationMs: WOODEN_FISH_COMPILE_EXPECTED_MS,
},
{
phase: 'wooden-fish-hit-object',
durationMs: WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS,
},
{
phase: 'wooden-fish-background',
durationMs: WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS,
},
{
phase: 'wooden-fish-back-button',
durationMs: WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS,
},
{
phase: 'wooden-fish-write-draft',
durationMs: WOODEN_FISH_WRITE_DRAFT_EXPECTED_MS,
},
];
}
function resolveWoodenFishTimelineByElapsedMs(elapsedMs: number) {
let elapsedBeforePhase = 0;
for (const item of buildWoodenFishPhaseTimeline()) {
const elapsedInPhase = elapsedMs - elapsedBeforePhase;
if (elapsedInPhase < item.durationMs) {
return {
phase: item.phase,
activeStepProgressRatio: Math.max(
0,
Math.min(1, elapsedInPhase / item.durationMs),
),
};
}
elapsedBeforePhase += item.durationMs;
}
if (elapsedMs >= 240_000) {
return 'wooden-fish-hit-sound';
}
if (elapsedMs >= 120_000) {
return 'wooden-fish-background';
}
if (elapsedMs >= 12_000) {
return 'wooden-fish-hit-object';
}
return 'wooden-fish-draft';
return {
phase: 'wooden-fish-write-draft' as const,
activeStepProgressRatio: 1,
};
}
function resolvePuzzleTimelineByElapsedMs(
@@ -683,12 +732,23 @@ export function buildMiniGameDraftGenerationProgress(
state.phase !== 'ready'
? resolvePuzzleTimelineByElapsedMs(elapsedMs, state)
: null;
const woodenFishTimeline =
state.kind === 'wooden-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? resolveWoodenFishTimelineByElapsedMs(elapsedMs)
: null;
const normalizedState =
puzzleTimeline != null
? {
...state,
phase: puzzleTimeline.phase,
}
: woodenFishTimeline != null
? {
...state,
phase: woodenFishTimeline.phase,
}
: state.kind === 'big-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
@@ -724,13 +784,6 @@ export function buildMiniGameDraftGenerationProgress(
...state,
phase: resolveJumpHopPhaseByElapsedMs(elapsedMs),
}
: state.kind === 'wooden-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveWoodenFishPhaseByElapsedMs(elapsedMs),
}
: state;
const steps =
@@ -766,7 +819,7 @@ export function buildMiniGameDraftGenerationProgress(
: normalizedState.kind === 'jump-hop'
? 0.5
: normalizedState.kind === 'wooden-fish'
? 0.5
? (woodenFishTimeline?.activeStepProgressRatio ?? 0)
: 0;
const overallProgress =
normalizedState.phase === 'failed'
@@ -779,6 +832,8 @@ export function buildMiniGameDraftGenerationProgress(
? overallProgress
: normalizedState.kind === 'puzzle'
? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress)
: normalizedState.kind === 'wooden-fish'
? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress)
: overallProgress;
return {