Merge branch 'master' into codex/tiaoyitiao

This commit is contained in:
2026-06-07 00:57:38 +08:00
37 changed files with 2734 additions and 194 deletions

View File

@@ -16,6 +16,14 @@
---
## 2026-06-06 小程序微信绑定展示使用原生昵称组件
- 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。
- 决策:小程序登录页先展示原生 `input type="nickname"`,将昵称作为 `displayName``/api/auth/wechat/miniprogram-login` 提交;若还需要绑定手机号,再随 `/api/auth/wechat/bind-phone` 一并提交。`wechatDisplayName` 只能来自微信平台 profile、历史已保存的微信身份资料或小程序原生昵称组件不能用系统账号显示名或“微信旅人”兜底。小程序侧拿不到昵称时前端使用后端下发的 `wechatAccount`openid / provider_uid尾号展示避免只显示裸“已绑定”。
- 影响范围:`platform-auth` 小程序登录 profile、`module-auth` 微信身份持久化、`api-server` 小程序登录 / 绑定响应、账号信息面板、项目基线和后端契约文档。
- 验证方式:`npm run test -- src/components/auth/AccountModal.test.tsx``cargo test -p platform-auth --manifest-path server-rs/Cargo.toml``cargo test -p module-auth --manifest-path server-rs/Cargo.toml``cargo test -p api-server --manifest-path server-rs/Cargo.toml wechat_miniprogram``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-03 拼消消收敛为单关 6x6 与 4-sheet 素材策略
- 背景:最初 4 关 / 135 次消除 / 单张大 atlas 方案生图数量和空间一致性成本过高,真实 image2 结果容易被布局提示词诱导成带文字、边框或编号的说明图,不适合运行态 1x1 切片。

View File

@@ -2042,3 +2042,19 @@
- 处理:含中文提示词的 live 验证优先写成 UTF-8 `.mjs` 文件再执行,或使用能确认 UTF-8 的运行入口;执行后先检查本次 `request.json` 是否保留真实中文,再判断生图质量。不要基于 `????` prompt 生成的图片调整项目提示词。
- 验证:生成前后检查 `request.json`,其中 `prompt` 字段应显示中文而不是问号;同一提示词在 UTF-8 文件脚本下应能得到符合主题的图。
- 关联:`.codex/skills/gpt-image-2-apimart/SKILL.md``server-rs/crates/api-server/src/jump_hop.rs`
## 自动试玩退出不要回到生成页
- 现象:拼图草稿生成完成后自动进入试玩,用户从试玩退出或使用系统返回时落回生成进度页,页面还暴露“重新生成”按钮。
- 原因:自动试玩前如果没有先把 `/creation/puzzle/result` 写成 `/runtime/puzzle` 的浏览器历史前一站,系统返回会命中旧的生成页历史项;仅靠运行态内部 `returnStage='puzzle-result'` 只能覆盖运行态按钮返回,不能覆盖浏览器 / WebView 系统返回。
- 处理:所有“生成完成后自动进入草稿试玩”的分支在 `openPuzzleRuntimeStage(...)` 前都必须调用结果页历史写入 helper`/creation/puzzle/result` 与当前 `sessionId/profileId/workId` 写入历史;运行态按钮返回到 `puzzle-result` 时也同步写回创作恢复 query。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial and runtime back opens draft result"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## CreativeImageInputPanel 主图点击默认预览
- 现象:复用 `CreativeImageInputPanel` 的结果页 / 编辑页已有主图时用户点击图片却触发上传无法直接查看大图不同玩法若各自手写上传按钮会让主图、历史图、AI 重绘和参考图行为再次分叉。
- 原因:旧主图卡整卡是上传 label缺少主图预览模式和上传 / 历史入口的显式控制参数。
- 处理:通用面板已有主图时默认点击主图打开全屏预览,上传 / 更换收口到右下角 `ImagePlus` 图标按钮;无图时仍允许点击空图卡上传。调用方用 `canUploadMainImage``canUseImageHistory` 分别控制上传与历史按钮,不要复制面板或用样式遮挡按钮。
- 验证:`npm run test -- src/components/common/CreativeImageInputPanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- 关联:`src/components/common/CreativeImageInputPanel.tsx``src/components/puzzle-result/PuzzleResultView.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`

View File

@@ -0,0 +1,81 @@
# 敲木鱼音频延迟上传与本地标准化测试用例
## 覆盖目标
- 选择上传或录音结束后只在浏览器本地处理,不请求 OSS 上传凭证。
- 用户点击 `生成` 时才上传处理后的音频 Blob/File并把确认后的 `WoodenFishAudioAsset` 放入创建 session payload。
- 上传和录音统一执行前后声音过小片段裁切、最长 1 秒限制、近似 `-15 LKFS` 响度平衡和峰值保护。
- 音频面板明确显示 `最长 1 秒`,并正确处理上传、录音、重置、禁用和错误状态。
- OSS 上传 client 只接收 Blob/File不接受 Data URL并覆盖上传凭证、OSS POST、资产确认和错误分支。
## 音频处理 helper
- 空文件:`size=0`,报 `音频文件为空,请重新选择。`
- 非音频 MIME`text/plain`,报 `请选择音频文件。`
- 浏览器没有 `AudioContext`:报 `当前浏览器不支持音频处理。`
- `decodeAudioData` 失败:报 `音频解码失败,请重新选择。`
- 全静音或声音全低于阈值:报 `音频声音过小,请重新录制或上传。`
- 前后静音裁切:低于阈值的头尾帧被裁掉,`startFrame``frameCount` 正确。
- 裁切后刚好 `1000ms`:允许通过。
- 裁切后超过 `1000ms`:报 `音频最长 1 秒。`
- 上传来源 `uploaded` 与录音来源 `recorded`:返回 pending asset 保留对应 source。
- 原文件名有扩展名:输出 `.wav` 文件名;无扩展名补 `.wav`;空白文件名输出 `creative-audio.wav`
- `URL.createObjectURL` 存在:`audioSrc` / `previewUrl` 为 blob URL不存在时返回空字符串且不阻断处理。
- 近似响度平衡:低 RMS 样本被拉向 `-15 LKFS` 目标。
- 峰值保护:高峰值样本增益后不超过 `peakCeiling`
- 零能量 section归一化阶段报声音过小。
- WAV 编码:写入 RIFF/WAVE/data header、PCM16 数据长度和采样值。
## 音频输入面板
- 传入 `limitLabel` 时显示 `最长 1 秒`;未传入时不显示限制标签。
- 无资产时显示默认音效文案。
- 有资产且 `audioSrc` 存在时渲染 `<audio controls>`
- 有资产但无 `audioSrc` 时显示 `音效已选择`
- 点击重置调用 `onAssetChange(null)`
- 上传取消选择时不读取音频、不写入资产。
- 上传成功后调用 `readFileAsAsset(file, 'uploaded')`,清空错误并写入资产。
- 上传失败时展示错误,不写入资产。
- 浏览器不支持录音时提示 `当前浏览器不支持录音。`
- 麦克风启动失败时透传启动错误。
- 录音停止后把 Blob 包成 File并以 `recorded` 来源读取。
- 录音保存失败时展示错误。
- disabled 状态不启动录音,文件输入禁用。
## 木鱼工作台链路
- 音频面板显示 `最长 1 秒`,并只保留上传和录音入口。
- 选择上传音频后只调用本地处理 helper不调用 `uploadWoodenFishHitSoundAsset`
- 点击 `生成` 且有 pending 音频时,先上传处理后的 WAV再调用 `woodenFishClient.createSession`
- 上传给 OSS 的文件是处理后的 WAV文件名和 MIME 为 `.wav` / `audio/wav`
- 提交 payload 使用 OSS confirmed asset不包含 `data:audio`
- 未选择音频时不上传 OSSpayload 使用默认木鱼音。
- 处理阶段报超过 1 秒时展示错误,不写入用户音频;继续生成时走默认木鱼音。
- OSS 上传失败时停留工作台,展示错误,不创建 session。
- `createSession` 失败时停留工作台,展示错误。
- 提交中重复点击 `生成` 不重复上传、不重复创建 session。
- 替换本地音频时回收旧 `previewUrl`
## 木鱼音频上传 client
- 空 Blob/File、超过 20MB、非音频 MIME 均在本地拒绝,不创建上传凭证。
- File 上传默认使用 `file.name``file.type`
- Blob 上传支持通过显式文件名扩展推断 `audio/wav` 等音频 MIME。
- Blob 缺少 MIME 且扩展未知时拒绝上传。
- direct upload ticket 请求包含 `legacyPrefix`、path segments、fileName、contentType、access、maxSizeBytes 和木鱼音频 metadata。
- OSS POST 成功后调用 `/api/assets/objects/confirm`
- OSS POST 非 2xx 时提示 `上传敲击音效失败。`,不确认资产对象。
- confirm 失败时透传确认错误。
- confirm 请求包含 bucket、objectKey、contentType、contentLength、assetKind、accessPolicy 和 entityId。
- 成功返回的 `WoodenFishAudioAsset` 包含 assetObjectId、audioObjectKey、audioSrc、source 和 prompt。
## 验证命令
```bash
npm run test -- src/components/common/creativeAudioProcessing.test.ts
npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx
npm run test -- src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx
npm run test -- src/services/wooden-fish/woodenFishAssetClient.test.ts
npm run typecheck
npm run check:encoding
```

View File

@@ -75,7 +75,7 @@ npm run check:server-rs-ddd
### 认证态用户与会话摘要下发口径
- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id``publicUserCode``displayName``avatarUrl``phoneNumberMasked``loginMethod``bindingStatus``wechatBound`
- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id``publicUserCode``displayName``avatarUrl``phoneNumber``phoneNumberMasked``loginMethod``bindingStatus``wechatBound``wechatDisplayName``wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login``/api/auth/wechat/bind-phone` 可接收 `displayName``/api/auth/wechat/miniprogram-login` 额外返回 `created`,供小程序壳在快捷登录后判断是否需要补采集微信昵称。`jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号
- `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId``sessionIds``sessionCount``clientLabel``ipMasked``isCurrent``createdAt``lastSeenAt``expiresAt`
- 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO而不是把账号 / 会话快照恢复为全量对象。
@@ -175,7 +175,7 @@ npm run check:server-rs-ddd
- Match3D 1:1 容器 UIVectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!``api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
- 敲木鱼敲击物和背景环境图VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图第一张固定为后端内嵌默认木鱼图用户上传图只作为新主题参考prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。
- Hyper3D / Rodin只保留后端安全代理和旧数据兼容Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d``api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。
- 音频视觉小说专用音频路由保留VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio``api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`
- 音频视觉小说专用音频路由保留VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio``api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;前端选择或录音阶段只在浏览器本地处理待提交音频,统一限制裁切后最长 1 秒、裁掉前后声音过小片段,并用浏览器端近似响度算法平衡到 `-15 LKFS` 后做峰值保护。点击生成时才直传 OSS 并确认 `asset_object`,创作 JSON 只提交轻量 `WoodenFishAudioAsset`,不得继续上传 Data URL 音频;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`
- OSS私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation``object_key` / `key_prefix``status_class``error_kind``elapsed_ms` 下钻。
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send``response_body``upstream_status``response_parse``missing_image``image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure``scope_kind = module``scope_id = provider``module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outboxoutbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event``event_key = external_generation_run``scope_kind = module``scope_id = provider``module_key = external-generation`。metadata 固定包含 `runId``provider``operation``requestLabel``requestPayload``status``success``failureReason``providerRequestId``resultPayload``startedAtMicros``completedAtMicros``durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。

View File

@@ -1,6 +1,6 @@
# 平台入口与玩法链路
更新时间:`2026-06-04`
更新时间:`2026-06-06`
## 平台创作入口
@@ -42,7 +42,7 @@
默认工作台只提交结构化表单、图片槽位和配置 payload不默认增加聊天输入区、流式消息区或轻输入 Agent。确需偏离该模式时必须先在 PRD 和本文档写明例外原因、影响范围和回退方式,再进入编码。
单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图和删除确认;新玩法页面不得重复手写这些交互。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec``slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。
单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图、主图预览和删除确认;新玩法页面不得重复手写这些交互。主图已有图片时,默认点击图片打开全屏预览,上传 / 更换收口到右下角 `ImagePlus` 图标按钮;无图时仍允许点击空图卡上传。调用方只能通过 `canUploadMainImage``canUseImageHistory` 等受控参数开关上传和历史入口,不得用复制组件或样式遮挡改行为。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec``slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。
通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets``n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、默认绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求;高风险撞色玩法可显式使用专用 key 色、关闭近白扣除并限制为边缘连通背景扣除。`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。
@@ -64,8 +64,12 @@
发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、账号生成的脱敏手机号、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。推荐页运行态、标题和作者信息必须使用同一套公开作品 key 选中当前条目;新增或补齐公开玩法类型时复用 `buildPlatformPublicGalleryCardKey(...)`,避免运行内容已切换但标题 / 作者仍退回第一条作品。
移动端底部导航的创作按钮在登录前后必须保持同一个图片化创作图标,不因登录态切换成加号。
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、通用设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;主题设置、账号与安全只放在通用设置弹窗下一级,不在外层单独占行;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度,外层卡片不展示“去完成”等行动按钮。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
平台应用隐藏浏览器根节点 `html` / `body` / `#root` 和平台页面级滚动容器的最外层滚动条可见轨道;弹窗、列表、运行态侧栏等内部滚动容器继续使用原有滚动条样式或显式 `.scrollbar-hide` 控制。
## RPG / 自定义世界
当前 RPG 创作入口使用 `playId = rpg`,工程域和运行态源类型沿用历史 `custom-world`。默认入口状态为 `visible=true``open=true`,对外展示为“文字冒险”;`airp` 仍是独立的“AI RPG”占位入口保持 `open=false`,不要把它当作当前 RPG 创作链路开放。
@@ -109,7 +113,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进。`88/94/96` 只负责切换当前步骤,不作为总进度地板;总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`。任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。
- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。生成失败后,同一浏览器会话内的失败 notice 必须覆盖后端可能仍短暂返回的 `generationStatus=generating` 摘要,作品架保留对应草稿卡但不再显示“生成中”,点击后回到失败 / 重试状态。
- 拼图草稿编译是长耗时 action前端 action 请求默认等待 `1_800_000ms`30 分钟)且不自动重试。每次图片生成调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面``生成UI与背景`,合计约 208 秒。生成页恢复时必须使用后端 session `updatedAt` 或作品摘要 `updatedAt` 作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。
- 拼图草稿编译是长耗时 action前端 action 请求默认等待 `1_800_000ms`30 分钟)且不自动重试。每次图片生成调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面``生成UI与背景`,合计约 208 秒。生成页恢复时必须使用后端 session `updatedAt` 或作品摘要 `updatedAt` 作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。生成完成后若自动进入草稿试玩,进入 `/runtime/puzzle` 前必须先把 `/creation/puzzle/result` 和当前 `sessionId/profileId/workId` 写成浏览器历史前一站;运行态返回按钮和系统返回都应回到结果页,不得退回生成进度页或暴露重新生成入口。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。
- 前端创作、结果页、生成页和错误提示不展示 GPT / Gemini 等具体模型名称如需在内部保留模型路由UI 只使用“标准模式”“创意模式”等产品化名称。
- 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。
- 拼图参考图 AI 重绘走 VectorEngine `/v1/images/edits`;无参考图时走 `/v1/images/generations`。两者模型都使用 `gpt-image-2`,参考图由后端作为 multipart `image` part 传入编辑接口。
@@ -129,6 +133,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品;但这仍属于同一个 runtime run 内部推进,不能触发推荐 rail 切卡动画、纵向位移或启动封面重置,已挂载且 ready 的运行态画面应保持稳定,只静默更新作品信息和操作基准。
- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`
- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`
- 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。
@@ -185,7 +190,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
创作输入固定为:
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. `敲击音效`:音频资产槽位,当前创作阶段只支持用户上传或麦克风录制;麦克风录制结束后,前端会自动裁掉音频开头连续静音段,再把裁剪后的录音作为 `recorded` 音频资产写入表单。上传音频不做裁剪;浏览器音频解码或裁剪失败时保留原始录音继续保存,不能让用户录音丢失。未提供音频时统一写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。提示词生成音效入口临时关闭,通用 `/api/creation/audio/sound-effect` 对木鱼 `hit_sound` 目标也返回 `410 Gone``hitSoundPrompt` 只作为历史兼容字段保留,不参与当前创作流程,也不得由 `spacetime-client` 合成假音频路径。
2. `敲击音效`:音频资产槽位,当前创作阶段只支持用户上传或麦克风录制;音频面板必须在前端明确显示 `最长 1 秒`。选择文件或录音结束后,前端只在浏览器本地解码并生成待提交音频对象,不在选择阶段请求 `/api/assets/direct-upload-tickets`。上传和录音统一裁掉前后声音过小片段,裁切后仍超过 1 秒时提示错误且不写入表单状态;有效音频按浏览器端近似算法做响度平衡,目标为 GY/T 377-2023 口径下的 `-15 LKFS`,并做峰值保护后重新编码为可上传 Blob。用户点击 `生成` 时才把处理后的音频直传 OSS、确认 `asset_object`,创作 session/action 只提交 `hitSoundAsset.assetObjectId``audioSrc` 和对象 key 等轻量字段;未提供音频时统一写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。提示词生成音效入口临时关闭,通用 `/api/creation/audio/sound-effect` 对木鱼 `hit_sound` 目标也返回 `410 Gone``hitSoundPrompt` 只作为历史兼容字段保留,不参与当前创作流程,也不得由 `spacetime-client` 合成假音频路径。后端对敲木鱼创作 JSON 的放宽 body limit 仅用于兼容旧小程序 Data URL 请求,不作为新链路输入方式。
3. `功德有什么`:最多 8 条飘字,创作态首屏只保留一个默认词条 `幸运`,其下提供加号格继续追加词条;创作态只保存词条名,运行态飘字展示时再追加 `+1`。运行态顶部总数卡采用品牌化徽标样式,子项计数器预置展示在可展开面板中,未出现词条初始值为 0。
4. `作品标题 / 作品简介 / 主题标签`:不再放在创作工作台首屏,改为生成草稿后的结果页补录区,提交试玩或发布前必须先写回当前作品信息。主题标签编辑样式对齐拼图结果页的胶囊标签编辑器。

View File

@@ -45,11 +45,11 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。
3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。
4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。
5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权,再调用 `/api/auth/wechat/miniprogram-login` `/api/auth/wechat/bind-phone` 换取系统登录态。
5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一通过 `wx.login` 获取微信登录 code 并调用 `/api/auth/wechat/miniprogram-login` 完成快捷登录。若该接口返回 `created=true`,或返回用户昵称仍是手机号、公开陶泥号、“微信旅人”等默认展示值,才展示原生 `input type="nickname"` 补充微信昵称并再次调用 `/api/auth/wechat/miniprogram-login` 写入 `displayName`。若后端返回 `pending_bind_phone`,再通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权并调用 `/api/auth/wechat/bind-phone` 换取系统登录态。
6. 小程序外壳注入到 H5 URL 的 `clientType``clientRuntime``miniProgramEnv` 是宿主上下文H5 内部 `pushState` / 阶段导航必须跨页面保留,避免登录和充值误判为普通浏览器;首点时微信 JS bridge 可能尚未就绪,前端还需用 `MicroMessenger + miniProgram` User-Agent 作为小程序识别兜底。
7. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。
8. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release``trial``dev`
9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信展示微信昵称而不是微信账号标识,换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。
9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自快捷登录后按需展示的原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。
## 账户与充值

View File

@@ -107,6 +107,62 @@ function parseBooleanQueryFlag(value) {
return value === true || value === '1' || value === 'true' || value === 'yes';
}
function normalizeNicknameInput(value) {
return String(value || '').trim();
}
function normalizeNicknameForMatch(value) {
return normalizeNicknameInput(value).replace(/\s+/gu, '').toLowerCase();
}
function isPhoneLikeDisplayName(value) {
const normalized = normalizeNicknameForMatch(value);
if (!normalized) {
return false;
}
const digits = normalized.replace(/\D/gu, '');
return (
/^(\+?86)?1\d{10}$/u.test(normalized) ||
/^1\d{2}\*{4}\d{4}$/u.test(normalized) ||
(/[*x]/iu.test(normalized) && digits.length >= 7) ||
digits.length >= 11
);
}
function isDefaultDisplayName(value, publicUserCode) {
const normalized = normalizeNicknameForMatch(value);
const normalizedPublicUserCode = normalizeNicknameForMatch(publicUserCode);
if (!normalized) {
return true;
}
return (
normalized === '微信旅人' ||
normalized === '玩家' ||
normalized === normalizedPublicUserCode ||
/^sy-\d{8}$/iu.test(normalized) ||
/^user[_-]/iu.test(normalized) ||
isPhoneLikeDisplayName(normalized)
);
}
function shouldRequestNicknameAfterLogin(authResult) {
const user = authResult && authResult.user ? authResult.user : {};
const wechatDisplayName = normalizeNicknameInput(user.wechatDisplayName);
if (wechatDisplayName && !isDefaultDisplayName(wechatDisplayName, user.publicUserCode)) {
return false;
}
return (
authResult &&
(authResult.created ||
isDefaultDisplayName(user.displayName, user.publicUserCode) ||
(wechatDisplayName &&
isDefaultDisplayName(wechatDisplayName, user.publicUserCode)))
);
}
function normalizeMiniProgramEnv(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'release') {
@@ -268,7 +324,7 @@ function wxLogin() {
});
}
function requestMiniProgramLogin(code) {
function requestMiniProgramLogin(code, displayName) {
return new Promise((resolve, reject) => {
const runtimeConfig = resolveMiniProgramRuntimeConfig();
const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
@@ -280,7 +336,10 @@ function requestMiniProgramLogin(code) {
wx.request({
url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`,
method: 'POST',
data: { code },
data: {
code,
...(displayName ? { displayName } : {}),
},
header: {
'content-type': 'application/json',
'x-client-type': MINI_PROGRAM_CLIENT_TYPE,
@@ -310,7 +369,7 @@ function requestMiniProgramLogin(code) {
});
}
function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
function requestMiniProgramBindPhone(authToken, wechatPhoneCode, displayName) {
return new Promise((resolve, reject) => {
const runtimeConfig = resolveMiniProgramRuntimeConfig();
const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl);
@@ -322,7 +381,10 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
wx.request({
url: `${apiBaseUrl}/api/auth/wechat/bind-phone`,
method: 'POST',
data: { wechatPhoneCode },
data: {
wechatPhoneCode,
...(displayName ? { displayName } : {}),
},
header: {
authorization: `Bearer ${authToken}`,
'content-type': 'application/json',
@@ -353,15 +415,17 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) {
});
}
async function resolveAuthResult() {
async function resolveAuthResult(displayName) {
const code = await wxLogin();
const response = await requestMiniProgramLogin(code);
const response = await requestMiniProgramLogin(code, displayName);
if (!response || !response.token) {
throw new Error('服务器未返回登录态');
}
return {
token: response.token,
bindingStatus: response.bindingStatus || 'pending_bind_phone',
user: response.user || null,
created: response.created === true,
};
}
@@ -370,7 +434,10 @@ Page({
authResult: null,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: true,
nicknameInput: '',
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage: false,
webViewUrl: '',
@@ -395,6 +462,7 @@ Page({
if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) {
this.setData({
authResult: null,
bindingPhone: false,
errorMessage: '',
loading: false,
phoneBindingRequired: false,
@@ -414,20 +482,65 @@ Page({
}
this.setData({
authResult: null,
bindingPhone: false,
errorMessage: '',
loggingIn: true,
loading: true,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
errorMessage: '',
webViewUrl: '',
});
await this.startAuthFlow(returnToPreviousPage, '');
},
handleNicknameInput(event) {
this.setData({
nicknameInput: event.detail ? event.detail.value : '',
});
},
async handleStartLogin() {
const displayName = normalizeNicknameInput(this.data.nicknameInput);
if (!displayName) {
this.setData({
errorMessage: '请先选择或填写微信昵称。',
});
return;
}
this.setData({
errorMessage: '',
loggingIn: true,
});
await this.startAuthFlow(this.data.returnToPreviousPage, displayName);
},
async startAuthFlow(returnToPreviousPage, displayName) {
try {
const authResult = await resolveAuthResult();
const authResult = await resolveAuthResult(displayName);
if (!displayName && shouldRequestNicknameAfterLogin(authResult)) {
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: true,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: '',
});
return;
}
if (authResult.bindingStatus === 'pending_bind_phone') {
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: true,
returnToPreviousPage,
webViewUrl: '',
@@ -437,6 +550,16 @@ Page({
if (returnToPreviousPage) {
persistAuthResult(authResult);
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: '',
});
wx.navigateBack();
return;
}
@@ -444,7 +567,9 @@ Page({
this.setData({
authResult,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: resolveWebViewUrl(authResult),
@@ -454,7 +579,9 @@ Page({
authResult: null,
errorMessage:
error && error.message ? error.message : '微信登录失败,请稍后重试。',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage,
webViewUrl: '',
@@ -466,6 +593,13 @@ Page({
const authResult = consumeAuthResult();
if (authResult) {
this.setData({
authResult,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(authResult),
});
}
@@ -510,6 +644,7 @@ Page({
const response = await requestMiniProgramBindPhone(
this.data.authResult.token,
detail.code,
normalizeNicknameInput(this.data.nicknameInput),
);
if (!response || !response.token) {
throw new Error('服务器未返回绑定后的登录态');
@@ -523,7 +658,9 @@ Page({
this.setData({
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
});
wx.navigateBack();
@@ -533,7 +670,9 @@ Page({
authResult: nextAuthResult,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: false,
nicknameRequired: false,
phoneBindingRequired: false,
webViewUrl: resolveWebViewUrl(nextAuthResult),
});
@@ -553,7 +692,10 @@ Page({
authResult: null,
bindingPhone: false,
errorMessage: '',
loggingIn: false,
loading: true,
nicknameInput: '',
nicknameRequired: false,
phoneBindingRequired: false,
returnToPreviousPage: false,
webViewUrl: '',

View File

@@ -8,6 +8,32 @@
/>
</block>
<view wx:elif="{{nicknameRequired}}" class="setup-screen">
<view class="setup-card">
<view class="setup-title">完善昵称</view>
<view wx:if="{{errorMessage}}" class="setup-text setup-text--danger">
{{errorMessage}}
</view>
<input
class="nickname-input"
type="nickname"
value="{{nicknameInput}}"
placeholder="微信昵称"
disabled="{{loggingIn}}"
bindinput="handleNicknameInput"
bindblur="handleNicknameInput"
/>
<button
class="retry-button"
loading="{{loggingIn}}"
disabled="{{loggingIn}}"
bindtap="handleStartLogin"
>
{{loggingIn ? '正在提交' : '确认昵称'}}
</button>
</view>
</view>
<view wx:elif="{{loading}}" class="setup-screen">
<view class="setup-card">
<view class="setup-title">正在登录</view>

View File

@@ -36,6 +36,19 @@
color: #ffb4a9;
}
.nickname-input {
margin-top: 28rpx;
width: 100%;
min-height: 88rpx;
padding: 0 24rpx;
border: 1rpx solid rgba(255, 255, 255, 0.22);
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.1);
color: #f5f7fb;
font-size: 28rpx;
box-sizing: border-box;
}
.retry-button {
margin-top: 28rpx;
width: 100%;

View File

@@ -126,6 +126,7 @@ export type AuthWechatBindPhoneRequest = {
phone?: string;
code?: string;
wechatPhoneCode?: string;
displayName?: string;
};
export type AuthWechatBindPhoneResponse = {
@@ -135,12 +136,14 @@ export type AuthWechatBindPhoneResponse = {
export type AuthWechatMiniProgramLoginRequest = {
code: string;
displayName?: string;
};
export type AuthWechatMiniProgramLoginResponse = {
token: string;
bindingStatus: AuthBindingStatus;
user: AuthUser;
created: boolean;
};
export type AuthPhoneChangeRequest = {

View File

@@ -1504,6 +1504,88 @@ mod tests {
assert!(!body_text.contains("length limit exceeded"));
}
#[tokio::test]
async fn wooden_fish_session_creation_accepts_legacy_audio_body_above_default_limit() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user =
seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_audio_body");
let app = build_router(state);
let request_body = format!(
"{{\"templateId\":\"wooden-fish\",\"hitSoundAsset\":{{\"audioSrc\":\"data:audio/webm;base64,{}\"}}",
"A".repeat(3 * 1024 * 1024)
);
assert!(request_body.len() > 2 * 1024 * 1024);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/creation/wooden-fish/sessions")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("hitSoundAsset") || body_text.contains("missing field"),
"handler should parse the oversized wooden fish payload before rejecting invalid JSON fields: {body_text}"
);
assert!(!body_text.contains("length limit exceeded"));
}
#[tokio::test]
async fn wooden_fish_actions_accept_legacy_audio_body_above_default_limit() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user =
seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_action_body");
let app = build_router(state);
let request_body = format!(
"{{\"actionType\":\"replace-hit-sound\",\"hitSoundAsset\":{{\"audioSrc\":\"data:audio/webm;base64,{}\"}}",
"A".repeat(3 * 1024 * 1024)
);
assert!(request_body.len() > 2 * 1024 * 1024);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/creation/wooden-fish/sessions/wooden-fish-session-large/actions")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("hitSoundAsset") || body_text.contains("missing field"),
"handler should parse the oversized wooden fish action payload before rejecting invalid JSON fields: {body_text}"
);
assert!(!body_text.contains("length limit exceeded"));
}
#[tokio::test]
async fn password_entry_rejects_unknown_phone_without_registration() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -2502,7 +2584,7 @@ mod tests {
}
#[tokio::test]
async fn wechat_miniprogram_login_returns_system_token_and_marks_session_source() {
async fn wechat_miniprogram_login_returns_system_token_and_marks_session_label() {
let config = AppConfig {
wechat_auth_enabled: true,
..AppConfig::default()
@@ -2524,7 +2606,8 @@ mod tests {
.header("x-mini-program-env", "develop")
.body(Body::from(
serde_json::json!({
"code": "wx-mini-code-001"
"code": "wx-mini-code-001",
"displayName": "微信旅人"
})
.to_string(),
))
@@ -2557,10 +2640,19 @@ mod tests {
login_payload["bindingStatus"],
Value::String("pending_bind_phone".to_string())
);
assert_eq!(login_payload["created"], Value::Bool(true));
assert_eq!(
login_payload["user"]["loginMethod"],
Value::String("wechat".to_string())
);
assert_eq!(
login_payload["user"]["wechatDisplayName"],
Value::String("微信旅人".to_string())
);
assert_eq!(
login_payload["user"]["wechatAccount"],
Value::String("wx-mini-code-001".to_string())
);
assert!(refresh_cookie.contains("genarrative_refresh_session="));
let sessions_response = app
@@ -2585,16 +2677,23 @@ mod tests {
let sessions_payload: Value =
serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
assert_eq!(
sessions_payload["sessions"][0]["clientType"],
Value::String("mini_program".to_string())
sessions_payload["sessions"][0]["clientLabel"],
Value::String("微信小程序 / iPhone".to_string())
);
assert_eq!(
sessions_payload["sessions"][0]["clientRuntime"],
Value::String("wechat_mini_program".to_string())
sessions_payload["sessions"][0]["sessionCount"],
Value::Number(1.into())
);
assert_eq!(
sessions_payload["sessions"][0]["miniProgramAppId"],
Value::String("wx-mini-test".to_string())
sessions_payload["sessions"][0]["isCurrent"],
Value::Bool(true)
);
assert_eq!(
sessions_payload["sessions"][0]["sessionIds"]
.as_array()
.expect("session ids should exist")
.len(),
1
);
}
@@ -2621,7 +2720,8 @@ mod tests {
.header("x-mini-program-env", "develop")
.body(Body::from(
serde_json::json!({
"code": "wx-mini-code-bind-001"
"code": "wx-mini-code-bind-001",
"displayName": "微信旅人"
})
.to_string(),
))
@@ -2647,6 +2747,7 @@ mod tests {
login_payload["bindingStatus"],
Value::String("pending_bind_phone".to_string())
);
assert_eq!(login_payload["created"], Value::Bool(true));
let bind_response = app
.oneshot(
@@ -2663,7 +2764,8 @@ mod tests {
.header("x-mini-program-env", "develop")
.body(Body::from(
serde_json::json!({
"wechatPhoneCode": "13800138000"
"wechatPhoneCode": "13800138000",
"displayName": "微信旅人"
})
.to_string(),
))
@@ -2914,7 +3016,7 @@ mod tests {
}
#[tokio::test]
async fn auth_sessions_returns_multi_device_session_fields() {
async fn auth_sessions_returns_multi_device_session_summaries() {
let state = AppState::new(AppConfig::default()).expect("state should build");
seed_phone_user_with_password(&state, "13800138013", TEST_PASSWORD).await;
let app = build_router(state);
@@ -3014,23 +3116,19 @@ mod tests {
assert_eq!(sessions.len(), 2);
assert!(sessions.iter().any(|session| {
session["clientType"] == Value::String("web_browser".to_string())
&& session["clientRuntime"] == Value::String("chrome".to_string())
&& session["clientPlatform"] == Value::String("windows".to_string())
session["clientLabel"] == Value::String("Windows / Chrome".to_string())
&& session["sessionCount"] == Value::Number(1.into())
&& session["sessionIds"]
.as_array()
.is_some_and(|ids| ids.len() == 1)
&& session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string())
&& session["isCurrent"] == Value::Bool(true)
}));
assert!(sessions.iter().any(|session| {
session["clientType"] == Value::String("mini_program".to_string())
&& session["clientRuntime"] == Value::String("wechat_mini_program".to_string())
session["clientLabel"] == Value::String("微信小程序 / Android".to_string())
&& session["sessionCount"] == Value::Number(1.into())
&& session["miniProgramAppId"] == Value::String("wx-session-test".to_string())
&& session["miniProgramEnv"] == Value::String("release".to_string())
&& session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string())
&& session["sessionIds"]
.as_array()
.is_some_and(|ids| ids.len() == 1)
&& session["isCurrent"] == Value::Bool(false)
}));
}

View File

@@ -1,24 +1,33 @@
use axum::{
Router, middleware,
routing::{delete, get, post},
Router,
extract::DefaultBodyLimit,
middleware,
routing::{get, post},
};
use crate::{
auth::{require_bearer_auth, require_runtime_principal_auth},
state::AppState,
wooden_fish::{
checkpoint_wooden_fish_run, create_wooden_fish_session, delete_wooden_fish_work,
execute_wooden_fish_action, finish_wooden_fish_run, get_wooden_fish_gallery_detail,
get_wooden_fish_runtime_work, get_wooden_fish_session, list_wooden_fish_gallery,
list_wooden_fish_works, publish_wooden_fish_work, start_wooden_fish_run,
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,
get_wooden_fish_session, list_wooden_fish_gallery, list_wooden_fish_works,
publish_wooden_fish_work, start_wooden_fish_run,
},
};
const WOODEN_FISH_CREATION_BODY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/creation/wooden-fish/sessions",
post(create_wooden_fish_session).route_layer(middleware::from_fn_with_state(
post(create_wooden_fish_session)
// 中文注释:兼容旧小程序把参考图或录音 Data URL 放进创作 JSON 的请求;新前端音频会先直传 OSS。
.layer(DefaultBodyLimit::max(
WOODEN_FISH_CREATION_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
@@ -32,7 +41,12 @@ pub fn router(state: AppState) -> Router<AppState> {
)
.route(
"/api/creation/wooden-fish/sessions/{session_id}/actions",
post(execute_wooden_fish_action).route_layer(middleware::from_fn_with_state(
post(execute_wooden_fish_action)
// 中文注释compile/regenerate 会携带参考图旧兼容输入,避免 Axum 默认 2MB 先于 handler 拦截。
.layer(DefaultBodyLimit::max(
WOODEN_FISH_CREATION_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
@@ -44,13 +58,6 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/creation/wooden-fish/works/{profile_id}",
delete(delete_wooden_fish_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/wooden-fish/works/{profile_id}/publish",
post(publish_wooden_fish_work).route_layer(middleware::from_fn_with_state(

View File

@@ -14,6 +14,7 @@ use shared_contracts::auth::{
WechatMiniProgramLoginRequest, WechatMiniProgramLoginResponse, WechatStartQuery,
WechatStartResponse,
};
use shared_kernel::normalize_optional_string;
use time::OffsetDateTime;
use url::Url;
@@ -208,6 +209,7 @@ pub async fn bind_wechat_phone(
.bind_wechat_verified_phone(BindWechatVerifiedPhoneInput {
user_id: authenticated.claims().user_id().to_string(),
phone_number: phone_profile.phone_number,
wechat_display_name: payload.display_name.clone(),
})
.await
.map_err(map_wechat_bind_phone_error)?
@@ -235,6 +237,7 @@ pub async fn bind_wechat_phone(
user_id: authenticated.claims().user_id().to_string(),
phone_number: phone.to_string(),
verify_code: code.to_string(),
wechat_display_name: payload.display_name.clone(),
},
OffsetDateTime::now_utc(),
)
@@ -313,7 +316,7 @@ pub async fn login_wechat_mini_program(
let result = state
.wechat_auth_service()
.resolve_login(module_auth::ResolveWechatLoginInput {
profile: map_wechat_profile_to_domain(profile),
profile: map_wechat_profile_to_domain_with_display_name(profile, payload.display_name),
})
.await
.map_err(map_wechat_auth_error)?;
@@ -346,6 +349,7 @@ pub async fn login_wechat_mini_program(
token: signed_session.access_token,
binding_status: result.user.binding_status.as_str().to_string(),
user: map_auth_user_payload(result.user),
created: result.created,
},
),
))
@@ -389,6 +393,17 @@ fn map_wechat_profile_to_domain(
}
}
fn map_wechat_profile_to_domain_with_display_name(
profile: platform_auth::WechatIdentityProfile,
display_name: Option<String>,
) -> module_auth::WechatIdentityProfile {
let mut profile = map_wechat_profile_to_domain(profile);
if let Some(display_name) = normalize_optional_string(display_name) {
profile.display_name = Some(display_name);
}
profile
}
fn normalize_redirect_path(raw_value: Option<&str>, fallback: &str) -> String {
let Some(raw_value) = raw_value.map(str::trim).filter(|value| !value.is_empty()) else {
return fallback.to_string();

View File

@@ -65,12 +65,14 @@ pub struct BindWechatPhoneInput {
pub user_id: String,
pub phone_number: String,
pub verify_code: String,
pub wechat_display_name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatVerifiedPhoneInput {
pub user_id: String,
pub phone_number: String,
pub wechat_display_name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]

View File

@@ -111,11 +111,7 @@ fn hydrate_private_auth_fields(
.find(|identity| identity.user_id == hydrated.user.id);
if hydrated.user.wechat_display_name.is_none() {
hydrated.user.wechat_display_name = hydrated_wechat_identity
.and_then(|identity| identity.display_name.clone())
.or_else(|| {
(hydrated.user.login_method == AuthLoginMethod::Wechat)
.then(|| hydrated.user.display_name.clone())
});
.and_then(|identity| normalize_optional_string(identity.display_name.clone()));
}
if hydrated.user.wechat_account.is_none() {
hydrated.user.wechat_account =
@@ -655,9 +651,11 @@ impl PhoneAuthService {
return Err(PhoneAuthError::UserStateMismatch);
}
let (merged_user, activated_new_user) = self
.store
.bind_wechat_phone_to_user(&input.user_id, normalized_phone)?;
let (merged_user, activated_new_user) = self.store.bind_wechat_phone_to_user(
&input.user_id,
normalized_phone,
input.wechat_display_name,
)?;
Ok(BindWechatPhoneResult {
user: merged_user,
@@ -711,9 +709,11 @@ impl PhoneAuthService {
return Err(PhoneAuthError::UserStateMismatch);
}
let (merged_user, activated_new_user) = self
.store
.bind_wechat_phone_to_user(&input.user_id, normalized_phone)?;
let (merged_user, activated_new_user) = self.store.bind_wechat_phone_to_user(
&input.user_id,
normalized_phone,
input.wechat_display_name,
)?;
Ok(BindWechatPhoneResult {
user: merged_user,
@@ -1365,8 +1365,7 @@ impl InMemoryAuthStore {
.filter(|value| !value.is_empty())
.unwrap_or("微信旅人")
.to_string();
let wechat_display_name = normalize_optional_string(profile.display_name.clone())
.or_else(|| Some(display_name.clone()));
let wechat_display_name = normalize_optional_string(profile.display_name.clone());
let username = build_wechat_username(&display_name, &profile.provider_uid);
let provider_uid = normalize_required_string(&profile.provider_uid).unwrap_or_default();
let user = AuthUser {
@@ -1758,11 +1757,13 @@ impl InMemoryAuthStore {
&self,
pending_user_id: &str,
phone_number: PhoneNumberSnapshot,
wechat_display_name: Option<String>,
) -> Result<(AuthUser, bool), PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
let submitted_wechat_display_name = normalize_optional_string(wechat_display_name);
let existing_phone_user_id =
Self::resolve_phone_user_locked(&mut state, &phone_number.e164)
@@ -1777,20 +1778,24 @@ impl InMemoryAuthStore {
.cloned()
.ok_or(PhoneAuthError::UserStateMismatch)?;
let pending_wechat_account = pending_wechat_identity.provider_uid.clone();
let pending_wechat_display_name = pending_wechat_identity.display_name.clone();
let pending_username = state
let pending_user = state
.users_by_username
.values()
.find(|stored| stored.user.id == pending_user_id)
.map(|stored| stored.user.username.clone())
.cloned()
.ok_or(PhoneAuthError::UserNotFound)?;
let pending_username = pending_user.user.username.clone();
let pending_wechat_display_name = submitted_wechat_display_name
.clone()
.or_else(|| normalize_optional_string(pending_wechat_identity.display_name.clone()))
.or_else(|| normalize_optional_string(pending_user.user.wechat_display_name));
state.users_by_username.remove(&pending_username);
state.wechat_identity_by_provider_uid.insert(
pending_wechat_identity.provider_uid.clone(),
StoredWechatIdentity {
user_id: target_user_id.clone(),
display_name: pending_wechat_display_name.clone(),
..pending_wechat_identity.clone()
},
);
@@ -1825,11 +1830,31 @@ impl InMemoryAuthStore {
.values()
.find(|identity| identity.user_id == pending_user_id)
.map(|identity| identity.provider_uid.clone());
let bound_wechat_display_name = state
let bound_wechat_display_name = submitted_wechat_display_name.clone().or_else(|| {
state
.wechat_identity_by_provider_uid
.values()
.find(|identity| identity.user_id == pending_user_id)
.and_then(|identity| identity.display_name.clone());
.and_then(|identity| normalize_optional_string(identity.display_name.clone()))
.or_else(|| {
state
.users_by_username
.values()
.find(|stored| stored.user.id == pending_user_id)
.and_then(|stored| {
normalize_optional_string(stored.user.wechat_display_name.clone())
})
})
});
if let Some(display_name) = bound_wechat_display_name.clone()
&& let Some(identity) = state
.wechat_identity_by_provider_uid
.values_mut()
.find(|identity| identity.user_id == pending_user_id)
{
identity.display_name = Some(display_name);
}
let stored_user = state
.users_by_username
@@ -3584,6 +3609,7 @@ mod tests {
user_id: wechat_user.id.clone(),
phone_number: "13800138000".to_string(),
verify_code: "123456".to_string(),
wechat_display_name: None,
},
now + Duration::seconds(3),
)
@@ -3619,4 +3645,97 @@ mod tests {
Some("已归并微信用户")
);
}
#[tokio::test]
async fn bind_wechat_phone_keeps_account_marker_when_identity_has_no_display_name() {
let store = build_store();
let phone_service = build_phone_service(store.clone());
let wechat_service = WechatAuthService::new(store.clone());
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138031".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone login code should send");
let phone_user = phone_service
.login(
PhoneLoginInput {
phone_number: "13800138031".to_string(),
verify_code: "123456".to_string(),
},
now + Duration::seconds(1),
)
.await
.expect("phone login should succeed")
.user;
let wechat_user = wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-mini-bind".to_string(),
provider_union_id: Some("wx-union-mini-bind".to_string()),
display_name: None,
avatar_url: None,
session_key: Some("mini-session-key".to_string()),
},
})
.await
.expect("mini program wechat login should succeed")
.user;
assert_eq!(wechat_user.wechat_display_name, None);
assert_eq!(
wechat_user.wechat_account.as_deref(),
Some("wx-openid-mini-bind")
);
assert_ne!(wechat_user.id, phone_user.id);
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138031".to_string(),
scene: PhoneAuthScene::BindPhone,
},
now + Duration::seconds(2),
)
.await
.expect("bind phone code should send");
let merged = phone_service
.bind_wechat_phone(
BindWechatPhoneInput {
user_id: wechat_user.id.clone(),
phone_number: "13800138031".to_string(),
verify_code: "123456".to_string(),
wechat_display_name: None,
},
now + Duration::seconds(3),
)
.await
.expect("bind phone should succeed");
assert_eq!(merged.user.id, phone_user.id);
assert!(merged.user.wechat_bound);
assert_eq!(merged.user.wechat_display_name, None);
assert_eq!(
merged.user.wechat_account.as_deref(),
Some("wx-openid-mini-bind")
);
let restored_user = build_password_service(store)
.get_user_by_id(&phone_user.id)
.expect("user lookup should succeed")
.expect("merged user should exist")
.user;
assert_eq!(restored_user.wechat_display_name, None);
assert_eq!(
restored_user.wechat_account.as_deref(),
Some("wx-openid-mini-bind")
);
}
}

View File

@@ -796,7 +796,7 @@ impl WechatProvider {
) -> Result<WechatIdentityProfile, WechatProviderError> {
match self {
Self::Disabled => Err(WechatProviderError::Disabled),
Self::Mock(provider) => Ok(provider.resolve_callback_profile(code)),
Self::Mock(provider) => Ok(provider.resolve_mini_program_login_profile(code)),
Self::Real(provider) => provider.resolve_mini_program_login_profile(code).await,
}
}
@@ -839,6 +839,21 @@ impl MockWechatProvider {
session_key: None,
}
}
fn resolve_mini_program_login_profile(&self, mock_code: Option<&str>) -> WechatIdentityProfile {
let provider_uid = mock_code
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(self.mock_user_id.as_str())
.to_string();
WechatIdentityProfile {
provider_uid: provider_uid.clone(),
provider_union_id: self.mock_union_id.clone(),
display_name: None,
avatar_url: None,
session_key: Some(format!("mock-session-key-{provider_uid}")),
}
}
}
impl RealWechatProvider {
@@ -2274,6 +2289,42 @@ mod tests {
assert_eq!(profile.display_name.as_deref(), Some("微信测试用户"));
}
#[tokio::test]
async fn mock_wechat_provider_resolves_mini_program_profile_without_nickname() {
let provider = WechatProvider::new(WechatAuthConfig::new(
true,
"mock".to_string(),
None,
None,
None,
None,
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(),
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(),
DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(),
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT.to_string(),
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT.to_string(),
DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT.to_string(),
"wx-user-001".to_string(),
Some("wx-union-001".to_string()),
"微信测试用户".to_string(),
Some("https://example.test/avatar.png".to_string()),
));
let profile = provider
.resolve_mini_program_login_profile(Some("wx-mini-code-001"))
.await
.expect("mock mini program profile should resolve");
assert_eq!(profile.provider_uid, "wx-mini-code-001");
assert_eq!(profile.provider_union_id.as_deref(), Some("wx-union-001"));
assert_eq!(profile.display_name, None);
assert_eq!(profile.avatar_url, None);
assert_eq!(
profile.session_key.as_deref(),
Some("mock-session-key-wx-mini-code-001")
);
}
fn build_jwt_config() -> JwtConfig {
JwtConfig::new(
"https://auth.genarrative.local".to_string(),

View File

@@ -228,6 +228,8 @@ pub struct WechatBindPhoneRequest {
pub code: Option<String>,
#[serde(default)]
pub wechat_phone_code: Option<String>,
#[serde(default)]
pub display_name: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -241,6 +243,8 @@ pub struct WechatBindPhoneResponse {
#[serde(rename_all = "camelCase")]
pub struct WechatMiniProgramLoginRequest {
pub code: String,
#[serde(default)]
pub display_name: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -249,6 +253,7 @@ pub struct WechatMiniProgramLoginResponse {
pub token: String,
pub binding_status: String,
pub user: AuthUserPayload,
pub created: bool,
}
pub fn build_available_login_methods(
@@ -354,6 +359,7 @@ mod tests {
phone: None,
code: None,
wechat_phone_code: Some("wx-phone-code-001".to_string()),
display_name: Some("陶泥儿玩家".to_string()),
})
.expect("payload should serialize");
@@ -362,8 +368,51 @@ mod tests {
json!({
"phone": null,
"code": null,
"wechatPhoneCode": "wx-phone-code-001"
"wechatPhoneCode": "wx-phone-code-001",
"displayName": "陶泥儿玩家"
})
);
}
#[test]
fn wechat_mini_program_login_request_accepts_native_nickname() {
let payload = serde_json::to_value(WechatMiniProgramLoginRequest {
code: "wx-mini-code-001".to_string(),
display_name: Some("陶泥儿玩家".to_string()),
})
.expect("payload should serialize");
assert_eq!(
payload,
json!({
"code": "wx-mini-code-001",
"displayName": "陶泥儿玩家"
})
);
}
#[test]
fn wechat_mini_program_login_response_marks_created_user() {
let payload = serde_json::to_value(WechatMiniProgramLoginResponse {
token: "token-001".to_string(),
binding_status: AUTH_BINDING_STATUS_PENDING_BIND_PHONE.to_string(),
user: AuthUserPayload {
id: "user_001".to_string(),
public_user_code: "SY-00000001".to_string(),
display_name: "微信旅人".to_string(),
avatar_url: None,
phone_number: None,
phone_number_masked: None,
login_method: AUTH_LOGIN_METHOD_WECHAT.to_string(),
binding_status: AUTH_BINDING_STATUS_PENDING_BIND_PHONE.to_string(),
wechat_bound: true,
wechat_display_name: None,
wechat_account: Some("wx-openid-001".to_string()),
},
created: true,
})
.expect("payload should serialize");
assert_eq!(payload["created"], serde_json::Value::Bool(true));
}
}

View File

@@ -177,6 +177,23 @@ test('account panel uses compact binding cards and keeps logout actions at the b
).toBe('true');
});
test('account panel avoids bare bound label when wechat display name is missing', () => {
renderAccountModal({
entryMode: 'account',
user: {
...baseUser,
wechatDisplayName: null,
wechatAccount: 'openid_abcdef123456',
},
});
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('绑定微信')).toBeTruthy();
expect(within(accountDialog).getByText('微信账号尾号 123456')).toBeTruthy();
expect(within(accountDialog).queryByText('openid_abcdef123456')).toBeNull();
expect(within(accountDialog).queryByText('已绑定')).toBeNull();
});
test('account actions open in independent panels instead of inline expansion', async () => {
const user = userEvent.setup();

View File

@@ -109,6 +109,15 @@ function formatSessionTime(value: string) {
});
}
function formatBoundWechatAccount(value: string | null | undefined) {
const normalized = value?.trim();
if (!normalized) {
return null;
}
return `微信账号尾号 ${normalized.slice(-6)}`;
}
function SettingsEntryCard({
label,
detail,
@@ -444,7 +453,9 @@ export function AccountModal({
const boundPhoneNumber =
user.phoneNumber?.trim() || user.phoneNumberMasked || '未绑定';
const boundWechatDisplayName =
user.wechatDisplayName?.trim() || (user.wechatBound ? '已绑定' : '未绑定');
user.wechatDisplayName?.trim() ||
formatBoundWechatAccount(user.wechatAccount) ||
(user.wechatBound ? '微信账号已绑定' : '未绑定');
const sectionSummaries: Record<PrimarySettingsSection, string> = {
appearance:

View File

@@ -0,0 +1,300 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import {
CreativeAudioInputPanel,
} from './CreativeAudioInputPanel';
import type { CreativeAudioAsset } from './creativeAudioFileAsset';
type TestAudioAsset = CreativeAudioAsset;
const originalMediaRecorder = globalThis.MediaRecorder;
const originalMediaDevices = navigator.mediaDevices;
afterEach(() => {
globalThis.MediaRecorder = originalMediaRecorder;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: originalMediaDevices,
});
vi.restoreAllMocks();
});
function buildAsset(overrides: Partial<TestAudioAsset> = {}): TestAudioAsset {
return {
assetId: 'asset-test',
audioSrc: 'blob:audio-preview',
audioObjectKey: '',
assetObjectId: '',
source: 'uploaded',
prompt: 'hit.wav',
durationMs: 800,
...overrides,
};
}
function renderPanel(
overrides: Partial<ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>> = {},
) {
const onAssetChange = vi.fn();
const onError = vi.fn();
const readFileAsAsset = vi.fn(async (file: File, source: 'uploaded' | 'recorded') =>
buildAsset({
audioSrc: `blob:${source}`,
source,
prompt: file.name,
}),
);
const rendered = render(
<CreativeAudioInputPanel<TestAudioAsset>
title="敲击音效"
defaultLabel="默认木鱼音"
asset={null}
buildRecordedFileName={() => 'recorded-hit.webm'}
onAssetChange={onAssetChange}
onError={onError}
readFileAsAsset={readFileAsAsset}
{...overrides}
/>,
);
return { ...rendered, onAssetChange, onError, readFileAsAsset };
}
function getUploadInput() {
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
return input!;
}
test('音频面板按需显示最长限制标签', () => {
renderPanel({ limitLabel: '最长 1 秒' });
expect(screen.getByText('最长 1 秒')).toBeTruthy();
});
test('音频面板未传限制标签时不渲染限制提示', () => {
renderPanel();
expect(screen.queryByText('最长 1 秒')).toBeNull();
});
test('音频面板无资产时显示默认音效文案', () => {
renderPanel();
expect(screen.getByText('默认木鱼音')).toBeTruthy();
});
test('音频面板有预览地址时渲染 audio 控件', () => {
const { container } = render(
<CreativeAudioInputPanel<TestAudioAsset>
title="敲击音效"
defaultLabel="默认木鱼音"
asset={buildAsset({ audioSrc: 'blob:preview' })}
buildRecordedFileName={() => 'recorded-hit.webm'}
onAssetChange={() => {}}
onError={() => {}}
/>,
);
expect(container.querySelector('audio')?.getAttribute('src')).toBe(
'blob:preview',
);
});
test('音频面板有资产但无预览地址时显示已选择状态', () => {
renderPanel({ asset: buildAsset({ audioSrc: '' }) });
expect(screen.getByText('音效已选择')).toBeTruthy();
});
test('点击重置清空当前音频资产', () => {
const onAssetChange = vi.fn();
renderPanel({
asset: buildAsset(),
onAssetChange,
});
fireEvent.click(screen.getByRole('button', { name: '重置' }));
expect(onAssetChange).toHaveBeenCalledWith(null);
});
test('取消上传选择时不读取音频', () => {
const { readFileAsAsset, onAssetChange } = renderPanel();
fireEvent.change(getUploadInput(), { target: { files: [] } });
expect(readFileAsAsset).not.toHaveBeenCalled();
expect(onAssetChange).not.toHaveBeenCalled();
});
test('上传音频成功后清空错误并写入资产', async () => {
const audioFile = new File(['audio'], 'hit.webm', { type: 'audio/webm' });
const { readFileAsAsset, onAssetChange, onError } = renderPanel();
fireEvent.change(getUploadInput(), { target: { files: [audioFile] } });
await waitFor(() =>
expect(readFileAsAsset).toHaveBeenCalledWith(audioFile, 'uploaded'),
);
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
expect(onError).toHaveBeenCalledWith(null);
});
test('上传音频失败时提示错误且不写入资产', async () => {
const readFileAsAsset = vi.fn(async () => {
throw new Error('音频最长 1 秒。');
});
const onAssetChange = vi.fn();
const onError = vi.fn();
renderPanel({ readFileAsAsset, onAssetChange, onError });
fireEvent.change(getUploadInput(), {
target: {
files: [new File(['audio'], 'hit.webm', { type: 'audio/webm' })],
},
});
await waitFor(() => expect(onError).toHaveBeenCalledWith('音频最长 1 秒。'));
expect(onAssetChange).not.toHaveBeenCalled();
});
test('浏览器不支持录音时提示错误', async () => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: undefined,
});
globalThis.MediaRecorder = undefined as unknown as typeof MediaRecorder;
const { onError } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith('当前浏览器不支持录音。'),
);
});
test('录音启动失败时透传启动错误', async () => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(async () => {
throw new Error('麦克风拒绝授权。');
}),
},
});
globalThis.MediaRecorder = class {
start = vi.fn();
stop = vi.fn();
} as unknown as typeof MediaRecorder;
const { onError } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(onError).toHaveBeenCalledWith('麦克风拒绝授权。'));
});
test('录音停止后按 recorded 来源读取音频', async () => {
const stopTrack = vi.fn();
const recorderInstances: Array<{
ondataavailable: ((event: BlobEvent) => void) | null;
onstop: (() => void) | null;
}> = [];
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(async () => ({
getTracks: () => [{ stop: stopTrack }],
})),
},
});
globalThis.MediaRecorder = class {
mimeType = 'audio/webm';
ondataavailable: ((event: BlobEvent) => void) | null = null;
onstop: (() => void) | null = null;
constructor() {
recorderInstances.push(this);
}
start = vi.fn();
stop = vi.fn(() => {
this.ondataavailable?.({
data: new Blob(['recorded-audio'], { type: 'audio/webm' }),
} as BlobEvent);
this.onstop?.();
});
} as unknown as typeof MediaRecorder;
const { readFileAsAsset, onAssetChange } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: '停止' }));
await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1));
const [recordedFile, source] = readFileAsAsset.mock.calls[0]!;
expect(recordedFile).toBeInstanceOf(File);
expect((recordedFile as File).name).toBe('recorded-hit.webm');
expect(source).toBe('recorded');
expect(stopTrack).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
expect(recorderInstances).toHaveLength(1);
});
test('录音保存失败时提示错误', async () => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(async () => ({
getTracks: () => [],
})),
},
});
globalThis.MediaRecorder = class {
mimeType = 'audio/webm';
ondataavailable: ((event: BlobEvent) => void) | null = null;
onstop: (() => void) | null = null;
start = vi.fn();
stop = vi.fn(() => this.onstop?.());
} as unknown as typeof MediaRecorder;
const readFileAsAsset = vi.fn(async () => {
throw new Error('音频声音过小,请重新录制或上传。');
});
const onError = vi.fn();
renderPanel({ readFileAsAsset, onError });
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: '停止' }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith('音频声音过小,请重新录制或上传。'),
);
});
test('禁用状态不启动录音也不允许上传', () => {
const getUserMedia = vi.fn();
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia },
});
const { container } = renderPanel({ disabled: true });
fireEvent.click(screen.getByRole('button', { name: '录音' }));
expect(getUserMedia).not.toHaveBeenCalled();
const input = container.querySelector('input[type="file"]');
expect(input).not.toBeNull();
expect((input as HTMLInputElement).disabled).toBe(true);
});

View File

@@ -5,12 +5,12 @@ import {
type CreativeAudioAsset,
readCreativeAudioFileAsAsset,
} from './creativeAudioFileAsset';
import { trimLeadingSilenceFromRecordedAudioFile } from './creativeAudioSilenceTrim';
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
disabled?: boolean;
title: string;
defaultLabel: string;
limitLabel?: string;
asset: TAsset | null;
buildRecordedFileName: () => string;
onAssetChange: (asset: TAsset | null) => void;
@@ -25,6 +25,7 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
disabled = false,
title,
defaultLabel,
limitLabel,
asset,
buildRecordedFileName,
onAssetChange,
@@ -64,8 +65,7 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
const file = new File([blob], buildRecordedFileName(), {
type: blob.type,
});
void trimLeadingSilenceFromRecordedAudioFile(file)
.then((trimmedFile) => readFileAsAsset(trimmedFile, 'recorded'))
void readFileAsAsset(file, 'recorded')
.then(onAssetChange)
.catch((caughtError) => {
onError(
@@ -95,9 +95,16 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
return (
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{title}
</div>
{limitLabel ? (
<div className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-black text-[var(--platform-text-soft)]">
{limitLabel}
</div>
) : null}
</div>
{asset ? (
<button
type="button"

View File

@@ -287,6 +287,118 @@ test('creative image input panel supports a preview-only main image mode', () =>
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('creative image input panel can preview the main image and keep upload on a corner button', () => {
const onMainImageFileSelect = vi.fn();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
try {
render(
<CreativeImageInputPanel
mainImageClickMode="preview"
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(2);
fireEvent.click(
screen.getByRole('button', { name: '关闭关卡图片预览' }),
);
fireEvent.click(screen.getByRole('button', { name: '更换参考图' }));
expect(inputClickSpy).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
target: {
files: [new File(['a'], 'level-reference.png', { type: 'image/png' })],
},
});
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
} finally {
inputClickSpy.mockRestore();
}
});
test('creative image input panel can hide upload and history controls independently', () => {
render(
<CreativeImageInputPanel
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
canUploadMainImage={false}
canUseImageHistory={false}
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
history: '选择历史图片',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onHistoryClick={() => {}}
onSubmit={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '查看关卡图片' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '更换参考图' })).toBeNull();
expect(
screen.queryByLabelText('上传参考图', { selector: 'input' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull();
});
test('creative image input panel does not show empty upload hint over a non-removable image', () => {
render(
<CreativeImageInputPanel

View File

@@ -6,7 +6,7 @@ import {
Trash2,
X,
} from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -28,6 +28,8 @@ export type CreativeImageInputPanelLabels = {
promptReferenceUpload: string;
promptReferencePreviewAlt: string;
closePromptReferencePreview: string;
previewMainImage?: string;
closeMainImagePreview?: string;
history?: string;
};
@@ -37,6 +39,9 @@ export type CreativeImageInputPanelProps = {
disabled?: boolean;
isSubmitting?: boolean;
mainImageMode?: 'edit' | 'preview';
mainImageClickMode?: 'upload' | 'preview';
canUploadMainImage?: boolean;
canUseImageHistory?: boolean;
canRemoveMainImage?: boolean;
canToggleAiRedraw?: boolean;
canUploadPromptReferences?: boolean;
@@ -82,6 +87,9 @@ export function CreativeImageInputPanel({
disabled = false,
isSubmitting = false,
mainImageMode = 'edit',
mainImageClickMode = 'preview',
canUploadMainImage = true,
canUseImageHistory = true,
canRemoveMainImage = true,
canToggleAiRedraw = true,
canUploadPromptReferences,
@@ -117,8 +125,10 @@ export function CreativeImageInputPanel({
onHistoryClick,
onSubmit,
}: CreativeImageInputPanelProps) {
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
const [previewReferenceImage, setPreviewReferenceImage] =
useState<CreativeImageInputReferenceImage | null>(null);
const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false);
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false);
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
@@ -127,10 +137,19 @@ export function CreativeImageInputPanel({
const promptReferenceUploadDisabled =
disabled || promptReferenceImages.length >= promptReferenceLimit;
const canEditMainImage = mainImageMode === 'edit';
const isMainImageUploadEnabled = canEditMainImage && canUploadMainImage;
const shouldShowHistoryButton =
canEditMainImage && canUseImageHistory && Boolean(onHistoryClick);
const shouldPreviewMainImage =
mainImageClickMode === 'preview' && Boolean(uploadedImageSrc);
const shouldShowMainImageUploadButton =
isMainImageUploadEnabled && shouldPreviewMainImage;
useEffect(() => {
if (uploadedImageSrc) {
setPreviewReferenceImage(null);
} else {
setIsMainImagePreviewOpen(false);
}
}, [uploadedImageSrc]);
@@ -187,9 +206,9 @@ export function CreativeImageInputPanel({
</div>
<div className={imageFrameClassName}>
<div className={imageCardClassName}>
{canEditMainImage ? (
<>
{isMainImageUploadEnabled ? (
<input
ref={mainImageInputRef}
id={mainImageInputId}
type="file"
accept={mainImageAccept}
@@ -204,18 +223,31 @@ export function CreativeImageInputPanel({
}}
className="sr-only"
/>
) : null}
{shouldPreviewMainImage ? (
<button
type="button"
className="absolute inset-0 z-[2] cursor-zoom-in"
aria-label={labels.previewMainImage ?? uploadedImageAlt}
title={labels.previewMainImage ?? uploadedImageAlt}
onClick={() => setIsMainImagePreviewOpen(true)}
/>
) : isMainImageUploadEnabled ? (
<label
htmlFor={mainImageInputId}
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
title={
uploadedImageSrc ? labels.replaceImage : labels.uploadImage
uploadedImageSrc
? labels.replaceImage
: labels.uploadImage
}
>
<span className="sr-only">
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
{uploadedImageSrc
? labels.replaceImage
: labels.uploadImage}
</span>
</label>
</>
) : null}
{uploadedImageSrc ? (
<ResolvedAssetImage
@@ -232,7 +264,19 @@ export function CreativeImageInputPanel({
</span>
)}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
{canEditMainImage && onHistoryClick ? (
{shouldShowMainImageUploadButton ? (
<button
type="button"
disabled={disabled}
onClick={() => mainImageInputRef.current?.click()}
className="absolute bottom-3 right-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55"
aria-label={labels.replaceImage}
title={labels.replaceImage}
>
<ImagePlus className="h-4 w-4" />
</button>
) : null}
{shouldShowHistoryButton ? (
<button
type="button"
disabled={disabled}
@@ -284,7 +328,7 @@ export function CreativeImageInputPanel({
>
<Trash2 className="h-4 w-4" />
</button>
) : canEditMainImage && !uploadedImageSrc ? (
) : isMainImageUploadEnabled && !uploadedImageSrc ? (
<label
htmlFor={mainImageInputId}
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[var(--platform-accent)] sm:bottom-10 ${
@@ -477,6 +521,48 @@ export function CreativeImageInputPanel({
</div>
) : null}
{isMainImagePreviewOpen && uploadedImageSrc ? (
<div
className="platform-modal-backdrop fixed inset-0 z-[82] flex items-center justify-center px-4 py-6"
onClick={() => setIsMainImagePreviewOpen(false)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="creative-image-main-preview-title"
className="platform-modal-shell platform-remap-surface w-full max-w-4xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<div
id="creative-image-main-preview-title"
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
>
{labels.previewMainImage ?? uploadedImageAlt}
</div>
<button
type="button"
aria-label={
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
}
onClick={() => setIsMainImagePreviewOpen(false)}
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={uploadedImageSrc}
refreshKey={uploadedImageRefreshKey}
alt={uploadedImageAlt}
className="h-full max-h-[82vh] w-full object-contain"
/>
</div>
</div>
</div>
) : null}
{isRemoveImageConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div

View File

@@ -0,0 +1,241 @@
import { afterEach, expect, test, vi } from 'vitest';
import {
encodePcmChannelsToWavBlob,
findAudibleFrameRange,
normalizeAudioBufferSection,
prepareCreativeAudioFileForLocalUse,
} from './creativeAudioProcessing';
const originalAudioContext = globalThis.AudioContext;
const originalCreateObjectUrl = URL.createObjectURL;
afterEach(() => {
globalThis.AudioContext = originalAudioContext;
URL.createObjectURL = originalCreateObjectUrl;
vi.restoreAllMocks();
});
function createAudioBufferStub(
channels: number[][],
sampleRate = 1000,
): AudioBuffer {
return {
length: channels[0]?.length ?? 0,
numberOfChannels: channels.length,
sampleRate,
duration: (channels[0]?.length ?? 0) / sampleRate,
getChannelData: (channel: number) =>
new Float32Array(channels[channel] ?? []),
} as AudioBuffer;
}
function installAudioContextMock(
decodeAudioData: (bytes: ArrayBuffer) => Promise<AudioBuffer>,
) {
globalThis.AudioContext = class {
decodeAudioData = decodeAudioData;
close = vi.fn();
} as unknown as typeof AudioContext;
}
test('prepareCreativeAudioFileForLocalUse rejects empty audio files', async () => {
await expect(
prepareCreativeAudioFileForLocalUse(
new File([], 'empty.webm', { type: 'audio/webm' }),
'uploaded',
),
).rejects.toThrow('音频文件为空,请重新选择。');
});
test('prepareCreativeAudioFileForLocalUse rejects non-audio files', async () => {
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['not-audio'], 'note.txt', { type: 'text/plain' }),
'uploaded',
),
).rejects.toThrow('请选择音频文件。');
});
test('prepareCreativeAudioFileForLocalUse reports decode failures', async () => {
installAudioContextMock(async () => {
throw new Error('decode failed');
});
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'broken.webm', { type: 'audio/webm' }),
'uploaded',
),
).rejects.toThrow('音频解码失败,请重新选择。');
});
test('prepareCreativeAudioFileForLocalUse rejects when AudioContext is unavailable', async () => {
globalThis.AudioContext = undefined as unknown as typeof AudioContext;
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }),
'uploaded',
),
).rejects.toThrow('当前浏览器不支持音频处理。');
});
test('prepareCreativeAudioFileForLocalUse rejects all-silent audio', async () => {
installAudioContextMock(async () => createAudioBufferStub([[0, 0.001, 0]], 1000));
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'silent.webm', { type: 'audio/webm' }),
'recorded',
),
).rejects.toThrow('音频声音过小,请重新录制或上传。');
});
test('prepareCreativeAudioFileForLocalUse allows audio exactly at the visible limit', async () => {
URL.createObjectURL = vi.fn(() => 'blob:one-second-audio');
installAudioContextMock(async () =>
createAudioBufferStub([Array.from({ length: 1000 }, () => 0.2)], 1000),
);
const asset = await prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'one-second.webm', { type: 'audio/webm' }),
'uploaded',
);
expect(asset.durationMs).toBe(1000);
expect(asset.audioSrc).toBe('blob:one-second-audio');
});
test('prepareCreativeAudioFileForLocalUse rejects audio longer than the visible limit after trimming', async () => {
installAudioContextMock(async () =>
createAudioBufferStub([[0, ...Array.from({ length: 1001 }, () => 0.2), 0]], 1000),
);
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'long.webm', { type: 'audio/webm' }),
'uploaded',
),
).rejects.toThrow('音频最长 1 秒。');
});
test('findAudibleFrameRange trims quiet leading and trailing frames', () => {
const buffer = createAudioBufferStub([
[0, 0.003, 0.02, 0.2, -0.03, 0.004],
[0, 0, 0, 0.05, 0, 0],
]);
expect(findAudibleFrameRange(buffer, 0.01)).toEqual({
startFrame: 2,
frameCount: 3,
});
});
test('normalizeAudioBufferSection pulls samples toward -15 LKFS approximation', () => {
const buffer = createAudioBufferStub([[0.02, -0.02, 0.02, -0.02]], 1000);
const normalized = normalizeAudioBufferSection(
buffer,
{ startFrame: 0, frameCount: 4 },
{ targetLkfs: -15, peakCeiling: 0.98 },
);
const channel = normalized[0];
expect(channel).toBeDefined();
const rms = Math.sqrt(
channel!.reduce((sum, sample) => sum + sample * sample, 0) /
channel!.length,
);
expect(rms).toBeCloseTo(Math.pow(10, -15 / 20), 3);
});
test('normalizeAudioBufferSection avoids clipping when target gain is too high', () => {
const buffer = createAudioBufferStub([[0.8, -0.8, 0.4, -0.4]], 1000);
const normalized = normalizeAudioBufferSection(
buffer,
{ startFrame: 0, frameCount: 4 },
{ targetLkfs: 0, peakCeiling: 0.5 },
);
const channel = normalized[0];
expect(channel).toBeDefined();
const peak = Math.max(...channel!.map((sample) => Math.abs(sample)));
expect(peak).toBeLessThanOrEqual(0.5);
});
test('normalizeAudioBufferSection rejects zero-energy sections', () => {
const buffer = createAudioBufferStub([[0, 0, 0]], 1000);
expect(() =>
normalizeAudioBufferSection(buffer, { startFrame: 0, frameCount: 3 }),
).toThrow('音频声音过小,请重新录制或上传。');
});
test('prepareCreativeAudioFileForLocalUse writes trimmed normalized wav blob', async () => {
URL.createObjectURL = vi.fn(() => 'blob:processed-audio');
installAudioContextMock(async () =>
createAudioBufferStub([[0, 0, 0.12, -0.12, 0]], 1000),
);
const asset = await prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }),
'uploaded',
);
const bytes = await asset.blob.arrayBuffer();
expect(asset.fileName).toBe('hit.wav');
expect(asset.mimeType).toBe('audio/wav');
expect(asset.audioSrc).toBe('blob:processed-audio');
expect(asset.durationMs).toBe(2);
expect(String.fromCharCode(...new Uint8Array(bytes, 0, 4))).toBe('RIFF');
expect(String.fromCharCode(...new Uint8Array(bytes, 8, 4))).toBe('WAVE');
});
test('prepareCreativeAudioFileForLocalUse still succeeds without object URL support', async () => {
URL.createObjectURL = undefined as unknown as typeof URL.createObjectURL;
installAudioContextMock(async () =>
createAudioBufferStub([[0.12, -0.12]], 1000),
);
const asset = await prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }),
'recorded',
);
expect(asset.audioSrc).toBe('');
expect(asset.previewUrl).toBe('');
});
test('prepareCreativeAudioFileForLocalUse normalizes processed wav file names', async () => {
URL.createObjectURL = vi.fn(() => 'blob:processed-audio');
installAudioContextMock(async () =>
createAudioBufferStub([[0.12, -0.12]], 1000),
);
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'hit-sound', { type: 'audio/webm' }),
'uploaded',
),
).resolves.toMatchObject({ fileName: 'hit-sound.wav' });
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], ' ', { type: 'audio/webm' }),
'uploaded',
),
).resolves.toMatchObject({ fileName: 'creative-audio.wav' });
});
test('encodePcmChannelsToWavBlob writes pcm16 wav bytes', async () => {
const blob = encodePcmChannelsToWavBlob(
[new Float32Array([0.25, -0.5])],
1000,
);
const bytes = await blob.arrayBuffer();
const view = new DataView(bytes);
expect(blob.type).toBe('audio/wav');
expect(view.getUint32(40, true)).toBe(4);
expect(view.getInt16(44, true)).toBeCloseTo(8191, -1);
expect(view.getInt16(46, true)).toBeCloseTo(-16384, -1);
});

View File

@@ -0,0 +1,308 @@
import {
type CreativeAudioAsset,
} from './creativeAudioFileAsset';
type BrowserAudioGlobal = typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
};
export type CreativeAudioSource = 'uploaded' | 'recorded';
export type PendingCreativeAudioAsset = CreativeAudioAsset & {
fileName: string;
mimeType: string;
blob: Blob;
source: CreativeAudioSource;
previewUrl: string;
durationMs: number;
};
export type CreativeAudioProcessingOptions = {
maxDurationMs?: number;
silenceThreshold?: number;
targetLkfs?: number;
peakCeiling?: number;
};
export type AudibleFrameRange = {
startFrame: number;
frameCount: number;
};
const DEFAULT_MAX_DURATION_MS = 1000;
const DEFAULT_SILENCE_THRESHOLD = 0.01;
const DEFAULT_TARGET_LKFS = -15;
const DEFAULT_PEAK_CEILING = 0.98;
const WAV_HEADER_BYTE_LENGTH = 44;
const WAV_BITS_PER_SAMPLE = 16;
const WAV_BYTES_PER_SAMPLE = WAV_BITS_PER_SAMPLE / 8;
export async function prepareCreativeAudioFileForLocalUse(
file: File,
source: CreativeAudioSource,
options: CreativeAudioProcessingOptions = {},
): Promise<PendingCreativeAudioAsset> {
validateCreativeAudioFile(file);
const decodedBuffer = await decodeCreativeAudioFile(file);
const range = findAudibleFrameRange(
decodedBuffer,
options.silenceThreshold ?? DEFAULT_SILENCE_THRESHOLD,
);
if (!range) {
throw new Error('音频声音过小,请重新录制或上传。');
}
const durationMs = Math.round(
(range.frameCount / decodedBuffer.sampleRate) * 1000,
);
const maxDurationMs = options.maxDurationMs ?? DEFAULT_MAX_DURATION_MS;
if (durationMs > maxDurationMs) {
throw new Error(`音频最长 ${formatDurationSeconds(maxDurationMs)} 秒。`);
}
const normalized = normalizeAudioBufferSection(decodedBuffer, range, {
targetLkfs: options.targetLkfs ?? DEFAULT_TARGET_LKFS,
peakCeiling: options.peakCeiling ?? DEFAULT_PEAK_CEILING,
});
const blob = encodePcmChannelsToWavBlob(normalized, decodedBuffer.sampleRate);
const fileName = buildProcessedAudioFileName(file.name);
const previewUrl =
typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
? URL.createObjectURL(blob)
: '';
return {
assetId: `local-${source}-${Date.now()}`,
audioSrc: previewUrl,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs,
fileName,
mimeType: blob.type,
blob,
previewUrl,
};
}
export function findAudibleFrameRange(
buffer: AudioBuffer,
silenceThreshold = DEFAULT_SILENCE_THRESHOLD,
): AudibleFrameRange | null {
const threshold = Math.max(0, silenceThreshold);
let startFrame: number | null = null;
let endFrame: number | null = null;
for (let frameIndex = 0; frameIndex < buffer.length; frameIndex += 1) {
if (isFrameAudible(buffer, frameIndex, threshold)) {
startFrame = frameIndex;
break;
}
}
if (startFrame === null) {
return null;
}
for (let frameIndex = buffer.length - 1; frameIndex >= startFrame; frameIndex -= 1) {
if (isFrameAudible(buffer, frameIndex, threshold)) {
endFrame = frameIndex;
break;
}
}
if (endFrame === null) {
return null;
}
return {
startFrame,
frameCount: endFrame - startFrame + 1,
};
}
export function normalizeAudioBufferSection(
buffer: AudioBuffer,
range: AudibleFrameRange,
options: Pick<CreativeAudioProcessingOptions, 'targetLkfs' | 'peakCeiling'> = {},
) {
const channelCount = Math.max(1, buffer.numberOfChannels);
const targetLkfs = options.targetLkfs ?? DEFAULT_TARGET_LKFS;
const peakCeiling = Math.max(0.01, options.peakCeiling ?? DEFAULT_PEAK_CEILING);
const channels = Array.from({ length: channelCount }, (_value, channelIndex) =>
copyChannelSection(buffer, channelIndex, range),
);
const stats = measurePcmStats(channels);
if (stats.rms <= 0 || stats.peak <= 0) {
throw new Error('音频声音过小,请重新录制或上传。');
}
// 浏览器端近似:用全通道 RMS 估算 LKFS再按 GY/T 377-2023 目标值拉到 -15 LKFS。
const targetLinear = Math.pow(10, targetLkfs / 20);
const loudnessGain = targetLinear / stats.rms;
const protectedGain = Math.min(loudnessGain, peakCeiling / stats.peak);
return channels.map((channel) =>
Float32Array.from(channel, (sample) => clampSample(sample * protectedGain)),
);
}
export function encodePcmChannelsToWavBlob(
channels: Float32Array[],
sampleRate: number,
) {
const channelCount = Math.max(1, channels.length);
const frameCount = channels[0]?.length ?? 0;
const dataByteLength = frameCount * channelCount * WAV_BYTES_PER_SAMPLE;
const output = new ArrayBuffer(WAV_HEADER_BYTE_LENGTH + dataByteLength);
const view = new DataView(output);
writeAscii(view, 0, 'RIFF');
view.setUint32(4, 36 + dataByteLength, true);
writeAscii(view, 8, 'WAVE');
writeAscii(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channelCount, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * channelCount * WAV_BYTES_PER_SAMPLE, true);
view.setUint16(32, channelCount * WAV_BYTES_PER_SAMPLE, true);
view.setUint16(34, WAV_BITS_PER_SAMPLE, true);
writeAscii(view, 36, 'data');
view.setUint32(40, dataByteLength, true);
let outputOffset = WAV_HEADER_BYTE_LENGTH;
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const sample = channels[channelIndex]?.[frameIndex] ?? 0;
view.setInt16(outputOffset, toSignedPcm16(sample), true);
outputOffset += WAV_BYTES_PER_SAMPLE;
}
}
return new Blob([output], { type: 'audio/wav' });
}
function validateCreativeAudioFile(file: File) {
if (file.size <= 0) {
throw new Error('音频文件为空,请重新选择。');
}
if (!resolveFileMimeType(file).startsWith('audio/')) {
throw new Error('请选择音频文件。');
}
}
async function decodeCreativeAudioFile(file: File) {
const AudioContextConstructor = getAudioContextConstructor();
if (!AudioContextConstructor) {
throw new Error('当前浏览器不支持音频处理。');
}
const context = new AudioContextConstructor();
try {
const bytes = await file.arrayBuffer();
return await context.decodeAudioData(bytes.slice(0));
} catch {
throw new Error('音频解码失败,请重新选择。');
} finally {
void context.close();
}
}
function getAudioContextConstructor() {
const audioGlobal = globalThis as BrowserAudioGlobal;
return audioGlobal.AudioContext ?? audioGlobal.webkitAudioContext ?? null;
}
function resolveFileMimeType(file: File) {
if (file.type.trim()) {
return file.type.trim();
}
return '';
}
function isFrameAudible(
buffer: AudioBuffer,
frameIndex: number,
threshold: number,
) {
for (
let channelIndex = 0;
channelIndex < buffer.numberOfChannels;
channelIndex += 1
) {
const channelData = buffer.getChannelData(channelIndex);
if (Math.abs(channelData[frameIndex] ?? 0) > threshold) {
return true;
}
}
return false;
}
function copyChannelSection(
buffer: AudioBuffer,
channelIndex: number,
range: AudibleFrameRange,
) {
const source =
channelIndex < buffer.numberOfChannels
? buffer.getChannelData(channelIndex)
: new Float32Array(buffer.length);
const output = new Float32Array(range.frameCount);
for (let frameOffset = 0; frameOffset < range.frameCount; frameOffset += 1) {
output[frameOffset] = source[range.startFrame + frameOffset] ?? 0;
}
return output;
}
function measurePcmStats(channels: Float32Array[]) {
let sumSquares = 0;
let peak = 0;
let sampleCount = 0;
for (const channel of channels) {
for (const sample of channel) {
sumSquares += sample * sample;
peak = Math.max(peak, Math.abs(sample));
sampleCount += 1;
}
}
return {
rms: sampleCount > 0 ? Math.sqrt(sumSquares / sampleCount) : 0,
peak,
};
}
function clampSample(sample: number) {
return Math.max(-1, Math.min(1, sample));
}
function toSignedPcm16(sample: number) {
const clamped = clampSample(sample);
return clamped < 0
? Math.round(clamped * 0x8000)
: Math.round(clamped * 0x7fff);
}
function writeAscii(view: DataView, offset: number, value: string) {
for (let index = 0; index < value.length; index += 1) {
view.setUint8(offset + index, value.charCodeAt(index));
}
}
function buildProcessedAudioFileName(fileName: string) {
const normalizedName = fileName.trim();
if (!normalizedName) {
return 'creative-audio.wav';
}
return /\.[^.]+$/u.test(normalizedName)
? normalizedName.replace(/\.[^.]+$/u, '.wav')
: `${normalizedName}.wav`;
}
function formatDurationSeconds(durationMs: number) {
return Number.isInteger(durationMs / 1000)
? String(durationMs / 1000)
: (durationMs / 1000).toFixed(1);
}

View File

@@ -743,6 +743,20 @@ function getPlatformRecommendRuntimeKind(
return 'rpg';
}
function resolveRecommendEntryShareStage(
entry: PlatformPublicGalleryCard,
): PublishShareModalPayload['stage'] {
if (isBigFishGalleryEntry(entry)) {
return 'big-fish-runtime';
}
if (isPuzzleGalleryEntry(entry)) {
return 'puzzle-gallery-detail';
}
return 'work-detail';
}
function isRecommendRuntimeReadyForEntry(
entry: PlatformPublicGalleryCard,
state: RecommendRuntimeState,
@@ -1920,6 +1934,13 @@ function buildPuzzleCreationUrlState(
};
}
function pushPuzzleResultHistoryEntry(
session: PuzzleAgentSessionSnapshot | null,
) {
pushAppHistoryPath('/creation/puzzle/result');
writeCreationUrlState(buildPuzzleCreationUrlState(session));
}
function buildPuzzleDraftRuntimeUrlState(
item: PuzzleWorkSummary,
levelId?: string | null,
@@ -5205,6 +5226,22 @@ export function PlatformEntryFlowShellImpl({
[],
);
const openRecommendShareModal = useCallback(
(entry: PlatformPublicGalleryCard) => {
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
if (!publicWorkCode) {
return;
}
openPublishShareModal({
title: entry.worldName,
publicWorkCode,
stage: resolveRecommendEntryShareStage(entry),
});
},
[openPublishShareModal],
);
const openRpgPublishShareModal = useCallback(
async (profile: CustomWorldProfile | null | undefined) => {
const profileId = profile?.id?.trim();
@@ -6635,6 +6672,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(run);
setPuzzleRuntimeAuthMode('default');
setPuzzleRuntimeReturnStage('puzzle-result');
pushPuzzleResultHistoryEntry(response.session);
openPuzzleRuntimeStage(
setSelectionStage,
buildPuzzleDraftRuntimeUrlState(item, null),
@@ -7000,6 +7038,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(run);
setPuzzleRuntimeAuthMode('default');
setPuzzleRuntimeReturnStage('puzzle-result');
pushPuzzleResultHistoryEntry(latestSession);
openPuzzleRuntimeStage(
setSelectionStage,
buildPuzzleDraftRuntimeUrlState(item, null),
@@ -7992,6 +8031,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(run);
setPuzzleRuntimeAuthMode('default');
setPuzzleRuntimeReturnStage('puzzle-result');
pushPuzzleResultHistoryEntry(response.session);
openPuzzleRuntimeStage(
setSelectionStage,
buildPuzzleDraftRuntimeUrlState(item, null),
@@ -11926,6 +11966,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(run);
setPuzzleRuntimeAuthMode('default');
setPuzzleRuntimeReturnStage('puzzle-result');
pushPuzzleResultHistoryEntry(puzzleSession);
openPuzzleRuntimeStage(
setSelectionStage,
buildPuzzleDraftRuntimeUrlState(item, options.levelId ?? null),
@@ -11940,8 +11981,7 @@ export function PlatformEntryFlowShellImpl({
},
[
isPuzzleBusy,
puzzleSession?.publishedProfileId,
puzzleSession?.sessionId,
puzzleSession,
resolvePuzzleErrorMessage,
setIsPuzzleBusy,
setPuzzleError,
@@ -17799,6 +17839,9 @@ export function PlatformEntryFlowShellImpl({
onLikeRecommendEntry={(entry) => {
likePublicWork(entry);
}}
onShareRecommendEntry={(entry) => {
openRecommendShareModal(entry);
}}
onRemixRecommendEntry={(entry) => {
remixPublicWork(entry);
}}
@@ -19712,7 +19755,13 @@ export function PlatformEntryFlowShellImpl({
error={puzzleError}
hideBackButton={Boolean(puzzleOnboardingDraft)}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
const returnStage = puzzleRuntimeReturnStage;
setSelectionStage(returnStage);
if (returnStage === 'puzzle-result') {
writeCreationUrlState(
buildPuzzleCreationUrlState(puzzleSession),
);
}
}}
onRemodelWork={
selectedPuzzleDetail?.publicationStatus === 'published'

View File

@@ -427,6 +427,19 @@ describe('PuzzleResultView', () => {
const formalImageCard = formalImageTitle
.closest('.creative-image-input-panel__image-field')
?.querySelector('.puzzle-image-upload-card');
fireEvent.click(
within(dialog).getByRole('button', { name: '查看关卡图片' }),
);
const imagePreviewDialog = screen.getByRole('dialog', {
name: '查看关卡图片',
});
expect(within(imagePreviewDialog).getByAltText('暖灯猫街')).toBeTruthy();
fireEvent.click(
within(imagePreviewDialog).getByRole('button', {
name: '关闭关卡图片预览',
}),
);
expect(within(dialog).getByRole('button', { name: '更换参考图' })).toBeTruthy();
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();

View File

@@ -867,6 +867,8 @@ function PuzzleLevelDetailDialog({
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
history: '选择历史图片',
}}
onMainImageFileSelect={(file) => {

View File

@@ -6113,10 +6113,19 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
);
});
await act(async () => {
window.history.back();
});
await waitFor(() => {
expect(window.location.pathname).toBe('/creation/puzzle/result');
});
await user.click(await screen.findByRole('button', { name: '返回上一页' }));
expect(await screen.findByText('拼图结果页')).toBeTruthy();
expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy();
const creationParams = new URLSearchParams(window.location.search);
expect(creationParams.get('sessionId')).toBe('puzzle-session-auto-1');
expect(creationParams.get('profileId')).toBe('puzzle-profile-auto-1');
});
test('embedded puzzle form recovers when compile request times out after backend completion', async () => {
@@ -7319,6 +7328,47 @@ test('home recommendation starts embedded puzzle without global auth reset on lo
});
});
test('home recommendation share opens publish share modal', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
workId: 'puzzle-work-share-1',
profileId: 'SHARE001',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-share-1',
authorDisplayName: '拼图作者',
levelName: '星桥分享关',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [publishedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
render(<TestWrapper withAuth />);
const meta = await screen.findByLabelText('星桥分享关 作品信息');
await user.click(within(meta).getByRole('button', { name: '分享' }));
expect(
await screen.findByRole('dialog', { name: '分享给朋友' }),
).toBeTruthy();
expect(screen.getByText(/作品号PZ-SHARE001/u)).toBeTruthy();
expect(screen.getByText(/\/gallery\/puzzle\/detail\?work=PZ-SHARE001/u))
.toBeTruthy();
});
test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => {
const publishedPuzzleWork = {
workId: 'puzzle-work-public-2',
@@ -11881,6 +11931,7 @@ test('creation hub gives jump hop wooden fish and bark battle cards the shared d
themeText: '跳台删除草稿',
workTitle: '跳台删除草稿',
workDescription: '跳一跳草稿也应接入统一删除。',
themeText: '跳台',
themeTags: ['跳台'],
difficulty: 'standard',
stylePreset: 'paper-toy',

View File

@@ -4037,6 +4037,7 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
const onSelectNextRecommendEntry = vi.fn();
const onSelectPreviousRecommendEntry = vi.fn();
const onLikeRecommendEntry = vi.fn();
const onShareRecommendEntry = vi.fn();
const onRemixRecommendEntry = vi.fn();
const firstEntry = {
...puzzlePublicEntry,
@@ -4122,6 +4123,7 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
onSelectNextRecommendEntry={onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry}
onLikeRecommendEntry={onLikeRecommendEntry}
onShareRecommendEntry={onShareRecommendEntry}
onRemixRecommendEntry={onRemixRecommendEntry}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
@@ -4140,11 +4142,6 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
expect(screen.getAllByText('上一拼图').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText('评论')).toBeNull();
expect(screen.queryByLabelText(//u)).toBeNull();
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: clipboardWriteText },
});
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
expect(meta.closest('[data-recommend-swipe-zone="true"]')).toBeTruthy();
@@ -4166,10 +4163,9 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
fireEvent.click(remixButton);
expect(onLikeRecommendEntry).toHaveBeenCalledWith(firstEntry);
expect(onShareRecommendEntry).toHaveBeenCalledWith(firstEntry);
expect(onRemixRecommendEntry).toHaveBeenCalledWith(firstEntry);
expect(clipboardWriteText).toHaveBeenCalledWith(
expect.stringContaining('作品号PZ-FEED1'),
);
expect(activeRecommendCard.getByRole('button', { name: '分享' })).toBeTruthy();
act(() => {
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 300 });

View File

@@ -19,7 +19,6 @@ import {
MessageCircle,
Palette,
Pencil,
Plus,
ScanLine,
Search,
Settings,
@@ -203,6 +202,7 @@ export interface RpgEntryHomeViewProps {
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onShareRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
@@ -1070,7 +1070,6 @@ function RecommendSwipeCard({
authorSummary,
isActive,
visual,
shareState,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
@@ -1084,7 +1083,6 @@ function RecommendSwipeCard({
authorSummary?: PublicUserSummary | null;
isActive: boolean;
visual: ReactNode;
shareState?: 'idle' | 'copied' | 'failed';
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
@@ -1108,7 +1106,6 @@ function RecommendSwipeCard({
authorAvatarUrl={authorAvatarUrl}
authorSummary={authorSummary}
isActive={isActive}
shareState={shareState}
onDragPointerDown={onDragPointerDown}
onDragPointerMove={onDragPointerMove}
onDragPointerUp={onDragPointerUp}
@@ -1130,7 +1127,6 @@ function RecommendRuntimeMeta({
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
shareState = 'idle',
onLike,
onShare,
onRemix,
@@ -1143,7 +1139,6 @@ function RecommendRuntimeMeta({
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
shareState?: 'idle' | 'copied' | 'failed';
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
@@ -1234,13 +1229,7 @@ function RecommendRuntimeMeta({
onShare?.();
}}
disabled={!isActive || !onShare}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
aria-label="分享"
title="分享"
>
<Share2 className="h-5 w-5" aria-hidden="true" />
@@ -4134,6 +4123,7 @@ export function RpgEntryHomeView({
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
onLikeRecommendEntry,
onShareRecommendEntry,
onRemixRecommendEntry,
onOpenLibraryDetail,
onDeleteLibraryEntry,
@@ -4415,7 +4405,7 @@ export function RpgEntryHomeView({
? {
home: Sparkles,
category: Compass,
create: Plus,
create: Sparkles,
saves: Pencil,
profile: UserRound,
}
@@ -5560,13 +5550,9 @@ export function RpgEntryHomeView({
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<1 | -1 | null>(null);
const [recommendShareState, setRecommendShareState] = useState<
'idle' | 'copied' | 'failed'
>('idle');
const activeRecommendEntryKeyForSelection = activeRecommendEntry
? buildPublicGalleryCardKey(activeRecommendEntry)
: null;
const recommendShareResetTimerRef = useRef<number | null>(null);
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{
pointerId: number;
@@ -5704,39 +5690,6 @@ export function RpgEntryHomeView({
onSelectNextRecommendEntry,
recommendedFeedEntries.length,
]);
useEffect(
() => () => {
if (recommendShareResetTimerRef.current !== null) {
window.clearTimeout(recommendShareResetTimerRef.current);
}
},
[],
);
useEffect(() => {
setRecommendShareState('idle');
}, [activeRecommendEntryKey]);
const shareRecommendEntry = useCallback(
(entry: PlatformPublicGalleryCard) => {
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
if (!publicWorkCode) {
setRecommendShareState('failed');
return;
}
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
void copyTextToClipboard(shareText).then((copied) => {
setRecommendShareState(copied ? 'copied' : 'failed');
if (recommendShareResetTimerRef.current !== null) {
window.clearTimeout(recommendShareResetTimerRef.current);
}
recommendShareResetTimerRef.current = window.setTimeout(() => {
recommendShareResetTimerRef.current = null;
setRecommendShareState('idle');
}, 1400);
});
},
[],
);
const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
@@ -5880,9 +5833,8 @@ export function RpgEntryHomeView({
onDragPointerMove={moveRecommendDrag}
onDragPointerUp={endRecommendDrag}
onDragPointerCancel={cancelRecommendDrag}
shareState={recommendShareState}
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
onShare={() => shareRecommendEntry(activeRecommendEntry)}
onShare={() => onShareRecommendEntry?.(activeRecommendEntry)}
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
/>
</div>

View File

@@ -4,7 +4,12 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
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 { uploadWoodenFishHitSoundAsset } from '../../../services/wooden-fish/woodenFishAssetClient';
import {
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
} from '../../../services/wooden-fish/woodenFishDefaults';
import { prepareCreativeAudioFileForLocalUse } from '../../common/creativeAudioProcessing';
import { WoodenFishCreationWorkspace } from './WoodenFishCreationWorkspace';
vi.mock('../../../services/wooden-fish/woodenFishClient', () => ({
@@ -13,8 +18,18 @@ vi.mock('../../../services/wooden-fish/woodenFishClient', () => ({
},
}));
vi.mock('../../../services/wooden-fish/woodenFishAssetClient', () => ({
uploadWoodenFishHitSoundAsset: vi.fn(),
}));
vi.mock('../../common/creativeAudioProcessing', () => ({
prepareCreativeAudioFileForLocalUse: vi.fn(),
}));
beforeEach(() => {
vi.mocked(woodenFishClient.createSession).mockReset();
vi.mocked(uploadWoodenFishHitSoundAsset).mockReset();
vi.mocked(prepareCreativeAudioFileForLocalUse).mockReset();
vi.mocked(woodenFishClient.createSession).mockResolvedValue({
session: {
sessionId: 'wooden-fish-session-test',
@@ -25,6 +40,30 @@ beforeEach(() => {
updatedAt: '2026-05-24T00:00:00Z',
},
});
vi.mocked(uploadWoodenFishHitSoundAsset).mockResolvedValue({
assetId: 'uploaded-hit-sound-asset',
audioSrc: '/generated-wooden-fish-assets/draft/hit-sound.webm',
audioObjectKey: 'generated-wooden-fish-assets/draft/hit-sound.webm',
assetObjectId: 'asset-object-hit-sound',
source: 'uploaded',
prompt: 'hit-sound.webm',
durationMs: null,
});
vi.mocked(prepareCreativeAudioFileForLocalUse).mockImplementation(
async (file, source) => ({
assetId: 'local-hit-sound-asset',
audioSrc: 'blob:local-hit-sound',
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: 800,
fileName: 'hit-sound.wav',
mimeType: 'audio/wav',
blob: new Blob(['processed-audio-bytes'], { type: 'audio/wav' }),
previewUrl: 'blob:local-hit-sound',
}),
);
});
test('敲什么输入栏初始置空但提交时仍使用默认生成提示词', async () => {
@@ -103,10 +142,303 @@ test('敲击音效临时关闭提示词生成入口,仅保留上传和录音',
expect(section).not.toBeNull();
expect(within(section as HTMLElement).queryByText('音效描述')).toBeNull();
expect(within(section as HTMLElement).getByText('最长 1 秒')).toBeTruthy();
expect(within(section as HTMLElement).getByText('上传')).toBeTruthy();
expect(within(section as HTMLElement).getByText('录音')).toBeTruthy();
});
test('敲击音效上传后只生成本地待提交音频,点击生成时才上传 OSS', async () => {
const onSubmitted = vi.fn();
const audioFile = new File(['audio-bytes'], 'hit-sound.webm', {
type: 'audio/webm',
});
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={onSubmitted}
/>,
);
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, {
target: { files: [audioFile] },
});
await waitFor(() =>
expect(prepareCreativeAudioFileForLocalUse).toHaveBeenCalledWith(
audioFile,
'uploaded',
),
);
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
expect(uploadWoodenFishHitSoundAsset).toHaveBeenCalledTimes(1);
const uploadCall = vi.mocked(uploadWoodenFishHitSoundAsset).mock.calls[0];
expect(uploadCall).toBeDefined();
const [uploadedFile, source] = uploadCall!;
expect(uploadedFile).toBeInstanceOf(File);
const uploadedAudioFile = uploadedFile as File;
expect(uploadedAudioFile.name).toBe('hit-sound.wav');
expect(uploadedFile.type).toBe('audio/wav');
expect(source).toBe('uploaded');
const payload = onSubmitted.mock.calls[0]?.[1];
expect(payload.hitSoundAsset).toMatchObject({
assetObjectId: 'asset-object-hit-sound',
audioSrc: '/generated-wooden-fish-assets/draft/hit-sound.webm',
});
expect(payload.hitSoundAsset.audioSrc).not.toContain('data:audio');
});
test('未选择敲击音效时生成不上传 OSS 且使用默认木鱼音', async () => {
const onSubmitted = vi.fn();
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={onSubmitted}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
expect(onSubmitted.mock.calls[0]?.[1].hitSoundAsset).toEqual(
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
);
});
test('敲击音效超过 1 秒时展示错误且不提交音频', async () => {
const onSubmitted = vi.fn();
vi.mocked(prepareCreativeAudioFileForLocalUse).mockRejectedValueOnce(
new Error('音频最长 1 秒。'),
);
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={onSubmitted}
/>,
);
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, {
target: {
files: [
new File(['too-long-audio'], 'too-long.webm', {
type: 'audio/webm',
}),
],
},
});
expect(await screen.findByText('音频最长 1 秒。')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
expect(onSubmitted.mock.calls[0]?.[1].hitSoundAsset.audioSrc).not.toContain(
'data:audio',
);
});
test('敲击音效上传失败时停留在工作台且不创建 session', async () => {
vi.mocked(uploadWoodenFishHitSoundAsset).mockRejectedValueOnce(
new Error('上传敲击音效失败。'),
);
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, {
target: {
files: [
new File(['audio-bytes'], 'hit-sound.webm', {
type: 'audio/webm',
}),
],
},
});
await waitFor(() =>
expect(prepareCreativeAudioFileForLocalUse).toHaveBeenCalledTimes(1),
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(await screen.findByText('上传敲击音效失败。')).toBeTruthy();
expect(woodenFishClient.createSession).not.toHaveBeenCalled();
});
test('创建木鱼草稿失败时停留工作台并展示错误', async () => {
vi.mocked(woodenFishClient.createSession).mockRejectedValueOnce(
new Error('创建敲木鱼共创会话失败'),
);
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(await screen.findByText('创建敲木鱼共创会话失败')).toBeTruthy();
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
});
test('提交中重复点击生成不会重复上传或创建 session', async () => {
let resolveUpload: (
value: Awaited<ReturnType<typeof uploadWoodenFishHitSoundAsset>>,
) => void = () => {};
vi.mocked(uploadWoodenFishHitSoundAsset).mockImplementationOnce(
() =>
new Promise<Awaited<ReturnType<typeof uploadWoodenFishHitSoundAsset>>>((resolve) => {
resolveUpload = resolve;
}),
);
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, {
target: {
files: [
new File(['audio-bytes'], 'hit-sound.webm', {
type: 'audio/webm',
}),
],
},
});
await waitFor(() =>
expect(prepareCreativeAudioFileForLocalUse).toHaveBeenCalledTimes(1),
);
const submitButton = screen.getByRole('button', { name: '生成' });
fireEvent.click(submitButton);
fireEvent.click(submitButton);
expect(uploadWoodenFishHitSoundAsset).toHaveBeenCalledTimes(1);
expect(woodenFishClient.createSession).not.toHaveBeenCalled();
resolveUpload({
assetId: 'uploaded-hit-sound-asset',
audioSrc: '/generated-wooden-fish-assets/draft/hit-sound.webm',
audioObjectKey: 'generated-wooden-fish-assets/draft/hit-sound.webm',
assetObjectId: 'asset-object-hit-sound',
source: 'uploaded',
prompt: 'hit-sound.webm',
durationMs: null,
});
await waitFor(() => expect(woodenFishClient.createSession).toHaveBeenCalledTimes(1));
});
test('替换本地敲击音效时回收旧预览地址', async () => {
const revokeObjectURL = vi.fn();
URL.revokeObjectURL = revokeObjectURL;
vi.mocked(prepareCreativeAudioFileForLocalUse)
.mockResolvedValueOnce({
assetId: 'local-hit-sound-old',
audioSrc: 'blob:old-hit-sound',
audioObjectKey: '',
assetObjectId: '',
source: 'uploaded',
prompt: 'old.webm',
durationMs: 800,
fileName: 'old.wav',
mimeType: 'audio/wav',
blob: new Blob(['old-audio'], { type: 'audio/wav' }),
previewUrl: 'blob:old-hit-sound',
})
.mockResolvedValueOnce({
assetId: 'local-hit-sound-new',
audioSrc: 'blob:new-hit-sound',
audioObjectKey: '',
assetObjectId: '',
source: 'uploaded',
prompt: 'new.webm',
durationMs: 700,
fileName: 'new.wav',
mimeType: 'audio/wav',
blob: new Blob(['new-audio'], { type: 'audio/wav' }),
previewUrl: 'blob:new-hit-sound',
});
render(
<WoodenFishCreationWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
fireEvent.change(input as HTMLInputElement, {
target: {
files: [
new File(['old'], 'old.webm', {
type: 'audio/webm',
}),
],
},
});
await waitFor(() => expect(screen.getByText('重置')).toBeTruthy());
fireEvent.change(input as HTMLInputElement, {
target: {
files: [
new File(['new'], 'new.webm', {
type: 'audio/webm',
}),
],
},
});
await waitFor(() =>
expect(revokeObjectURL).toHaveBeenCalledWith('blob:old-hit-sound'),
);
});
test('敲击音效和功德词条不放进独立滚动窗', () => {
const { container } = render(
<WoodenFishCreationWorkspace

View File

@@ -13,12 +13,17 @@ import type {
WoodenFishWorkspaceCreateRequest,
} from '../../../../packages/shared/src/contracts/woodenFish';
import { readPuzzleReferenceImageAsDataUrl } from '../../../services/puzzleReferenceImage';
import { uploadWoodenFishHitSoundAsset } from '../../../services/wooden-fish/woodenFishAssetClient';
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
import {
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
} from '../../../services/wooden-fish/woodenFishDefaults';
import { CreativeAudioInputPanel } from '../../common/CreativeAudioInputPanel';
import {
type PendingCreativeAudioAsset,
prepareCreativeAudioFileForLocalUse,
} from '../../common/creativeAudioProcessing';
import { CreativeImageInputPanel } from '../../common/CreativeImageInputPanel';
type WoodenFishCreationWorkspaceProps = {
@@ -36,7 +41,7 @@ type WoodenFishCreationWorkspaceProps = {
type WoodenFishWorkspaceFormState = {
hitObjectPrompt: string;
hitObjectReferenceImageSrc: string;
hitSoundAsset: WoodenFishAudioAsset | null;
hitSoundAsset: PendingCreativeAudioAsset | null;
floatingWords: string[];
};
@@ -132,6 +137,14 @@ export function WoodenFishCreationWorkspace({
setLocalError(null);
try {
const hitSoundAsset: WoodenFishAudioAsset = formState.hitSoundAsset
? await uploadWoodenFishHitSoundAsset(
new File([formState.hitSoundAsset.blob], formState.hitSoundAsset.fileName, {
type: formState.hitSoundAsset.mimeType,
}),
formState.hitSoundAsset.source,
)
: WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET;
const payload: WoodenFishWorkspaceCreateRequest = {
templateId: 'wooden-fish',
workTitle: '',
@@ -143,8 +156,7 @@ export function WoodenFishCreationWorkspace({
hitObjectReferenceImageSrc:
formState.hitObjectReferenceImageSrc.trim() || null,
hitSoundPrompt: null,
hitSoundAsset:
formState.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
hitSoundAsset,
floatingWords: normalizedFloatingWords,
};
const response = await woodenFishClient.createSession(payload);
@@ -246,18 +258,28 @@ export function WoodenFishCreationWorkspace({
</div>
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
<CreativeAudioInputPanel<WoodenFishAudioAsset>
<CreativeAudioInputPanel<PendingCreativeAudioAsset>
disabled={isBusy || isSubmitting}
title="敲击音效"
defaultLabel="默认木鱼音"
limitLabel="最长 1 秒"
asset={formState.hitSoundAsset}
buildRecordedFileName={() => `wooden-fish-hit-${Date.now()}.webm`}
onAssetChange={(asset) =>
readFileAsAsset={prepareCreativeAudioFileForLocalUse}
onAssetChange={(asset) => {
if (
formState.hitSoundAsset?.previewUrl &&
formState.hitSoundAsset.previewUrl !== asset?.previewUrl &&
typeof URL !== 'undefined' &&
typeof URL.revokeObjectURL === 'function'
) {
URL.revokeObjectURL(formState.hitSoundAsset.previewUrl);
}
setFormState((current) => ({
...current,
hitSoundAsset: asset,
}))
}
}));
}}
onError={setLocalError}
/>

View File

@@ -40,11 +40,36 @@
html,
body,
#root {
#root,
.platform-viewport-shell,
.platform-tab-panel,
.platform-page-stage,
.unified-creation-page,
.platform-work-detail__scroll {
width: 100%;
height: 100%;
min-width: 0;
overflow-x: hidden;
-ms-overflow-style: none;
scrollbar-width: none;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
#root::-webkit-scrollbar,
.platform-viewport-shell::-webkit-scrollbar,
.platform-tab-panel::-webkit-scrollbar,
.platform-page-stage::-webkit-scrollbar,
.unified-creation-page::-webkit-scrollbar,
.platform-work-detail__scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
html,
body,
#root {
height: 100%;
}
body {

View File

@@ -31,6 +31,34 @@ function getCssBlock(source: string, selector: string) {
}
describe('index stylesheet unread dots', () => {
it('hides the outer page scrollbar without changing inner scroll helpers', () => {
const css = readIndexCss();
const rootBlock = getCssBlock(css, 'html,\nbody,\n#root');
expect(rootBlock).toContain('overflow-x: hidden;');
expect(rootBlock).toContain('-ms-overflow-style: none;');
expect(rootBlock).toContain('scrollbar-width: none;');
expect(css).toContain(
'html,\nbody,\n#root,\n.platform-viewport-shell,\n.platform-tab-panel,\n.platform-page-stage,\n.unified-creation-page,\n.platform-work-detail__scroll',
);
const webkitRootBlock = getCssBlock(
css,
'html::-webkit-scrollbar,\nbody::-webkit-scrollbar,\n#root::-webkit-scrollbar',
);
expect(webkitRootBlock).toContain('display: none;');
expect(webkitRootBlock).toContain('width: 0;');
expect(webkitRootBlock).toContain('height: 0;');
expect(css).toContain('.platform-viewport-shell::-webkit-scrollbar');
expect(css).toContain('.platform-tab-panel::-webkit-scrollbar');
expect(css).toContain('.platform-page-stage::-webkit-scrollbar');
expect(css).toContain('.unified-creation-page::-webkit-scrollbar');
expect(css).toContain('.platform-work-detail__scroll::-webkit-scrollbar');
expect(css).toContain('.scrollbar-hide');
expect(css).toContain('::-webkit-scrollbar-thumb');
});
it('uses warm brown tokens for draft unread markers instead of red literals', () => {
const css = readIndexCss();

View File

@@ -0,0 +1,212 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
const requestJsonMock = vi.hoisted(() => vi.fn());
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
import { uploadWoodenFishHitSoundAsset } from './woodenFishAssetClient';
const uploadTicket = {
upload: {
bucket: 'private-bucket',
host: 'https://oss.example.test/upload',
objectKey: 'generated-wooden-fish-assets/draft/hit-sound/uploaded/1/hit.wav',
legacyPublicPath: '/generated-wooden-fish-assets/draft/hit-sound.wav',
formFields: {
key: 'object-key',
policy: 'policy-value',
empty: null,
},
},
};
const confirmedAsset = {
assetObject: {
assetObjectId: 'asset-object-hit-sound',
objectKey: uploadTicket.upload.objectKey,
assetKind: 'wooden_fish_hit_sound',
},
};
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock
.mockResolvedValueOnce(uploadTicket)
.mockResolvedValueOnce(confirmedAsset);
globalThis.fetch = vi.fn(async () => new Response(null, { status: 204 }));
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-06T12:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
test('空音频 Blob 不创建上传凭证', async () => {
await expect(
uploadWoodenFishHitSoundAsset(
new Blob([], { type: 'audio/wav' }),
'uploaded',
'empty.wav',
),
).rejects.toThrow('音频文件为空,请重新选择。');
expect(requestJsonMock).not.toHaveBeenCalled();
});
test('超过 20MB 的音频不创建上传凭证', async () => {
await expect(
uploadWoodenFishHitSoundAsset(
new Blob([new Uint8Array(20 * 1024 * 1024 + 1)], {
type: 'audio/wav',
}),
'uploaded',
'large.wav',
),
).rejects.toThrow('音频文件过大,请压缩后再上传。');
expect(requestJsonMock).not.toHaveBeenCalled();
});
test('非音频 MIME 不创建上传凭证', async () => {
await expect(
uploadWoodenFishHitSoundAsset(
new Blob(['text'], { type: 'text/plain' }),
'uploaded',
'note.txt',
),
).rejects.toThrow('请选择音频文件。');
expect(requestJsonMock).not.toHaveBeenCalled();
});
test('File 上传使用自身 MIME 和文件名创建上传凭证', async () => {
const file = new File(['audio'], 'hit.webm', { type: 'audio/webm' });
const asset = await uploadWoodenFishHitSoundAsset(file, 'uploaded');
expect(requestJsonMock).toHaveBeenNthCalledWith(
1,
'/api/assets/direct-upload-tickets',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'创建敲击音效上传凭证失败',
);
const ticketBody = JSON.parse(requestJsonMock.mock.calls[0]![1].body);
expect(ticketBody).toMatchObject({
legacyPrefix: 'generated-wooden-fish-assets',
fileName: 'hit.webm',
contentType: 'audio/webm',
access: 'private',
maxSizeBytes: 20 * 1024 * 1024,
metadata: {
asset_kind: 'wooden_fish_hit_sound',
wooden_fish_slot: 'hit_sound',
wooden_fish_audio_source: 'uploaded',
},
});
expect(ticketBody.pathSegments).toEqual([
'wooden-fish',
'draft',
'hit-sound',
'uploaded',
String(Date.now()),
]);
expect(fetch).toHaveBeenCalledWith(uploadTicket.upload.host, {
method: 'POST',
body: expect.any(FormData),
});
expect(asset).toMatchObject({
assetId: 'asset-object-hit-sound',
audioSrc: uploadTicket.upload.legacyPublicPath,
audioObjectKey: uploadTicket.upload.objectKey,
assetObjectId: 'asset-object-hit-sound',
source: 'uploaded',
prompt: 'hit.webm',
});
});
test('Blob 上传可通过文件名扩展推断音频 MIME', async () => {
const blob = new Blob(['audio']);
await uploadWoodenFishHitSoundAsset(blob, 'recorded', 'recorded.wav');
const ticketBody = JSON.parse(requestJsonMock.mock.calls[0]![1].body);
expect(ticketBody).toMatchObject({
fileName: 'recorded.wav',
contentType: 'audio/wav',
metadata: {
wooden_fish_audio_source: 'recorded',
},
});
});
test('Blob 缺少 MIME 且文件扩展未知时拒绝上传', async () => {
await expect(
uploadWoodenFishHitSoundAsset(new Blob(['audio']), 'uploaded', 'hit.bin'),
).rejects.toThrow('请选择音频文件。');
expect(requestJsonMock).not.toHaveBeenCalled();
});
test('OSS POST 失败时不确认资产对象', async () => {
globalThis.fetch = vi.fn(async () => new Response(null, { status: 500 }));
await expect(
uploadWoodenFishHitSoundAsset(
new File(['audio'], 'hit.webm', { type: 'audio/webm' }),
'uploaded',
),
).rejects.toThrow('上传敲击音效失败。');
expect(requestJsonMock).toHaveBeenCalledTimes(1);
});
test('确认资产对象失败时透传确认错误', async () => {
requestJsonMock.mockReset();
requestJsonMock
.mockResolvedValueOnce(uploadTicket)
.mockRejectedValueOnce(new Error('确认敲击音效资产失败'));
await expect(
uploadWoodenFishHitSoundAsset(
new File(['audio'], 'hit.webm', { type: 'audio/webm' }),
'uploaded',
),
).rejects.toThrow('确认敲击音效资产失败');
expect(fetch).toHaveBeenCalledTimes(1);
});
test('确认资产对象时提交 OSS 对象和内容长度', async () => {
const file = new File(['audio'], 'hit.webm', { type: 'audio/webm' });
await uploadWoodenFishHitSoundAsset(file, 'uploaded');
expect(requestJsonMock).toHaveBeenNthCalledWith(
2,
'/api/assets/objects/confirm',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'确认敲击音效资产失败',
);
const confirmBody = JSON.parse(requestJsonMock.mock.calls[1]![1].body);
expect(confirmBody).toMatchObject({
bucket: uploadTicket.upload.bucket,
objectKey: uploadTicket.upload.objectKey,
contentType: 'audio/webm',
contentLength: file.size,
assetKind: 'wooden_fish_hit_sound',
accessPolicy: 'private',
entityId: 'hit_sound',
});
});

View File

@@ -0,0 +1,141 @@
import type { WoodenFishAudioAsset } from '../../../packages/shared/src/contracts/woodenFish';
import { requestJson } from '../apiClient';
const WOODEN_FISH_AUDIO_UPLOAD_MAX_BYTES = 20 * 1024 * 1024;
type DirectUploadTicketResponse = {
upload: {
bucket: string;
host: string;
objectKey: string;
legacyPublicPath: string;
formFields: Record<string, string | null | undefined>;
};
};
type ConfirmAssetObjectResponse = {
assetObject: {
assetObjectId: string;
objectKey: string;
assetKind: string;
};
};
const MIME_BY_EXTENSION: Record<string, string> = {
m4a: 'audio/mp4',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
wav: 'audio/wav',
webm: 'audio/webm',
};
function resolveAudioContentType(file: Blob, fileName: string) {
if (file.type.trim()) {
return file.type.trim();
}
const extension = fileName.split('.').pop()?.trim().toLowerCase() ?? '';
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
}
function validateWoodenFishAudioFile(file: Blob, fileName: string) {
const contentType = resolveAudioContentType(file, fileName);
if (file.size <= 0) {
throw new Error('音频文件为空,请重新选择。');
}
if (file.size > WOODEN_FISH_AUDIO_UPLOAD_MAX_BYTES) {
throw new Error('音频文件过大,请压缩后再上传。');
}
if (!contentType.startsWith('audio/')) {
throw new Error('请选择音频文件。');
}
return contentType;
}
function buildWoodenFishAudioPathSegments(source: 'uploaded' | 'recorded') {
return ['wooden-fish', 'draft', 'hit-sound', source, `${Date.now()}`];
}
async function postDirectUploadFile(
upload: DirectUploadTicketResponse['upload'],
file: Blob,
fileName: string,
) {
const formData = new FormData();
Object.entries(upload.formFields).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value);
}
});
formData.append('file', file, fileName);
const response = await fetch(upload.host, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传敲击音效失败。');
}
}
export async function uploadWoodenFishHitSoundAsset(
file: Blob,
source: 'uploaded' | 'recorded',
fileName =
typeof File !== 'undefined' && file instanceof File
? file.name
: 'wooden-fish-hit-sound.wav',
): Promise<WoodenFishAudioAsset> {
const contentType = validateWoodenFishAudioFile(file, fileName);
const ticket = await requestJson<DirectUploadTicketResponse>(
'/api/assets/direct-upload-tickets',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
legacyPrefix: 'generated-wooden-fish-assets',
pathSegments: buildWoodenFishAudioPathSegments(source),
fileName,
contentType,
access: 'private',
maxSizeBytes: WOODEN_FISH_AUDIO_UPLOAD_MAX_BYTES,
metadata: {
asset_kind: 'wooden_fish_hit_sound',
wooden_fish_slot: 'hit_sound',
wooden_fish_audio_source: source,
},
}),
},
'创建敲击音效上传凭证失败',
);
await postDirectUploadFile(ticket.upload, file, fileName);
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
'/api/assets/objects/confirm',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bucket: ticket.upload.bucket,
objectKey: ticket.upload.objectKey,
contentType,
contentLength: file.size,
assetKind: 'wooden_fish_hit_sound',
accessPolicy: 'private',
entityId: 'hit_sound',
}),
},
'确认敲击音效资产失败',
);
return {
assetId: confirmed.assetObject.assetObjectId,
audioSrc: ticket.upload.legacyPublicPath,
audioObjectKey: confirmed.assetObject.objectKey,
assetObjectId: confirmed.assetObject.assetObjectId,
source,
prompt: fileName,
durationMs: null,
};
}