Improve local auth env handling and fallbacks

Allow local env files to reliably override authentication feature flags (SMS/WeChat) by whitelisting keys in scripts/dev-utils.mjs and adding a unit test. Add SMS checks to scripts/check-api-server-env.mjs. Make server config.parse_bool tolerant of shell-wrapped quoted values (e.g. '"true"') and add tests so SMS_AUTH_ENABLED is parsed correctly when shells supply quotes. Update docs to clarify SMS env behaviour, restart requirements, and add guidance + a CSS fallback for old mobile browsers (QQ/X5) so public cover images render even when aspect-ratio is unsupported. Also include related frontend test and component adjustments and add puzzle onboarding handlers/endpoints in server-rs/crates/api-server/src/puzzle.rs.
This commit is contained in:
2026-05-18 23:13:49 +08:00
parent 4c10c181e3
commit d1adfa3406
22 changed files with 4309 additions and 52 deletions

View File

@@ -198,7 +198,8 @@
## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化
- 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。
- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt``uiBackgroundImageSrc``uiBackgroundImageObjectKey``compile_puzzle_draft` 草稿编译阶段在首图完成后自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc``uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。
- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt``uiBackgroundImageSrc``uiBackgroundImageObjectKey``compile_puzzle_draft` 草稿编译阶段自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc``uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。
- 2026-05-18 追加:为缩短首版草稿等待,`compile_puzzle_draft` 在首关命名和 `uiBackgroundPrompt` 稳定后并行启动首关关卡图生成与 UI 背景生成;上传主图且关闭 AI 重绘时,并行执行上传图持久化与 UI 背景生成。生成页预计完成时间按 5 分钟展示。
- 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。
- 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx``cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`

View File

@@ -458,8 +458,8 @@
- 现象:`POST /api/assets/hyper3d/text-to-model` 在本地返回 503详情里提示 `HYPER3D_API_KEY 未配置`,但开发者明明已经在本地私密文件里写了 key。
- 原因:`scripts/dev-utils.mjs` 之前按 `.env.secrets.local → .env.local → .env` 合并,结果仓库里的 `.env` 空示例值会把前面已经设置好的私密 key 覆盖掉。
- 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env``.env.local``.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`
- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖, shell 变量仍然最高优先级。
- 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env``.env.local``.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`本地认证开关例外:`SMS_AUTH_ENABLED``SMS_AUTH_PROVIDER` 等以本地 env 文件为准,避免父进程继承的旧开关值长期压过 `.env.local`
- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,真实密钥 shell 变量仍然最高优先级`mergeApiServerEnv(..., { SMS_AUTH_ENABLED: "false" })``.env.local``SMS_AUTH_ENABLED=true` 时应返回 true
- 关联:`scripts/dev-utils.mjs``server-rs/crates/api-server/src/hyper3d_generation.rs``docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`
## OSS 密钥键名不要把字母 O 写成数字 0
@@ -488,23 +488,23 @@
## 本地短信登录页签突然消失
- 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。
- 原因:前端根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;常见根因有两类:
- 原因:历史实现曾根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;接口返回空、失败或只返回 `["password"]` 时,`AuthGate` 会降级成只显示密码。
- 本地启动脚本没有让 `.env.local` 覆盖 `.env``SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`
- Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500``AuthGate` 降级成 `["password"]`
- 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。
- 生成页 UI 改动看起来“完全没变化”时,也要先确认当前浏览器打开的 Vite 进程正在返回最新源码;例如直接请求 `http://127.0.0.1:3000/src/components/CustomWorldGenerationView.tsx` 检查是否包含本次新增类名或关键字。
- 单独 `npm run dev:web` 启动瞬间另一个临时 API 端口可用,脚本若自动切过去,之后临时 API 停掉也会让 3000 继续代理到空端口。
- 处理:优先用 `npm run dev:api-server``npm run dev:spacetime``npm run dev` 启动,这些入口应保持 shell 环境变量最高优先级,并允许 `.env.local` 覆盖 `.env`;完整栈启动时还要确保脚本计算出的 `RUST_SERVER_TARGET` 不被 `.env.local` 里的旧值覆盖。排查时先请求 3000 域名下的 `/api/auth/login-options`,再直连 Rust API 目标,并核对 `.env.local``SMS_AUTH_ENABLED` 与代理端口;若 3001/3002 才返回正确结果,说明当前 3000 是旧前端进程,应清理旧进程后重启
- 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}`,登录弹窗会恢复短信登录页签和“获取验证码”按钮
- 关联:`scripts/dev-utils.mjs``scripts/dev.mjs``docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`
- 处理:当前口径是登录弹窗永远展示 `短信登录``密码登录` 两个核心入口;`login-options` 只补充微信等环境相关入口,不能隐藏短信或密码页签。如果“获取验证码”点击后失败,再按短信 provider / API 代理问题排查:优先用 `npm run dev:api-server``npm run dev:spacetime``npm run dev` 启动,确认 `.env.local` 覆盖 `.env``RUST_SERVER_TARGET` 没有指向旧端口,并分别请求 3000 域名和 Rust API 目标
- 验证:即使 `/api/auth/login-options` 返回空、失败或只返回 `["password"]`,登录弹窗也应同时显示 `短信登录``密码登录``验证码` 输入和“获取验证码”按钮;短信发送真实可用性再通过 `POST /api/auth/phone/send-code` 验证
- 关联:`src/components/auth/AuthGate.tsx``src/components/auth/LoginScreen.tsx``src/components/auth/AuthGate.test.tsx``scripts/dev-utils.mjs``scripts/dev.mjs`
## 本地短信收不到验证码先查 provider
- 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。
- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;真实阿里云链路已经改为普通短信 `SendSms`,验证码由当前 `api-server` 进程本地生成、哈希存储和校验,旧 `SendSmsVerifyCode` / `CheckSmsVerifyCode` 托管验证码参数不再参与真实校验。另外 `npm run dev:api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值
- 处理:真实短信联调时把 `.env.local``SMS_AUTH_PROVIDER` 显式设为 `aliyun`,并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com``ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技``ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486``ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE``api-server` 重启会清掉未校验的本地验证码。
- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]``api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效需要直接确认平台层真实调用阿里云时,配置 `ALIYUN_SMS_ACCESS_KEY_ID``ALIYUN_SMS_ACCESS_KEY_SECRET``ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 后手动执行 `cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture`
- 关联:`scripts/dev-utils.mjs``docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md``docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`
- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;真实阿里云链路已经改为普通短信 `SendSms`,验证码由当前 `api-server` 进程本地生成、哈希存储和校验,旧 `SendSmsVerifyCode` / `CheckSmsVerifyCode` 托管验证码参数不再参与真实校验。若接口直接返回“手机号登录暂未启用”,说明当前运行中的 `api-server` 进程内 `sms_auth_enabled=false`:常见原因是修改 `.env.local` 后没有重启后端,或外层 shell 已经设置了非空 `SMS_AUTH_ENABLED` 导致 dotenv 不覆盖。历史上 cmd 里 `set SMS_AUTH_ENABLED="true"` 会把引号也传进进程Rust bool 解析失败后保持默认 false
- 处理:真实短信联调时把 `.env.local``SMS_AUTH_ENABLED=true``SMS_AUTH_PROVIDER=aliyun` 显式打开,并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com``ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技``ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486``ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`Shell 临时覆盖时 PowerShell 用 `$env:SMS_AUTH_ENABLED="true"`cmd 用 `set SMS_AUTH_ENABLED=true`,不要把引号作为值的一部分。`api-server` 重启会清掉未校验的本地验证码。
- 验证:分别请求浏览器域名和 Rust API 直连的 `/api/auth/login-options`,都应返回 `["phone","password"]``api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效需要直接确认平台层真实调用阿里云时,配置 `ALIYUN_SMS_ACCESS_KEY_ID``ALIYUN_SMS_ACCESS_KEY_SECRET``ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 后手动执行 `cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture`
- 关联:`server-rs/crates/api-server/src/config.rs``scripts/dev-utils.mjs``docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md``docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`
## 手机验证码登录 500 先查短信 provider 语义
@@ -951,3 +951,19 @@
- 处理:把 `jenkins/Jenkinsfile.production-stdb-module-build``Checkout``Build Stdb Module` 两处 `powershell` step 收口成 `runWindowsPowerShell(...)` helper先用 `writeFile` 写出临时 `.ps1`,再用显式 `powershell.exe` 把脚本重写成 UTF-8 with BOM最后通过 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...` 执行。这个 helper 写在 Groovy GString 里时PowerShell 的 `$path` / `$text` / `$true` 必须写成 `\$path` / `\$text` / `\$true`,否则 Jenkinsfile 会在 Groovy 编译阶段报 `unexpected token: true`。Checkout 阶段优先复用 Jenkins GitSCM 已完成的工作区结果;`COMMIT_HASH` 为空或已经等于当前 `HEAD` 时不再重复 `git fetch` / `git checkout` / `git clean`,只有确实要切到另一个指定 commit 时才补 fetch、归属校验和 checkout。
- 验证:检查 Jenkins build log 中是否出现 `[jenkins-powershell] user:``[jenkins-powershell] exe:`,以及 `[stdb-checkout] current HEAD:`。上游 Full Build 传下来的 `COMMIT_HASH` 若已等于当前 GitSCM checkout日志应显示 `requested commit already matches Jenkins GitSCM checkout` 并继续进入构建阶段;同时确认 `builds/<n>/log` 不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或 Checkout 内部 exit code 5。
- 关联:`jenkins/Jenkinsfile.production-stdb-module-build``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## QQ 浏览器发现页推荐封面全不显示先查 aspect-ratio 兜底
- 现象:发现页的“推荐”子频道作品卡标题、作者和数据正常,但所有封面图不显示,常见于 QQ 浏览器 / X5 等旧移动内核。
- 原因:公开作品卡封面内部图片是绝对铺满,容器原本主要依赖 Tailwind `aspect-video` / CSS `aspect-ratio` 撑高;旧内核不支持或实现异常时封面容器高度会坍缩为 0。若封面还是 `/generated-*` 私有资源,换签失败后没有玩法参考图兜底时会进一步表现成黑卡。
- 处理:`.platform-public-work-card__cover::before` 使用 `padding-top: 56.25%` 保留 16:9 高度,沉浸式卡片单独覆盖比例;公开作品卡通过 `resolvePlatformWorldFallbackCoverImage(...)``ResolvedAssetImage` 传入玩法参考图兜底,签名失败或图片加载失败时仍有可见封面。
- 验证:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding`
- 关联:`src/index.css``src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/rpg-entry/rpgEntryWorldPresentation.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 生成中草稿刷新后不要只恢复作品架遮罩
- 现象:拼图或抓大鹅草稿生成中刷新网页后,作品架卡片能显示等待遮罩,但点击卡片会走普通草稿恢复,可能进入空白结果页或未完成工作区。
- 原因:前端只把内存 notice 当作“生成中点击恢复”的判断条件,没有把后端摘要里的 `generationStatus=generating` 纳入同一路径。
- 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice恢复对应玩法生成进度页恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs`
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`

View File

@@ -220,7 +220,7 @@ ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
```
阿里云模板参数固定发送为 `{"code":"<验证码>"}`。旧托管验证码相关变量如 `ALIYUN_SMS_CODE_LENGTH``ALIYUN_SMS_CODE_TYPE``ALIYUN_SMS_RETURN_VERIFY_CODE``ALIYUN_SMS_CASE_AUTH_POLICY``ALIYUN_SMS_SCHEME_NAME` 不再影响真实阿里云校验;验证码长度、有效期、冷却和失败次数由后端本地逻辑控制。真实短信联调仍需 `SMS_AUTH_PROVIDER=aliyun``SMS_AUTH_ENABLED=true` 和有效 `ALIYUN_SMS_ACCESS_KEY_*`
阿里云模板参数固定发送为 `{"code":"<验证码>"}`。旧托管验证码相关变量如 `ALIYUN_SMS_CODE_LENGTH``ALIYUN_SMS_CODE_TYPE``ALIYUN_SMS_RETURN_VERIFY_CODE``ALIYUN_SMS_CASE_AUTH_POLICY``ALIYUN_SMS_SCHEME_NAME` 不再影响真实阿里云校验;验证码长度、有效期、冷却和失败次数由后端本地逻辑控制。真实短信联调仍需 `SMS_AUTH_PROVIDER=aliyun``SMS_AUTH_ENABLED=true` 和有效 `ALIYUN_SMS_ACCESS_KEY_*`修改 `.env.local` 后必须重启 `api-server`,再用 `/api/auth/login-options` 确认返回包含 `phone`;如果通过 shell 临时覆盖PowerShell 使用 `$env:SMS_AUTH_ENABLED="true"`cmd 使用 `set SMS_AUTH_ENABLED=true`,不要把引号作为环境变量值的一部分传给进程。
如需在本地确认平台层确实调用阿里云 `SendSms`,可手动运行默认忽略的真实短信测试。该测试会向 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 发送验证码短信,普通 `cargo test` 不会执行:

View File

@@ -17,7 +17,8 @@
3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon删除等破坏性动作继续收口到左滑或长按操作层。
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
6. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导
7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
## 拼图
@@ -31,8 +32,8 @@
- 图像输入复用 `CreativeImageInputPanel`
- 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要生成完成并回写关卡图、UI 背景后再变为 `ready`;当前不自动生成背景音乐。
- 拼图草稿编译是长耗时 action前端 action 请求默认等待 `1_000_000ms` 且不自动重试;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要生成完成并回写关卡图、UI 背景后再变为 `ready`首关关卡图和 UI 背景在命名稳定后并行启动,当前不自动生成背景音乐。
- 拼图草稿编译是长耗时 action前端 action 请求默认等待 `1_000_000ms` 且不自动重试,生成页预计完成时间按 `5` 分钟展示;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。
- 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。
- 拼图参考图 AI 重绘优先走 VectorEngine `/v1/images/edits`;若编辑接口超时,`api-server` 会降级为 `/v1/images/generations`,并把同一参考图塞进 `image` 数组继续生成,避免参考图草稿整单失败。
- 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏。
@@ -162,3 +163,4 @@
3. 生成失败时,后端应返回可操作 `details.reason` / `details.missingEnv`,前端优先展示具体原因。
4. 半配置 OSS 不应阻断 `api-server` 启动;具体生成或换签接口在需要时返回配置缺失。
5. 历史 generated path 可以兼容读取,但新链路不要把裸 path 当公开静态资源。
6. 发现页 / 推荐流公开作品卡封面必须兼容旧移动浏览器内核:封面容器不能只依赖 CSS `aspect-ratio` 撑高,必须保留 16:9 或对应沉浸卡比例的可见高度兜底generated 私有封面换签失败时要回落到玩法类型参考图,避免卡片整体黑底。

View File

@@ -39,6 +39,12 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
内部状态值可继续复用历史 `home/category/create/saves/profile`,但用户可见文案按上面的新口径展示。
## 账户与登录
1. 主站登录弹窗必须稳定展示 `短信登录``密码登录` 两个核心入口;`GET /api/auth/login-options` 只能补充微信等环境相关入口,不能决定是否隐藏短信或密码登录。
2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。
3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。
## 账户与充值
1. “我的”页账户充值弹窗包含 `泥点充值``会员卡充值` 两个页签,入口必须打开独立弹窗,不在当前面板下方展开。

View File

@@ -27,6 +27,10 @@ function printStatus(key, present) {
const env = mergeApiServerEnv(process.cwd(), process.env);
const missing = [];
console.log('[api-server-env] 认证短信配置检查');
printStatus('SMS_AUTH_ENABLED', env.SMS_AUTH_ENABLED === 'true');
printStatus('SMS_AUTH_PROVIDER', hasValue(env.SMS_AUTH_PROVIDER));
console.log('[api-server-env] 拼图真实生成配置检查');
for (const key of REQUIRED_FOR_PUZZLE_GENERATION) {
const present = hasValue(env[key]);

View File

@@ -2,6 +2,13 @@ import {existsSync, mkdirSync, readFileSync} from 'node:fs';
import {dirname, isAbsolute, resolve} from 'node:path';
export const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local'];
const LOCAL_ENV_OVERRIDE_KEYS = new Set([
'SMS_AUTH_ENABLED',
'SMS_AUTH_PROVIDER',
'SMS_AUTH_MOCK_VERIFY_CODE',
'WECHAT_AUTH_ENABLED',
'WECHAT_AUTH_PROVIDER',
]);
export function buildProtectedEnvKeys(baseEnv) {
return new Set(
@@ -29,7 +36,7 @@ export function loadEnvFile(path, target, protectedKeys) {
}
const [, key, rawValue] = match;
if (protectedKeys.has(key)) {
if (protectedKeys.has(key) && !LOCAL_ENV_OVERRIDE_KEYS.has(key)) {
continue;
}

View File

@@ -68,6 +68,26 @@ describe('dev utils env merge', () => {
);
});
test('本地认证开关覆盖外层 shell 旧值', () => {
withTempEnvFiles(
{
'.env.local': [
'SMS_AUTH_ENABLED=true',
'SMS_AUTH_PROVIDER=aliyun',
].join('\n'),
},
(_env, tempDir) => {
const env = mergeApiServerEnv(tempDir, {
SMS_AUTH_ENABLED: 'false',
SMS_AUTH_PROVIDER: 'mock',
});
expect(env.SMS_AUTH_ENABLED).toBe('true');
expect(env.SMS_AUTH_PROVIDER).toBe('aliyun');
},
);
});
test('空外层 shell 变量不会遮蔽本地私密配置', () => {
withTempEnvFiles(
{

View File

@@ -978,6 +978,16 @@ fn parse_duration_seconds(raw: &str) -> Option<u64> {
}
fn parse_bool(raw: &str) -> Option<bool> {
let raw = raw.trim();
let raw = raw
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.or_else(|| {
raw.strip_prefix('\'')
.and_then(|value| value.strip_suffix('\''))
})
.unwrap_or(raw);
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
@@ -1053,7 +1063,9 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
#[cfg(test)]
mod tests {
use super::{AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider};
use super::{
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, parse_bool,
};
use std::sync::{Mutex, OnceLock};
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
@@ -1086,6 +1098,34 @@ mod tests {
);
}
#[test]
fn parse_bool_accepts_wrapped_quotes_from_shell_env() {
assert_eq!(parse_bool("\"true\""), Some(true));
assert_eq!(parse_bool("'true'"), Some(true));
assert_eq!(parse_bool("\"false\""), Some(false));
assert_eq!(parse_bool("'off'"), Some(false));
}
#[test]
fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("SMS_AUTH_ENABLED");
std::env::set_var("SMS_AUTH_ENABLED", "\"true\"");
}
let config = AppConfig::from_env();
assert!(config.sms_auth_enabled);
unsafe {
std::env::remove_var("SMS_AUTH_ENABLED");
}
}
#[test]
fn from_env_reads_non_public_models_and_urls() {
let _guard = ENV_LOCK

File diff suppressed because it is too large Load Diff

View File

@@ -316,7 +316,7 @@ test('auth gate does not auto-create a guest account when dev guest switch is no
expect(await screen.findByText('应用内容')).toBeTruthy();
});
test('auth gate keeps password entry available when login options are empty', async () => {
test('auth gate keeps sms and password entries available when login options are empty', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
@@ -336,12 +336,19 @@ test('auth gate keeps password entry available when login options are empty', as
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy();
expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy();
expect(within(dialog).getByLabelText('验证码')).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '获取验证码' }),
).toBeTruthy();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(within(dialog).getByLabelText('密码')).toBeTruthy();
expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
expect(within(dialog).queryByText('读取登录方式失败')).toBeNull();
});
test('auth gate falls back to password entry when login options request fails', async () => {
test('auth gate keeps sms and password entries available when login options request fails', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockRejectedValue(
@@ -357,6 +364,13 @@ test('auth gate falls back to password entry when login options request fails',
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy();
expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy();
expect(within(dialog).getByLabelText('验证码')).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '获取验证码' }),
).toBeTruthy();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(within(dialog).getByLabelText('密码')).toBeTruthy();
expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
});

View File

@@ -61,7 +61,7 @@ type AuthStatus =
| 'ready'
| 'error';
const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password'];
const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password'];
function readInviteCodeFromLocation(): string {
const params = new URLSearchParams(window.location.search || '');
@@ -76,11 +76,13 @@ function normalizeAvailableLoginMethods(
): AuthLoginMethod[] {
const normalizedMethods = Array.from(new Set(methods ?? []));
// 密码登录由 Rust auth entry 固定承载,不依赖短信或微信环境开关
// 当 login-options 联调失败或配置返回空数组时,仍要保留账号入口,避免登录弹窗失去可操作方式。
return normalizedMethods.length > 0
? normalizedMethods
: FALLBACK_LOGIN_METHODS;
// 登录面板的核心入口必须稳定展示login-options 只补充微信环境相关入口
return Array.from(
new Set<AuthLoginMethod>([
...REQUIRED_LOGIN_METHODS,
...normalizedMethods,
]),
);
}
type AuthHydrateSessionResult =
@@ -367,9 +369,9 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
setAvailableLoginMethods(FALLBACK_LOGIN_METHODS);
setAvailableLoginMethods(REQUIRED_LOGIN_METHODS);
setUser(null);
// 中文注释:登录方式接口失败时按产品约定保留密码登录入口;
// 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口;
// 这里不展示接口读取错误,避免用户误以为登录本身不可用。
setError(callbackResult?.error ?? '');
setStatus('unauthenticated');

View File

@@ -80,8 +80,8 @@ export function LoginScreen({
const [legalConsentChecked, setLegalConsentChecked] = useState(false);
const [activeLegalDocumentId, setActiveLegalDocumentId] =
useState<LegalDocumentId | null>(null);
const passwordLoginEnabled = availableLoginMethods.includes('password');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const passwordLoginEnabled = true;
const phoneLoginEnabled = true;
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');

View File

@@ -1718,6 +1718,10 @@ function isMiniGameDraftGenerating(state: MiniGameDraftGenerationState | null) {
return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed');
}
function isPersistedDraftGenerating(value: string | null | undefined) {
return value?.trim() === 'generating';
}
function resolveProfileWalletBalance(
dashboard: { walletBalance?: number | null } | null | undefined,
) {
@@ -8750,7 +8754,7 @@ export function PlatformEntryFlowShellImpl({
item.sourceSessionId,
buildPuzzleResultWorkId(item.sourceSessionId),
buildPuzzleResultProfileId(item.sourceSessionId),
]);
]) || isPersistedDraftGenerating(item.generationStatus);
setPuzzleOperation(null);
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
@@ -8897,7 +8901,7 @@ export function PlatformEntryFlowShellImpl({
item.workId,
item.profileId,
item.sourceSessionId,
]);
]) || isPersistedDraftGenerating(item.generationStatus);
const backgroundTask = getMatch3DBackgroundCompileTask(
item.sourceSessionId,
@@ -8972,7 +8976,10 @@ export function PlatformEntryFlowShellImpl({
setMatch3DFormDraftPayload(null);
setMatch3DProfile(null);
setMatch3DGenerationState(
createMiniGameDraftGenerationState('match3d'),
createMiniGameDraftGenerationStateFromStartedAt(
'match3d',
parseDraftGenerationStartedAtMs(item.updatedAt),
),
);
enterCreateTab();
selectionStageRef.current = 'match3d-generating';

View File

@@ -3432,6 +3432,73 @@ test('running match3d persisted draft reopens progress instead of unfinished res
);
});
test('persisted generating match3d draft opens generation progress after refresh', async () => {
const user = userEvent.setup();
const persistedGeneratingWork: Match3DWorkSummary = {
workId: 'match3d-work-generating',
profileId: 'match3d-profile-generating',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-generating',
gameName: '生成中抓鹅',
themeText: '霓虹水果摊',
summary: '刷新后仍应回到抓大鹅生成面板。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-18T12:05:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
generatedItemAssets: [],
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [persistedGeneratingWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValueOnce({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-generating',
draft: {
profileId: 'match3d-profile-generating',
gameName: '生成中抓鹅',
themeText: '霓虹水果摊',
summary: '刷新后仍应回到抓大鹅生成面板。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets: [],
},
stage: 'draft_ready',
lastAssistantReply: '正在生成抓大鹅素材。',
updatedAt: '2026-05-18T12:05:00.000Z',
}),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: /继续创作《生成中抓鹅》/u }),
);
await waitFor(() => {
expect(match3dCreationClient.getSession).toHaveBeenCalledWith(
'match3d-session-generating',
);
});
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-generating',
);
});
test('running match3d form generation keeps other creation templates available', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
@@ -6410,6 +6477,59 @@ test('puzzle draft result back button returns to creation hub', async () => {
expect(screen.queryByText('拼图结果页')).toBeNull();
});
test('persisted generating puzzle draft opens generation progress after refresh', async () => {
const user = userEvent.setup();
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [
{
workId: 'puzzle-work-session-generating',
profileId: 'puzzle-profile-session-generating',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-generating',
authorDisplayName: '测试玩家',
workTitle: '生成中拼图',
workDescription: '刷新后仍应回到生成面板。',
levelName: '生成中拼图',
summary: '刷新后仍应回到生成面板。',
themeTags: ['雨夜'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-05-18T12:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
generationStatus: 'generating',
},
],
});
vi.mocked(getPuzzleAgentSession).mockResolvedValueOnce({
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-generating',
stage: 'collecting_anchors',
progressPercent: 42,
lastAssistantReply: '正在生成拼图草稿。',
updatedAt: '2026-05-18T12:00:00.000Z',
}),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
await waitFor(() => {
expect(getPuzzleAgentSession).toHaveBeenCalledWith(
'puzzle-session-generating',
);
});
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
expect(screen.queryByText('拼图结果页')).toBeNull();
});
test('published puzzle work card restores its source session for editing', async () => {
const user = userEvent.setup();

View File

@@ -387,16 +387,24 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
fallbackSrc,
alt,
className,
...rest
}: {
src?: string | null;
fallbackSrc?: string | null;
alt?: string;
className?: string;
}) =>
src ? (
<img src={src} alt={alt ?? ''} className={className} {...rest} />
<img
src={src}
data-fallback-src={fallbackSrc ?? undefined}
alt={alt ?? ''}
className={className}
{...rest}
/>
) : null,
}));
@@ -2901,6 +2909,36 @@ test('mobile discover recommend feed only rotates the card closest to screen cen
);
});
test('mobile discover recommend feed renders cover fallback for legacy browsers', async () => {
renderStatefulLoggedOutHomeView({
latestEntries: [
{
...puzzlePublicEntry,
coverImageSrc:
'/generated-puzzle-assets/puzzle-session-1/cover/image.png',
},
],
});
const discoverPanel = document.getElementById('platform-tab-panel-category');
if (!discoverPanel) {
throw new Error('缺少发现面板');
}
const card = within(discoverPanel).getByRole('button', { name: //u });
const cover = card.querySelector('.platform-public-work-card__cover');
const image = within(card).getByRole('img');
expect(cover).toBeTruthy();
expect(cover?.className).toContain('platform-public-work-card__cover');
expect(image.getAttribute('src')).toBe(
'/generated-puzzle-assets/puzzle-session-1/cover/image.png',
);
expect(image.getAttribute('data-fallback-src')).toBe(
'/creation-type-references/puzzle.webp',
);
});
test('mobile today channel only shows newly published works from today', async () => {
const user = userEvent.setup();
const now = new Date();

View File

@@ -31,6 +31,7 @@ import {
UserRound,
XCircle,
} from 'lucide-react';
import QRCode from 'qrcode';
import {
type ComponentType,
type CSSProperties,
@@ -42,7 +43,6 @@ import {
useRef,
useState,
} from 'react';
import QRCode from 'qrcode';
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
@@ -58,13 +58,13 @@ import type {
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse,
WechatNativePayment,
ProfileSaveArchiveSummary,
ProfileTaskCenterResponse,
ProfileTaskItem,
ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
WechatMiniProgramPayParams,
WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
@@ -137,6 +137,7 @@ import {
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldFallbackCoverImage,
resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation';
@@ -392,11 +393,13 @@ function usePlatformDesktopLayout() {
function ResolvedAssetBackdrop({
src,
fallbackSrc,
alt,
className,
ariaHidden = false,
}: {
src?: string | null;
fallbackSrc?: string | null;
alt: string;
className: string;
ariaHidden?: boolean;
@@ -404,6 +407,7 @@ function ResolvedAssetBackdrop({
return (
<ResolvedAssetImage
src={src}
fallbackSrc={fallbackSrc}
alt={alt}
aria-hidden={ariaHidden}
className={className}
@@ -522,6 +526,7 @@ function WorldCard({
variant?: 'standard' | 'immersive';
}) {
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry);
const fallbackAssetCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
const coverSlides = useMemo(() => {
if (!enableCoverCarousel) {
return fallbackCoverImage
@@ -606,6 +611,7 @@ function WorldCard({
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
fallbackSrc={fallbackAssetCoverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>
@@ -692,6 +698,7 @@ function RecommendCoverOnlyCard({
onClick: () => void;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePublicGalleryCardKind(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
@@ -708,6 +715,7 @@ function RecommendCoverOnlyCard({
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
fallbackSrc={fallbackCoverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>

View File

@@ -13,7 +13,9 @@ import {
mapBabyObjectMatchDraftToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard,
type PlatformEdutainmentGalleryCard,
type PlatformPuzzleGalleryCard,
resolvePlatformPublicWorkCode,
resolvePlatformWorldFallbackCoverImage,
} from './rpgEntryWorldPresentation';
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
@@ -46,6 +48,32 @@ test('platform work display text limits names and tags by character count', () =
).toEqual(['超长机关', '星桥']);
});
test('platform public cards use play type reference images as cover fallback', () => {
const puzzleCard: PlatformPuzzleGalleryCard = {
sourceType: 'puzzle',
workId: 'puzzle-work-1',
profileId: 'puzzle-profile-1',
publicWorkCode: 'PZ-PUZZLE1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
worldName: '机关拼图',
subtitle: '拼图关卡',
summaryText: '公开作品',
coverImageSrc: '/generated-puzzle-assets/session/cover/image.png',
themeTags: ['拼图'],
playCount: 1,
remixCount: 0,
likeCount: 0,
visibility: 'published',
publishedAt: '2026-05-18T00:00:00.000Z',
updatedAt: '2026-05-18T00:00:00.000Z',
};
expect(resolvePlatformWorldFallbackCoverImage(puzzleCard)).toBe(
'/creation-type-references/puzzle.webp',
);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
const slides = buildPuzzleWorkCoverSlides({
workId: 'work-1',

View File

@@ -446,6 +446,36 @@ export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
return '';
}
export function resolvePlatformWorldFallbackCoverImage(
entry: PlatformWorldCardLike,
) {
if (isPuzzleGalleryEntry(entry)) {
return '/creation-type-references/puzzle.webp';
}
if (isMatch3DGalleryEntry(entry)) {
return '/creation-type-references/match3d.webp';
}
if (isSquareHoleGalleryEntry(entry)) {
return '/creation-type-references/square-hole.webp';
}
if (isVisualNovelGalleryEntry(entry)) {
return '/creation-type-references/visual-novel.webp';
}
if (isBigFishGalleryEntry(entry)) {
return '/creation-type-references/big-fish.webp';
}
if (isEdutainmentGalleryEntry(entry)) {
return '/creation-type-references/creative-agent.webp';
}
return '/creation-type-references/rpg.webp';
}
export function resolvePlatformWorldCoverSlides(
entry: PlatformWorldCardLike,
): PlatformPuzzleCoverSlide[] {

View File

@@ -1226,6 +1226,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
background: color-mix(in srgb, var(--platform-panel-fill-soft) 86%, #000 14%);
}
.platform-public-work-card__cover::before {
content: '';
display: block;
padding-top: 56.25%;
}
.platform-public-work-card__body {
background: color-mix(in srgb, var(--platform-subpanel-fill) 92%, #000 8%);
}
@@ -4902,6 +4908,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
min-height: min(58vh, 28rem);
}
.platform-public-work-card--immersive .platform-public-work-card__cover::before {
padding-top: 122%;
}
.platform-public-work-card--immersive .platform-public-work-card__body {
min-height: 8rem;
padding: 0.9rem 0.95rem 1rem;

View File

@@ -25,15 +25,15 @@ describe('miniGameDraftGenerationProgress', () => {
expect(progress?.steps.map((step) => step.label)).toEqual([
'编译首关草稿',
'生成关卡名称',
'生成首关画面',
'生成UI背景',
'并行生成素材',
'校验背景资源',
'写入正式草稿',
]);
expect(progress?.phaseLabel).toBe('编译首关草稿');
expect(progress?.steps[0]?.detail).toBe(
'读取画面描述,建立可编辑草稿与首关结构。',
);
expect(progress?.estimatedRemainingMs).toBe(130_500);
expect(progress?.estimatedRemainingMs).toBe(298_500);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
});
@@ -49,17 +49,20 @@ describe('miniGameDraftGenerationProgress', () => {
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 96_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 126_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 282_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(
state,
296_000,
);
expect(imageProgress?.phaseId).toBe('puzzle-images');
expect(imageProgress?.estimatedRemainingMs).toBe(107_000);
expect(imageProgress?.estimatedRemainingMs).toBe(275_000);
expect(imageProgress?.steps[1]?.status).toBe('completed');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
expect(uiProgress?.phaseId).toBe('puzzle-ui-background');
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(7_000);
expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000);
expect(writeBackProgress?.steps[3]?.status).toBe('completed');
expect(writeBackProgress?.steps[4]?.status).toBe('active');
});
@@ -74,7 +77,7 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 200_000);
const progress = buildMiniGameDraftGenerationProgress(state, 360_000);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);

View File

@@ -93,15 +93,15 @@ const PUZZLE_STEPS = [
},
{
id: 'puzzle-images',
label: '生成首关画面',
detail: '调用图片模型生成适合切块的正方形首图。',
weight: 42,
label: '并行生成素材',
detail: '同时生成首关画面与 9:16 纯背景。',
weight: 74,
},
{
id: 'puzzle-ui-background',
label: '生成UI背景',
detail: '生成不含槽位和控件的 9:16 纯背景。',
weight: 32,
label: '校验背景资源',
detail: '确认首关图和 UI 背景都已写入资产库。',
weight: 0,
},
{
id: 'puzzle-select-image',
@@ -111,7 +111,7 @@ const PUZZLE_STEPS = [
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const PUZZLE_ESTIMATED_WAIT_MS = 132_000;
const PUZZLE_ESTIMATED_WAIT_MS = 5 * 60_000;
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000;
const PUZZLE_PHASE_TIMELINE: Array<{
@@ -127,8 +127,8 @@ const PUZZLE_PHASE_TIMELINE: Array<{
}> = [
{ phase: 'compile', durationMs: 12_000 },
{ phase: 'puzzle-level-name', durationMs: 8_000 },
{ phase: 'puzzle-images', durationMs: 70_000 },
{ phase: 'puzzle-ui-background', durationMs: 32_000 },
{ phase: 'puzzle-images', durationMs: 260_000 },
{ phase: 'puzzle-ui-background', durationMs: 10_000 },
{ phase: 'puzzle-select-image', durationMs: 10_000 },
];