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:
@@ -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`。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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` 不会执行:
|
||||
|
||||
|
||||
@@ -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 私有封面换签失败时要回落到玩法类型参考图,避免卡片整体黑底。
|
||||
|
||||
@@ -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. “我的”页账户充值弹窗包含 `泥点充值` 与 `会员卡充值` 两个页签,入口必须打开独立弹窗,不在当前面板下方展开。
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user