diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md
index 0570e1a6..5f3612c2 100644
--- a/.hermes/shared-memory/decision-log.md
+++ b/.hermes/shared-memory/decision-log.md
@@ -16,6 +16,22 @@
---
+## 2026-06-13 图片大图预览统一为黑底全屏查看器
+
+- 背景:`CreativeImageInputPanel` 的参考图 / 主图预览曾使用白底 `UnifiedModal` 工具弹窗,移动端会透出原页面背景,且不能全屏查看、缩放或拖拽细节。
+- 决策:纯图片大图预览统一使用 `src/components/common/PlatformImagePreviewModal.tsx`。该组件底层复用 `UnifiedModal` 的 dialog / portal / Escape 语义,但视觉上固定为黑底全屏查看器;图片按视口 contain 初始完整展示,缩放范围固定 `1x-4x`,拖拽位移按缩放后的图片边界夹取,避免露出背景。裁剪、选择、编辑等工具语义仍继续使用白底工具弹窗,不并入图片查看器。
+- 影响范围:`CreativeImageInputPanel` 的参考图预览、主图预览,以及后续 common 级图片查看场景。
+- 验证方式:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`、`npm run typecheck`、`npm run check:encoding`。
+- 关联文档:`docs/README.md`、`docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md`。
+
+## 2026-06-13 外部生成队列概览归属“我的”页签
+
+- 背景:外部生成 worker 队列从单个生成页等待信息扩展为当前账号级别的后台排队 / 生成概览;继续放在生成页 / 进度页会把账号级队列与当前玩法业务进度混在一起。
+- 决策:移动端用户可见的外部生成队列概览统一放在一级 `我的` 页签;生成页 / 进度页只展示当前玩法的阶段、步骤、总进度、错误和重试动作。队列概览只读取 BFF `GET /api/runtime/external-generation/queue-overview` 与当前前端已知单 job 状态作为等待补充,不替代玩法 session/detail 的 ready / failed 回读。
+- 影响范围:平台入口壳层轮询条件、`RpgEntryHomeView` 我的页卡片、共用生成页 `CustomWorldGenerationView` / `UnifiedGenerationPage`、外部生成 worker 技术文档和本地开发验证文档。
+- 验证方式:生成页不出现“生成队列”区域;登录用户进入“我的”页且队列有 pending/running 或当前 job 为 queued/running/failed 时显示队列卡;退出登录或切换账号时不保留旧账号队列概览。前端验证运行 `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/unified-creation/UnifiedGenerationPage.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`。
+- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
+
## 2026-06-12 外部生成 worker 扩展到跳一跳、拼消消和敲木鱼
- 背景:外部图片生成已从 HTTP 长请求迁到 `external_generation_job` 队列;跳一跳、拼消消和敲木鱼继续扩展时需要统一 job 粒度、前端等待展示和本地 / 生产验证口径。
diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md
index eb41f0e5..5d999557 100644
--- a/.hermes/shared-memory/pitfalls.md
+++ b/.hermes/shared-memory/pitfalls.md
@@ -248,6 +248,14 @@
- 验证:聚焦测试断言关卡缩略图使用 `object-contain` 且没有 `object-cover`,并断言关卡详情内主图预览 overlay 层级高于详情弹窗;浏览器里检查列表完整显示图片,详情内点击画面图能打开可见预览。
- 关联:`src/components/puzzle-result/PuzzleResultView.tsx`、`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-result/PuzzleResultView.test.tsx`。
+## 图片大图预览不要复用白底工具弹窗
+
+- 现象:点击图像输入面板里的参考图或主图预览后,页面只出现白底非全屏弹窗,背后原页面透出,不能缩放或拖拽查看细节。
+- 原因:图片查看和工具弹窗共用了 `UnifiedModal` 白底壳层;该壳层适合编辑 / 选择工具,不适合沉浸式看图,也没有图片边界拖拽状态。
+- 处理:纯图片预览统一走 `PlatformImagePreviewModal`,全屏黑底展示,初始 contain 保证完整图片可见,缩放夹在 `1x-4x`,拖拽位移按缩放后的图片边界夹取,避免把图片拖到露出背景。
+- 验证:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx` 应覆盖黑底全屏、缩放上限、拖拽边界和关闭按钮。
+- 关联:`src/components/common/PlatformImagePreviewModal.tsx`、`src/components/common/CreativeImageInputPanel.tsx`。
+
## 玩法入口分类字段缺失要前端兜底
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel` 调 `trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
diff --git a/docs/README.md b/docs/README.md
index 7aa90c09..fc92fc35 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -85,7 +85,7 @@ RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/compon
平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。
-平台 UI Kit 的提示 / 确认弹窗收口到 `src/components/common/UnifiedConfirmDialog.tsx`,复制反馈收口到 `src/components/common/useCopyFeedback.ts`、`src/components/common/CopyFeedbackButton.tsx`、`src/components/common/CopyCodeButton.tsx` 与 `src/components/common/CopyFeedbackMessage.tsx`,基础状态提示收口到 `src/components/common/PlatformStatusMessage.tsx`,运行态短错误 / 成功 / 反馈 toast 收口到 `src/components/common/PlatformRuntimeStatusToast.tsx`,平台空态 / 轻量加载态收口到 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮收口到 `src/components/common/PlatformActionButton.tsx`,平台白底子面板 / 小型列表卡片收口到 `src/components/common/PlatformSubpanel.tsx`,平台输入框 / 文本域收口到 `src/components/common/PlatformTextField.tsx`,平台字段标题收口到 `src/components/common/PlatformFieldLabel.tsx`,平台媒体预览框收口到 `src/components/common/PlatformMediaFrame.tsx`,平台胶囊状态标签收口到 `src/components/common/PlatformPillBadge.tsx`,平台 / 个人中心弹窗关闭按钮收口到 `src/components/common/PlatformModalCloseButton.tsx`,底层继续复用 `UnifiedModal`;普通提示、确认 / 取消、危险确认、复制状态机、短代码复制 chip、复制按钮表现、白底 / 个人中心 / 认证入口 token 状态条、运行态状态 toast、无操作空态、主动作按钮、白底子面板、白底交互列表卡片、普通输入字段、字段标题、图片源 / fallback / 固定比例媒体预览、单个状态 / 标签 chip 和圆形关闭按钮优先使用公共 Module,规则见 [【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md](./technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md)。
+平台 UI Kit 的提示 / 确认弹窗收口到 `src/components/common/UnifiedConfirmDialog.tsx`,复制反馈收口到 `src/components/common/useCopyFeedback.ts`、`src/components/common/CopyFeedbackButton.tsx`、`src/components/common/CopyCodeButton.tsx` 与 `src/components/common/CopyFeedbackMessage.tsx`,基础状态提示收口到 `src/components/common/PlatformStatusMessage.tsx`,运行态短错误 / 成功 / 反馈 toast 收口到 `src/components/common/PlatformRuntimeStatusToast.tsx`,平台空态 / 轻量加载态收口到 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮收口到 `src/components/common/PlatformActionButton.tsx`,平台白底子面板 / 小型列表卡片收口到 `src/components/common/PlatformSubpanel.tsx`,平台输入框 / 文本域收口到 `src/components/common/PlatformTextField.tsx`,平台字段标题收口到 `src/components/common/PlatformFieldLabel.tsx`,平台媒体预览框收口到 `src/components/common/PlatformMediaFrame.tsx`,平台胶囊状态标签收口到 `src/components/common/PlatformPillBadge.tsx`,平台图片全屏预览收口到 `src/components/common/PlatformImagePreviewModal.tsx`,平台 / 个人中心弹窗关闭按钮收口到 `src/components/common/PlatformModalCloseButton.tsx`,底层继续复用 `UnifiedModal`;普通提示、确认 / 取消、危险确认、复制状态机、短代码复制 chip、复制按钮表现、白底 / 个人中心 / 认证入口 token 状态条、运行态状态 toast、无操作空态、主动作按钮、白底子面板、白底交互列表卡片、普通输入字段、字段标题、图片源 / fallback / 固定比例媒体预览、全屏黑底图片查看、单个状态 / 标签 chip 和圆形关闭按钮优先使用公共 Module,规则见 [【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md](./technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md)。
平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。
diff --git a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md
index a38545a4..9e7d27f4 100644
--- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md
+++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md
@@ -277,7 +277,7 @@
19.3.47. `PlatformDarkModalFooter` 继续从标准双按钮 footer 扩展到 detail / confirm 收尾:`NpcModals.tsx` 的交易详情单按钮 footer 与 `MapModal.tsx` 的场景切换确认 footer 已接入共享 dark footer frame,分别保留“关闭”单 CTA 和“取消 / 确认前往”双 CTA 的业务语义、按钮 tone 与禁用态。后续 dark / pixel modal 里若只是标准底部分隔线 + 常规动作区排布,优先直接复用 `PlatformDarkModalFooter`,即使只有单个按钮也不再手写 `flex justify-end`;但像 `SquareImageCropModal.tsx` 这类白底弹窗 footer、sticky 工作台 footer 和运行态 HUD 工具条继续留在各自语义壳层,不强行混到 dark footer 抽象里。验证命令:`npx vitest run src/components/NpcModals.test.tsx src/components/MapModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
19.3.48. `RpgEntryHomeView.tsx` 里的分类筛选工具条继续从页面内重复 JSX 收口到 `src/components/common/PlatformFilterToolbar.tsx`;该 Module 只承接“筛选按钮 + 横向 tabs + 排序按钮”的结构排布,暴露 `mobile / desktop` 两种 layout 以覆盖移动端 divider + 独立排序行和桌面端同排布局差异,但不持有分类列表、筛选状态、空态或排序逻辑。当前 RPG 首页分类区已接入,后续若其它白底列表页也出现同构的筛选壳层,可直接复用这套薄结构组件;若场景只是在单页内局部重复、接口会为了兼容业务差异不断膨胀,则优先退回文件内 helper,不把 `common` 扩成假的“万能筛选条”。验证命令:`npx vitest run src/components/common/PlatformFilterToolbar.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
19.3.49. `SquareImageCropModal.tsx` 的白底 modal 壳层与 footer 已收口到 `src/components/common/UnifiedModal.tsx`;`UnifiedModal` 为此只薄补了 `titleId` 与 `closeIcon` 透传,继续由调用方决定 `closeOnBackdrop`、`closeOnEscape`、`portal`、header/footer 样式和按钮内容,不额外掺入 profile 业务语义,也不让 `common/` 反向依赖 `platform-entry/`。`SquareImageCropModal.tsx` 继续保留裁剪拖拽、pointer capture、保存禁用态与两列等宽 footer 行为,只把 header / body / footer 外壳交给共享 modal 承接。后续 `common` 级白底工具弹窗若只是标准标题栏 + 内容区 + footer 按钮排布,优先先看 `UnifiedModal` 是否够用,再决定是否需要新的薄壳;不要为了一个弹窗把 `PlatformProfileModalShell` 之类带页面语义的壳层倒灌回 `common`。验证命令:`npx vitest run src/components/common/SquareImageCropModal.test.tsx src/components/common/UnifiedModal.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
-19.3.50. `CreativeImageInputPanel.tsx` 里内嵌的 white tool modal 继续并回 `UnifiedModal` 体系:参考图预览与主图预览都改成直接复用 `src/components/common/UnifiedModal.tsx`,继续保留各自 `max-w` / `max-h` 节奏、点击遮罩关闭与紧凑 header;移除图片确认改成复用 `src/components/common/UnifiedConfirmDialog.tsx`,不再在 panel 内手写 `platform-modal-backdrop + platform-modal-shell + 两列按钮`。这次没有新增 `PlatformImagePreviewModal`,因为当前预览弹窗差异还只在尺寸和文案层,继续直接组合 `UnifiedModal` 更深、更稳。后续 `common` 级图片面板若出现同类“预览大图 + 单标题栏 + 关闭按钮”弹窗,优先先复用 `UnifiedModal` 并把尺寸/文案留在调用方;只有当至少两到三个调用点开始重复同一套 preview body/header adapter 时,再考虑补新的薄壳。验证命令:`npx vitest run src/components/common/CreativeImageInputPanel.test.tsx src/components/common/UnifiedModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
+19.3.50. `CreativeImageInputPanel.tsx` 里内嵌的图片查看器改为 `src/components/common/PlatformImagePreviewModal.tsx`:参考图预览与主图预览都使用黑底全屏查看器,底层继续委托 `UnifiedModal size="fullscreen"` 承接 dialog / portal / Escape 语义,但 overlay、panel 和 body 必须强制全屏黑底,避免透出原页面或白底工具面板。查看器固定提供缩小、重置、放大和关闭图标按钮,缩放范围夹在 `1x-4x`;图片先按视口完整 contain,放大后拖拽位移按缩放后的图片边界夹取,不能把图片拖到露出背景。移除图片确认继续复用 `src/components/common/UnifiedConfirmDialog.tsx`,不和全屏查看器混同。后续 `common` 级图片大图预览优先复用 `PlatformImagePreviewModal`,若只是裁剪、选择或编辑工具弹窗,再回到 `UnifiedModal` / `PlatformToolModalShell` 的白底工具语义。验证命令:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
19.3.51. `PlatformReportDialog.tsx` 与 `PublishShareModal.tsx` 共同的工具信息弹窗壳层继续收口到 `src/components/common/PlatformUtilityInfoModal.tsx`;该 Module 只承接平台主题 overlay、白底 panel,以及 body / footer 的基础间距与标准 footer frame,底层继续委托 `UnifiedModal.tsx`,不吸收报告字段列表、分享正文、复制逻辑、渠道按钮或品牌图标这些业务内容。`PlatformReportDialog.tsx` 继续保留 `PlatformInfoBlock` 字段列表与 joined report copy 行为,`PublishShareModal.tsx` 继续保留分享文案、主复制动作和渠道按钮网格;后续 `common` 级白底工具信息弹窗若只是重复这套“共享 modal 外壳 + 业务正文 / footer 内容”的骨架,优先复用 `PlatformUtilityInfoModal`,只有当正文编排或 footer 交互明显偏离时才回退到直接组合 `UnifiedModal`。验证命令:`npx vitest run src/components/common/PlatformUtilityInfoModal.test.tsx src/components/common/PlatformReportDialog.test.tsx src/components/common/PublishShareModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
19.3.52. profile 白底 modal 里的摘要头、列表骨架和内容行继续沉到 `src/components/common/PlatformProfileSummaryHeader.tsx`、`src/components/common/PlatformProfileSkeletonList.tsx` 与 `src/components/common/PlatformProfileContentRow.tsx`;这三个 Module 只承接 `kicker + title + badge` 的摘要层次、重复 skeleton 列表行,以及 `PlatformSubpanel` 上的 `div / button` 内容行语义,不持有账单金额、任务进度、邀请用户信息、充值商品结构或 modal 状态切换逻辑。`PlatformProfileWalletLedgerModal.tsx`、`PlatformProfileTaskCenterModal.tsx`、`PlatformProfilePlayedWorksModal.tsx`、`PlatformProfileReferralModal.tsx` 与 `PlatformProfileRechargeModal.tsx` 已接入;后续 profile 副弹层若只是重复这三类白底内容骨架,优先继续复用这组薄组件,不再把 skeleton、摘要头和 row chrome 写回各自 modal。验证命令:`npx vitest run src/components/common/PlatformProfileModalContent.shared.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
19.3.53. 认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该 Module 只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx` 与 `RegistrationInviteModal.tsx` 已接入,业务组件只保留表单状态与提交流程。后续认证域新增同形态白底弹窗时优先复用该壳层;账号安全详情和绑定手机号这类布局差异较大的卡片先独立评估,不把 auth shell 扩成万能认证容器。验证命令:`npx vitest run src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
index 90eb4a19..d5d6b2b2 100644
--- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
+++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
@@ -35,10 +35,10 @@
队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table:
-- `GET /api/runtime/external-generation/queue-overview`:队列概览,用于生成页、调试面板或后台观测当前用户可见的等待状态。返回 pending / running / completed / failed / cancelled 数量、最早等待时间、当前可见 job 摘要,以及是否存在过期 lease 需要等待 worker 重领。
+- `GET /api/runtime/external-generation/queue-overview`:队列概览,用于 `我的` 页签、调试面板或后台观测当前用户可见的等待状态。返回 pending / running / completed / failed / cancelled 数量、最早等待时间、当前可见 job 摘要,以及是否存在过期 lease 需要等待 worker 重领。
- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。
-BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页展示“排队中 / 处理中 / 失败 / 完成”时,应优先用单 job 状态补充等待信息,再继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。
+BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见队列概览放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。
## 任务表
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index 8e9a5ba7..431606fc 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -55,7 +55,7 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模
本地排查外部内容生成 worker 队列时,必须显式使用 queue,例如 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server`,让同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前进入持久队列的外部图片生成动作包括:拼图 `compile_puzzle_draft` / `generate_puzzle_images` / `generate_puzzle_ui_background`,跳一跳 `compile-draft` / `regenerate-tiles`,拼消消 `compile-draft` / `regenerate-atlas`,敲木鱼 `compile-draft` / `regenerate-hit-object`。非外部图片生成动作继续 inline,不进入队列。worker 数量为 0 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。
-生成页或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。
+`我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。
需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `worker_smoke_unsupported` 测试 job;预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table,脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。完成 queue 链路验证时,还要用队列概览 BFF 和单 job 状态接口确认 job 从 queued/running 收敛,并用对应玩法 session/detail 接口确认业务状态同步完成。
diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
index 0af3f4a1..ac3104e6 100644
--- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
+++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
@@ -36,7 +36,7 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts`
平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner;表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。生成任务在用户离开生成页后异步失败时,也必须通过同一弹窗通知用户,并把失败消息写入该 session 的草稿 notice,供草稿页和失败重试页恢复使用。
-生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。
+生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。外部生成队列的用户可见概览统一放在移动端一级 `我的` 页签,生成页 / 进度页只展示当前玩法的阶段、步骤、总进度、错误和重试动作;用户离开生成页后仍可在 `我的` 页查看当前账号可见的排队与生成数量。队列概览只作为等待状态补充,草稿 ready / failed 与作品结果仍以后端玩法 session/detail 回读为准。
入口配置中的 `open=false` 表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。
diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx
index 886172b4..9c786a9c 100644
--- a/src/components/CustomWorldGenerationView.tsx
+++ b/src/components/CustomWorldGenerationView.tsx
@@ -31,16 +31,8 @@ interface CustomWorldGenerationViewProps {
idleBadgeLabel?: string;
structuredEmptyText?: string;
hideBatchModule?: boolean;
- queueStatus?: ExternalGenerationQueueStatus | null;
}
-export type ExternalGenerationQueueStatus = {
- currentStatus?: 'queued' | 'running' | 'completed' | 'failed' | null;
- currentProgress?: number | null;
- pendingCount?: number | null;
- runningCount?: number | null;
-};
-
function formatDuration(ms: number) {
const safeMs = Math.max(0, Math.round(ms));
const totalSeconds = Math.ceil(safeMs / 1000);
@@ -93,49 +85,6 @@ function getStepStatusLabel(step: { status: string }) {
return '待处理';
}
-function resolveQueueStatusLabel(
- status: ExternalGenerationQueueStatus['currentStatus'],
-) {
- if (status === 'queued') {
- return '排队中';
- }
-
- if (status === 'running') {
- return '生成中';
- }
-
- if (status === 'failed') {
- return '生成失败';
- }
-
- if (status === 'completed') {
- return '已完成';
- }
-
- return null;
-}
-
-function hasQueueStatus(status: ExternalGenerationQueueStatus | null | undefined) {
- return Boolean(
- status &&
- (status.currentStatus ||
- typeof status.pendingCount === 'number' ||
- typeof status.runningCount === 'number'),
- );
-}
-
-function formatQueueCount(value: number | null | undefined) {
- return Math.max(0, Math.round(value ?? 0)).toString();
-}
-
-function formatQueueProgress(value: number | null | undefined) {
- if (typeof value !== 'number' || !Number.isFinite(value)) {
- return null;
- }
-
- return `${Math.max(0, Math.min(100, Math.round(value)))}%`;
-}
-
function resolveCurrentGenerationStep(
progress: CustomWorldGenerationProgress | null,
) {
@@ -162,7 +111,6 @@ export function CustomWorldGenerationView({
activeBadgeLabel = '世界建设中',
idleBadgeLabel = '等待操作',
hideBatchModule = false,
- queueStatus = null,
}: CustomWorldGenerationViewProps) {
void hideBatchModule;
const progressValue = getProgressPercentage(progress);
@@ -183,11 +131,6 @@ export function CustomWorldGenerationView({
: '校准中';
const elapsedText =
progress != null ? formatDuration(progress.elapsedMs) : '启动中';
- const queueStatusLabel = resolveQueueStatusLabel(
- queueStatus?.currentStatus ?? null,
- );
- const queueProgressText = formatQueueProgress(queueStatus?.currentProgress);
- const shouldShowQueueStatus = hasQueueStatus(queueStatus);
return (
@@ -224,21 +167,6 @@ export function CustomWorldGenerationView({
/>
- {shouldShowQueueStatus ? (
-
- {queueStatusLabel ? (
-
- {queueProgressText
- ? `${queueStatusLabel} ${queueProgressText}`
- : queueStatusLabel}
-
- ) : null}
- 排队 {formatQueueCount(queueStatus?.pendingCount)}
-
- 生成 {formatQueueCount(queueStatus?.runningCount)}
-
- ) : null}
-
{!isGenerating ? (
expect(onMainImageRemove).toHaveBeenCalledTimes(1);
});
-test('creative image input panel closes reference preview on backdrop click', () => {
+test('creative image input panel closes reference preview with close button', () => {
render(
{
+test('creative image input panel closes main image preview with close button', () => {
render(
) : null}
- setPreviewReferenceImage(null)}
+ imageSrc={previewReferenceImage?.imageSrc ?? null}
+ imageAlt={labels.promptReferencePreviewAlt}
closeLabel={labels.closePromptReferencePreview}
- closeVariant="profileCompact"
- size="lg"
zIndexClassName="z-[80]"
- overlayClassName="px-4 py-6"
- panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
- headerClassName="mb-3 items-center border-b-0 px-1 py-0"
- titleClassName="text-sm font-black"
- bodyClassName="px-0 py-0"
- >
- {previewReferenceImage ? (
-
-
-
- ) : null}
-
+ onClose={() => setPreviewReferenceImage(null)}
+ />
- setIsMainImagePreviewOpen(false)}
+ imageSrc={uploadedImageSrc}
+ imageAlt={uploadedImageAlt}
+ refreshKey={uploadedImageRefreshKey}
closeLabel={
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
}
- closeVariant="profileCompact"
- size="xl"
zIndexClassName={mainImagePreviewZIndexClassName}
- overlayClassName="px-4 py-6"
- panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
- headerClassName="mb-3 items-center border-b-0 px-1 py-0"
- titleClassName="text-sm font-black"
- bodyClassName="px-0 py-0"
- >
- {uploadedImageSrc ? (
-
-
-
- ) : null}
-
+ onClose={() => setIsMainImagePreviewOpen(false)}
+ />
({
+ ResolvedAssetImage: ({
+ src,
+ alt,
+ className,
+ style,
+ draggable,
+ onLoad,
+ }: {
+ src?: string | null;
+ alt?: string;
+ className?: string;
+ style?: React.CSSProperties;
+ draggable?: boolean;
+ onLoad?: React.ReactEventHandler;
+ }) =>
+ src ? (
+
+ ) : null,
+}));
+
+test('clamps full-screen image preview zoom and drag within image bounds', () => {
+ expect(
+ clampPlatformImagePreviewTransform(
+ {
+ scale: 8,
+ offsetX: 999,
+ offsetY: -999,
+ },
+ { width: 400, height: 800 },
+ { width: 400, height: 400 },
+ ),
+ ).toEqual({
+ scale: 4,
+ offsetX: 600,
+ offsetY: -400,
+ });
+
+ expect(
+ clampPlatformImagePreviewTransform(
+ {
+ scale: 4,
+ offsetX: -999,
+ offsetY: 999,
+ },
+ { width: 400, height: 800 },
+ { width: 400, height: 400 },
+ ),
+ ).toEqual({
+ scale: 4,
+ offsetX: -600,
+ offsetY: 400,
+ });
+
+ expect(
+ clampPlatformImagePreviewTransform(
+ {
+ scale: 0.25,
+ offsetX: 80,
+ offsetY: 80,
+ },
+ { width: 400, height: 800 },
+ { width: 400, height: 400 },
+ ),
+ ).toEqual({
+ scale: 1,
+ offsetX: 0,
+ offsetY: 0,
+ });
+});
+
+test('renders full-screen image preview with zoom controls and dark backdrop', () => {
+ render(
+ {}}
+ />,
+ );
+
+ const dialog = screen.getByRole('dialog', { name: '查看关卡图片' });
+ expect(dialog.className).toContain('platform-image-preview-modal');
+ expect(dialog.className).toContain('bg-black');
+ expect(
+ dialog.parentElement?.className.includes('!bg-black'),
+ ).toBe(true);
+ expect(screen.getByRole('button', { name: '放大图片' })).toBeTruthy();
+ expect(screen.getByRole('button', { name: '缩小图片' })).toBeTruthy();
+ expect(screen.getByRole('button', { name: '重置图片缩放' })).toBeTruthy();
+ expect(screen.getByAltText('拼图关卡图').getAttribute('draggable')).toBe(
+ 'false',
+ );
+});
+
+test('zooms to at most four times and clamps dragged image position', async () => {
+ render(
+ {}}
+ />,
+ );
+
+ const stage = screen.getByTestId('platform-image-preview-stage');
+ Object.defineProperty(stage, 'getBoundingClientRect', {
+ configurable: true,
+ value: () => ({
+ width: 400,
+ height: 800,
+ top: 0,
+ right: 400,
+ bottom: 800,
+ left: 0,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ }),
+ });
+
+ const image = screen.getByAltText('拼图关卡图') as HTMLImageElement;
+ Object.defineProperties(image, {
+ naturalWidth: { configurable: true, value: 400 },
+ naturalHeight: { configurable: true, value: 400 },
+ });
+
+ await act(async () => {
+ window.dispatchEvent(new Event('resize'));
+ fireEvent.load(image);
+ });
+
+ await waitFor(() => {
+ expect(image.style.width).toBe('400px');
+ expect(image.style.height).toBe('400px');
+ });
+
+ for (let index = 0; index < 8; index += 1) {
+ fireEvent.click(screen.getByRole('button', { name: '放大图片' }));
+ }
+
+ await waitFor(() => {
+ expect(image.style.transform).toContain(
+ 'translate3d(0px, 0px, 0) scale(4)',
+ );
+ });
+
+ fireEvent.pointerDown(stage, { pointerId: 1, clientX: 200, clientY: 400 });
+ fireEvent.pointerMove(stage, { pointerId: 1, clientX: 1200, clientY: 1200 });
+ fireEvent.pointerUp(stage, { pointerId: 1, clientX: 1200, clientY: 1200 });
+
+ await waitFor(() => {
+ const offsetMatch = image.style.transform.match(
+ /translate3d\((-?\d+)px, (-?\d+)px, 0\) scale\(4\)/u,
+ );
+
+ expect(offsetMatch).not.toBeNull();
+ expect(Math.abs(Number(offsetMatch?.[1]))).toBe(600);
+ expect(Math.abs(Number(offsetMatch?.[2]))).toBe(400);
+ });
+});
diff --git a/src/components/common/PlatformImagePreviewModal.tsx b/src/components/common/PlatformImagePreviewModal.tsx
new file mode 100644
index 00000000..8b9c2cf5
--- /dev/null
+++ b/src/components/common/PlatformImagePreviewModal.tsx
@@ -0,0 +1,332 @@
+import { Minus, Plus, RotateCcw } from 'lucide-react';
+import {
+ type PointerEvent as ReactPointerEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type WheelEvent as ReactWheelEvent,
+} from 'react';
+
+import { ResolvedAssetImage } from '../ResolvedAssetImage';
+import { PlatformIconButton } from './PlatformIconButton';
+import {
+ clampPlatformImagePreviewTransform,
+ DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM,
+ MAX_PLATFORM_IMAGE_PREVIEW_SCALE,
+ MIN_PLATFORM_IMAGE_PREVIEW_SCALE,
+ PLATFORM_IMAGE_PREVIEW_SCALE_STEP,
+ type PlatformImagePreviewSize,
+ type PlatformImagePreviewTransform,
+ resolvePlatformImagePreviewContainedSize,
+ samePlatformImagePreviewTransform,
+} from './platformImagePreviewModel';
+import { PlatformModalCloseButton } from './PlatformModalCloseButton';
+import { UnifiedModal } from './UnifiedModal';
+
+type PlatformImagePreviewModalProps = {
+ open: boolean;
+ title: string;
+ imageSrc: string | null;
+ imageAlt: string;
+ refreshKey?: string | number | null;
+ closeLabel: string;
+ zIndexClassName?: string;
+ onClose: () => void;
+};
+
+type DragState = {
+ pointerId: number;
+ startClientX: number;
+ startClientY: number;
+ startOffsetX: number;
+ startOffsetY: number;
+ scale: number;
+};
+
+/**
+ * 全屏图片查看器。
+ * 1x 保持完整图片可见;放大后拖拽会按图片边界夹住,避免拖出空背景。
+ */
+export function PlatformImagePreviewModal({
+ open,
+ title,
+ imageSrc,
+ imageAlt,
+ refreshKey = null,
+ closeLabel,
+ zIndexClassName = 'z-[110]',
+ onClose,
+}: PlatformImagePreviewModalProps) {
+ const stageRef = useRef(null);
+ const dragStateRef = useRef(null);
+ const [viewportSize, setViewportSize] = useState({
+ width: 1,
+ height: 1,
+ });
+ const [naturalSize, setNaturalSize] =
+ useState(null);
+ const [transform, setTransform] = useState(
+ DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM,
+ );
+ const [isDragging, setIsDragging] = useState(false);
+
+ const containedImageSize = useMemo(
+ () => resolvePlatformImagePreviewContainedSize(viewportSize, naturalSize),
+ [naturalSize, viewportSize],
+ );
+
+ const clampTransform = useCallback(
+ (nextTransform: PlatformImagePreviewTransform) =>
+ clampPlatformImagePreviewTransform(
+ nextTransform,
+ viewportSize,
+ naturalSize,
+ ),
+ [naturalSize, viewportSize],
+ );
+
+ const updateViewportSize = useCallback(() => {
+ const rect = stageRef.current?.getBoundingClientRect();
+ setViewportSize({
+ width: rect?.width || window.innerWidth || 1,
+ height: rect?.height || window.innerHeight || 1,
+ });
+ }, []);
+
+ const updateTransform = useCallback(
+ (
+ updater: (
+ current: PlatformImagePreviewTransform,
+ ) => PlatformImagePreviewTransform,
+ ) => {
+ setTransform((current) => {
+ const nextTransform = clampTransform(updater(current));
+ return samePlatformImagePreviewTransform(current, nextTransform)
+ ? current
+ : nextTransform;
+ });
+ },
+ [clampTransform],
+ );
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ setTransform(DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM);
+ setNaturalSize(null);
+ dragStateRef.current = null;
+ setIsDragging(false);
+ }, [imageSrc, open, refreshKey]);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ updateViewportSize();
+ const frameId = window.requestAnimationFrame(updateViewportSize);
+ window.addEventListener('resize', updateViewportSize);
+ return () => {
+ window.cancelAnimationFrame(frameId);
+ window.removeEventListener('resize', updateViewportSize);
+ };
+ }, [open, updateViewportSize]);
+
+ useEffect(() => {
+ setTransform((current) => {
+ const nextTransform = clampTransform(current);
+ return samePlatformImagePreviewTransform(current, nextTransform)
+ ? current
+ : nextTransform;
+ });
+ }, [clampTransform]);
+
+ const zoomBy = useCallback(
+ (scaleDelta: number) => {
+ updateTransform((current) => ({
+ ...current,
+ scale: current.scale + scaleDelta,
+ }));
+ },
+ [updateTransform],
+ );
+
+ const resetTransform = useCallback(() => {
+ setTransform(DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM);
+ }, []);
+
+ const handleWheel = useCallback(
+ (event: ReactWheelEvent) => {
+ event.preventDefault();
+ zoomBy(
+ event.deltaY < 0
+ ? PLATFORM_IMAGE_PREVIEW_SCALE_STEP
+ : -PLATFORM_IMAGE_PREVIEW_SCALE_STEP,
+ );
+ },
+ [zoomBy],
+ );
+
+ const handlePointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ if (transform.scale <= MIN_PLATFORM_IMAGE_PREVIEW_SCALE) {
+ return;
+ }
+
+ event.preventDefault();
+ event.currentTarget.setPointerCapture?.(event.pointerId);
+ dragStateRef.current = {
+ pointerId: event.pointerId,
+ startClientX: event.clientX,
+ startClientY: event.clientY,
+ startOffsetX: transform.offsetX,
+ startOffsetY: transform.offsetY,
+ scale: transform.scale,
+ };
+ setIsDragging(true);
+ },
+ [transform],
+ );
+
+ const handlePointerMove = useCallback(
+ (event: ReactPointerEvent) => {
+ const dragState = dragStateRef.current;
+ if (!dragState || dragState.pointerId !== event.pointerId) {
+ return;
+ }
+
+ event.preventDefault();
+ const deltaX = event.clientX - dragState.startClientX;
+ const deltaY = event.clientY - dragState.startClientY;
+ updateTransform(() => ({
+ scale: dragState.scale,
+ offsetX: dragState.startOffsetX + deltaX,
+ offsetY: dragState.startOffsetY + deltaY,
+ }));
+ },
+ [updateTransform],
+ );
+
+ const finishPointerDrag = useCallback(
+ (event: ReactPointerEvent) => {
+ if (dragStateRef.current?.pointerId !== event.pointerId) {
+ return;
+ }
+
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
+ dragStateRef.current = null;
+ setIsDragging(false);
+ },
+ [],
+ );
+
+ const imageStyle = {
+ width: `${containedImageSize.width}px`,
+ height: `${containedImageSize.height}px`,
+ transform: `translate3d(${transform.offsetX}px, ${transform.offsetY}px, 0) scale(${transform.scale})`,
+ };
+ const canZoomOut = transform.scale > MIN_PLATFORM_IMAGE_PREVIEW_SCALE;
+ const canZoomIn = transform.scale < MAX_PLATFORM_IMAGE_PREVIEW_SCALE;
+
+ return (
+
+
+
+
}
+ onClick={() => zoomBy(-PLATFORM_IMAGE_PREVIEW_SCALE_STEP)}
+ className="h-9 w-9"
+ />
+
}
+ onClick={resetTransform}
+ className="h-9 w-9"
+ />
+
}
+ onClick={() => zoomBy(PLATFORM_IMAGE_PREVIEW_SCALE_STEP)}
+ className="h-9 w-9"
+ />
+
+
+
+
+ MIN_PLATFORM_IMAGE_PREVIEW_SCALE
+ ? isDragging
+ ? 'cursor-grabbing'
+ : 'cursor-grab'
+ : 'cursor-default',
+ ]
+ .filter(Boolean)
+ .join(' ')}
+ style={{ touchAction: 'none' }}
+ onWheel={handleWheel}
+ onPointerDown={handlePointerDown}
+ onPointerMove={handlePointerMove}
+ onPointerUp={finishPointerDrag}
+ onPointerCancel={finishPointerDrag}
+ >
+ {imageSrc ? (
+ {
+ const image = event.currentTarget;
+ setNaturalSize({
+ width: image.naturalWidth || image.clientWidth || 1,
+ height: image.naturalHeight || image.clientHeight || 1,
+ });
+ }}
+ className={`max-w-none select-none object-contain transition-transform ease-out ${
+ isDragging ? 'duration-0' : 'duration-150'
+ }`}
+ style={imageStyle}
+ />
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/common/platformImagePreviewModel.ts b/src/components/common/platformImagePreviewModel.ts
new file mode 100644
index 00000000..1da70423
--- /dev/null
+++ b/src/components/common/platformImagePreviewModel.ts
@@ -0,0 +1,96 @@
+export type PlatformImagePreviewSize = {
+ width: number;
+ height: number;
+};
+
+export type PlatformImagePreviewTransform = {
+ scale: number;
+ offsetX: number;
+ offsetY: number;
+};
+
+export const MIN_PLATFORM_IMAGE_PREVIEW_SCALE = 1;
+export const MAX_PLATFORM_IMAGE_PREVIEW_SCALE = 4;
+export const PLATFORM_IMAGE_PREVIEW_SCALE_STEP = 0.5;
+export const DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM: PlatformImagePreviewTransform =
+ {
+ scale: MIN_PLATFORM_IMAGE_PREVIEW_SCALE,
+ offsetX: 0,
+ offsetY: 0,
+ };
+
+function clampNumber(value: number, min: number, max: number) {
+ if (!Number.isFinite(value)) {
+ return min;
+ }
+
+ return Math.max(min, Math.min(max, value));
+}
+
+function normalizePreviewSize(size: PlatformImagePreviewSize | null) {
+ if (!size || size.width <= 0 || size.height <= 0) {
+ return { width: 1, height: 1 };
+ }
+
+ return size;
+}
+
+export function resolvePlatformImagePreviewContainedSize(
+ viewportSize: PlatformImagePreviewSize,
+ naturalSize: PlatformImagePreviewSize | null,
+) {
+ const viewport = normalizePreviewSize(viewportSize);
+ const natural = normalizePreviewSize(naturalSize);
+ const imageRatio = natural.width / natural.height;
+ const viewportRatio = viewport.width / viewport.height;
+
+ if (imageRatio > viewportRatio) {
+ return {
+ width: viewport.width,
+ height: viewport.width / imageRatio,
+ };
+ }
+
+ return {
+ width: viewport.height * imageRatio,
+ height: viewport.height,
+ };
+}
+
+export function samePlatformImagePreviewTransform(
+ left: PlatformImagePreviewTransform,
+ right: PlatformImagePreviewTransform,
+) {
+ return (
+ left.scale === right.scale &&
+ left.offsetX === right.offsetX &&
+ left.offsetY === right.offsetY
+ );
+}
+
+export function clampPlatformImagePreviewTransform(
+ transform: PlatformImagePreviewTransform,
+ viewportSize: PlatformImagePreviewSize,
+ naturalSize: PlatformImagePreviewSize | null,
+): PlatformImagePreviewTransform {
+ const viewport = normalizePreviewSize(viewportSize);
+ const containedSize = resolvePlatformImagePreviewContainedSize(
+ viewport,
+ naturalSize,
+ );
+ const scale = clampNumber(
+ transform.scale,
+ MIN_PLATFORM_IMAGE_PREVIEW_SCALE,
+ MAX_PLATFORM_IMAGE_PREVIEW_SCALE,
+ );
+ const scaledWidth = containedSize.width * scale;
+ const scaledHeight = containedSize.height * scale;
+ const panLimitX = Math.max(0, (scaledWidth - viewport.width) / 2);
+ const panLimitY = Math.max(0, (scaledHeight - viewport.height) / 2);
+
+ return {
+ scale,
+ offsetX: clampNumber(transform.offsetX, -panLimitX, panLimitX),
+ offsetY: clampNumber(transform.offsetY, -panLimitY, panLimitY),
+ };
+}
diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts b/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts
index c364441d..76fe4b7b 100644
--- a/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts
+++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts
@@ -5,7 +5,10 @@ import {
resolveMiniGameGenerationProgressTickState,
resolveMiniGameGenerationViewBusy,
} from './PlatformEntryFlowShellImpl';
-import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel';
+import {
+ buildExternalGenerationQueuePresentation,
+ buildExternalGenerationQueueStatus,
+} from './platformExternalGenerationQueueStatusModel';
import { resolveFinishedMiniGameDraftGenerationState } from './platformMiniGameDraftGenerationStateModel';
describe('resolveMiniGameGenerationProgressTickState', () => {
@@ -88,4 +91,36 @@ describe('buildExternalGenerationQueueStatus', () => {
test('没有队列或任务信息时不显示状态条', () => {
expect(buildExternalGenerationQueueStatus(null, null)).toBeNull();
});
+
+ test('构造我的页生成队列展示状态', () => {
+ expect(
+ buildExternalGenerationQueuePresentation({
+ currentStatus: 'running',
+ currentProgress: 42.4,
+ pendingCount: 2,
+ runningCount: 1,
+ }),
+ ).toEqual({
+ statusLabel: '生成中',
+ progressLabel: '42%',
+ pendingLabel: '2',
+ runningLabel: '1',
+ pendingCount: 2,
+ runningCount: 1,
+ progress: 42,
+ shouldShow: true,
+ });
+
+ expect(buildExternalGenerationQueuePresentation(null).shouldShow).toBe(
+ false,
+ );
+ expect(
+ buildExternalGenerationQueuePresentation({
+ currentStatus: 'completed',
+ currentProgress: 100,
+ pendingCount: 0,
+ runningCount: 0,
+ }).shouldShow,
+ ).toBe(false);
+ });
});
diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
index 875665e2..f35b2358 100644
--- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
+++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
@@ -4782,13 +4782,18 @@ export function PlatformEntryFlowShellImpl({
woodenFishGenerationState,
);
const shouldShowExternalGenerationQueueStatus =
- isExternalGenerationQueueStage(selectionStage);
+ isExternalGenerationQueueStage(selectionStage) ||
+ (selectionStage === 'platform' &&
+ platformBootstrap.platformTab === 'profile' &&
+ platformBootstrap.canReadProtectedData);
useEffect(() => {
if (!shouldShowExternalGenerationQueueStatus) {
setExternalGenerationQueueOverview(null);
return;
}
+ setExternalGenerationQueueOverview(null);
+
let disposed = false;
let controller: AbortController | null = null;
@@ -4816,47 +4821,44 @@ export function PlatformEntryFlowShellImpl({
controller?.abort();
window.clearInterval(intervalId);
};
- }, [shouldShowExternalGenerationQueueStatus]);
- const puzzleExternalGenerationQueueStatus = useMemo(
- () =>
- buildExternalGenerationQueueStatus(
- externalGenerationQueueOverview,
- puzzleOperation?.queueState ?? null,
- ),
- [externalGenerationQueueOverview, puzzleOperation],
- );
- const jumpHopExternalGenerationQueueStatus = useMemo(
- () =>
- buildExternalGenerationQueueStatus(
- externalGenerationQueueOverview,
- jumpHopQueueState,
- ),
- [externalGenerationQueueOverview, jumpHopQueueState],
- );
- const puzzleClearExternalGenerationQueueStatus = useMemo(
- () =>
- buildExternalGenerationQueueStatus(
- externalGenerationQueueOverview,
- puzzleClearQueueState,
- ),
- [externalGenerationQueueOverview, puzzleClearQueueState],
- );
- const woodenFishExternalGenerationQueueStatus = useMemo(
- () =>
- buildExternalGenerationQueueStatus(
- externalGenerationQueueOverview,
- woodenFishQueueState,
- ),
- [externalGenerationQueueOverview, woodenFishQueueState],
- );
+ }, [authUi?.user?.id, shouldShowExternalGenerationQueueStatus]);
+ const activeExternalGenerationJobState = useMemo(() => {
+ const candidates = [
+ puzzleOperation?.queueState ?? null,
+ jumpHopQueueState,
+ puzzleClearQueueState,
+ woodenFishQueueState,
+ ].filter((candidate): candidate is ExternalGenerationJobStatusRecord =>
+ Boolean(candidate),
+ );
+
+ return (
+ candidates.find(
+ (candidate) =>
+ candidate.status === 'queued' || candidate.status === 'running',
+ ) ??
+ candidates[0] ??
+ null
+ );
+ }, [
+ jumpHopQueueState,
+ puzzleClearQueueState,
+ puzzleOperation?.queueState,
+ woodenFishQueueState,
+ ]);
const externalGenerationQueueStatus = useMemo(
() =>
buildExternalGenerationQueueStatus(
externalGenerationQueueOverview,
- null,
+ activeExternalGenerationJobState,
),
- [externalGenerationQueueOverview],
+ [activeExternalGenerationJobState, externalGenerationQueueOverview],
);
+ const profileExternalGenerationQueueStatus =
+ platformBootstrap.canReadProtectedData &&
+ platformBootstrap.platformTab === 'profile'
+ ? externalGenerationQueueStatus
+ : null;
const platformBootstrapErrorForDisplay = isCreationEntryDisabledErrorMessage(
platformBootstrap.platformError,
)
@@ -15141,6 +15143,7 @@ export function PlatformEntryFlowShellImpl({
isLoadingDashboard={platformBootstrap.isLoadingDashboard}
hasUnreadDraftUpdate={hasUnreadDraftUpdates}
profileTaskRefreshKey={profileTaskRefreshKey}
+ profileGenerationQueueStatus={profileExternalGenerationQueueStatus}
isDesktopLayout={isDesktopLayout}
isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey}
platformError={
@@ -15576,7 +15579,6 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="草稿生成中"
pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区"
- queueStatus={jumpHopExternalGenerationQueueStatus}
/>
@@ -15721,7 +15723,6 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('match3d-agent-workspace');
}}
onRetry={retryMatch3DDraftGeneration}
- queueStatus={puzzleClearExternalGenerationQueueStatus}
hideBatchModule
/>
@@ -15992,7 +15993,6 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="草稿生成中"
pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区"
- queueStatus={woodenFishExternalGenerationQueueStatus}
/>
@@ -16193,7 +16193,6 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="图片生成中"
pausedBadgeLabel="图片生成已暂停"
idleBadgeLabel="等待返回结果页"
- queueStatus={externalGenerationQueueStatus}
/>
@@ -16398,7 +16397,6 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('jump-hop-workspace');
}}
onRetry={retryJumpHopDraftGeneration}
- queueStatus={externalGenerationQueueStatus}
/>
@@ -16547,7 +16545,6 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="素材生成中"
pausedBadgeLabel="素材生成已暂停"
idleBadgeLabel="等待返回工作区"
- queueStatus={externalGenerationQueueStatus}
/>
@@ -16677,7 +16674,6 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('wooden-fish-workspace');
}}
onRetry={retryWoodenFishDraftGeneration}
- queueStatus={externalGenerationQueueStatus}
/>
@@ -16871,7 +16867,6 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('puzzle-agent-workspace');
}}
onRetry={retryPuzzleDraftGeneration}
- queueStatus={puzzleExternalGenerationQueueStatus}
hideBatchModule
/>
@@ -16998,7 +16993,6 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="草稿生成中"
pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区"
- queueStatus={externalGenerationQueueStatus}
/>
@@ -17242,7 +17236,6 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="草稿编译中"
pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区"
- queueStatus={externalGenerationQueueStatus}
/>
diff --git a/src/components/platform-entry/PlatformProfileGenerationQueueCard.tsx b/src/components/platform-entry/PlatformProfileGenerationQueueCard.tsx
new file mode 100644
index 00000000..fc6e7cae
--- /dev/null
+++ b/src/components/platform-entry/PlatformProfileGenerationQueueCard.tsx
@@ -0,0 +1,84 @@
+import { Loader2 } from 'lucide-react';
+
+import { PlatformPillBadge } from '../common/PlatformPillBadge';
+import { PlatformProgressBar } from '../common/PlatformProgressBar';
+import {
+ buildExternalGenerationQueuePresentation,
+ type ExternalGenerationQueueStatus,
+} from './platformExternalGenerationQueueStatusModel';
+
+type PlatformProfileGenerationQueueCardProps = {
+ queueStatus: ExternalGenerationQueueStatus | null;
+};
+
+/**
+ * “我的”页里的后台生成状态卡。
+ * 只展示当前账号可见的队列概览,业务完成仍以各玩法草稿 / 作品回读为准。
+ */
+export function PlatformProfileGenerationQueueCard({
+ queueStatus,
+}: PlatformProfileGenerationQueueCardProps) {
+ const presentation = buildExternalGenerationQueuePresentation(queueStatus);
+
+ if (!presentation.shouldShow) {
+ return null;
+ }
+
+ const progressValue = presentation.progress ?? 0;
+
+ return (
+
+
+
+
+
+
+
+
+ 生成队列
+
+
+
+ {presentation.statusLabel ?? '等待生成任务'}
+
+
+ {presentation.statusLabel ? (
+
+ {presentation.progressLabel
+ ? `${presentation.statusLabel} ${presentation.progressLabel}`
+ : presentation.statusLabel}
+
+ ) : null}
+
+
+
+
+
+
+ 排队
+ {presentation.pendingLabel}
+
+
+ 生成
+ {presentation.runningLabel}
+
+
+
+ );
+}
diff --git a/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts b/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts
index 7238377b..180dbc61 100644
--- a/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts
+++ b/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts
@@ -2,7 +2,24 @@ import type {
ExternalGenerationJobStatusRecord,
ExternalGenerationQueueOverview,
} from '../../../packages/shared/src/contracts/externalGeneration';
-import type { ExternalGenerationQueueStatus } from '../CustomWorldGenerationView';
+
+export type ExternalGenerationQueueStatus = {
+ currentStatus?: 'queued' | 'running' | 'completed' | 'failed' | null;
+ currentProgress?: number | null;
+ pendingCount?: number | null;
+ runningCount?: number | null;
+};
+
+export type ExternalGenerationQueuePresentation = {
+ statusLabel: string | null;
+ progressLabel: string | null;
+ pendingLabel: string;
+ runningLabel: string;
+ pendingCount: number;
+ runningCount: number;
+ progress: number | null;
+ shouldShow: boolean;
+};
export function buildExternalGenerationQueueStatus(
overview: ExternalGenerationQueueOverview | null,
@@ -19,3 +36,97 @@ export function buildExternalGenerationQueueStatus(
runningCount: overview?.runningCount ?? null,
};
}
+
+export function resolveExternalGenerationQueueStatusLabel(
+ status: ExternalGenerationQueueStatus['currentStatus'],
+) {
+ if (status === 'queued') {
+ return '排队中';
+ }
+
+ if (status === 'running') {
+ return '生成中';
+ }
+
+ if (status === 'failed') {
+ return '生成失败';
+ }
+
+ if (status === 'completed') {
+ return '已完成';
+ }
+
+ return null;
+}
+
+function resolveExternalGenerationQueueOverviewLabel(
+ pendingCount: number,
+ runningCount: number,
+) {
+ if (runningCount > 0) {
+ return '生成中';
+ }
+
+ if (pendingCount > 0) {
+ return '排队中';
+ }
+
+ return null;
+}
+
+export function normalizeExternalGenerationQueueCount(
+ value: number | null | undefined,
+) {
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return 0;
+ }
+
+ return Math.max(0, Math.round(value));
+}
+
+export function normalizeExternalGenerationQueueProgress(
+ value: number | null | undefined,
+) {
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return null;
+ }
+
+ return Math.max(0, Math.min(100, Math.round(value)));
+}
+
+export function buildExternalGenerationQueuePresentation(
+ status: ExternalGenerationQueueStatus | null | undefined,
+): ExternalGenerationQueuePresentation {
+ const pendingCount = normalizeExternalGenerationQueueCount(
+ status?.pendingCount,
+ );
+ const runningCount = normalizeExternalGenerationQueueCount(
+ status?.runningCount,
+ );
+ const progress = normalizeExternalGenerationQueueProgress(
+ status?.currentProgress,
+ );
+ const isCompletedCurrentJob = status?.currentStatus === 'completed';
+ const currentStatusLabel = resolveExternalGenerationQueueStatusLabel(
+ status?.currentStatus ?? null,
+ );
+ const overviewStatusLabel = resolveExternalGenerationQueueOverviewLabel(
+ pendingCount,
+ runningCount,
+ );
+ const statusLabel = isCompletedCurrentJob
+ ? overviewStatusLabel
+ : (currentStatusLabel ?? overviewStatusLabel);
+ const progressValue = isCompletedCurrentJob ? null : progress;
+
+ return {
+ statusLabel,
+ progressLabel: progressValue == null ? null : `${progressValue}%`,
+ pendingLabel: pendingCount.toString(),
+ runningLabel: runningCount.toString(),
+ pendingCount,
+ runningCount,
+ progress: progressValue,
+ shouldShow: Boolean(statusLabel || pendingCount > 0 || runningCount > 0),
+ };
+}
diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
index e34e3510..587d7733 100644
--- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
+++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
@@ -779,6 +779,7 @@ function ProfileHomeViewHarness({
userOverrides = {},
activeTab = 'profile',
profileTaskRefreshKey = 0,
+ profileGenerationQueueStatus = null,
profilePlayStats = null,
isProfilePlayStatsOpen = false,
}: {
@@ -789,6 +790,7 @@ function ProfileHomeViewHarness({
userOverrides?: Partial;
activeTab?: RpgEntryHomeViewProps['activeTab'];
profileTaskRefreshKey?: number;
+ profileGenerationQueueStatus?: RpgEntryHomeViewProps['profileGenerationQueueStatus'];
profilePlayStats?: ProfilePlayStatsResponse | null;
isProfilePlayStatsOpen?: boolean;
}) {
@@ -856,6 +858,7 @@ function ProfileHomeViewHarness({
onSearchPublicCode={vi.fn()}
onRechargeSuccess={onRechargeSuccess}
profileTaskRefreshKey={profileTaskRefreshKey}
+ profileGenerationQueueStatus={profileGenerationQueueStatus}
/>
);
@@ -871,6 +874,7 @@ function renderProfileView(
profileStatsOptions: {
profilePlayStats?: ProfilePlayStatsResponse | null;
isProfilePlayStatsOpen?: boolean;
+ profileGenerationQueueStatus?: RpgEntryHomeViewProps['profileGenerationQueueStatus'];
} = {},
) {
return render(
@@ -879,6 +883,9 @@ function renderProfileView(
profileDashboardOverrides={profileDashboardOverrides}
userOverrides={userOverrides}
profileTaskRefreshKey={profileTaskRefreshKey}
+ profileGenerationQueueStatus={
+ profileStatsOptions.profileGenerationQueueStatus
+ }
profilePlayStats={profileStatsOptions.profilePlayStats}
isProfilePlayStatsOpen={profileStatsOptions.isProfilePlayStatsOpen}
/>,
@@ -2596,7 +2603,11 @@ test('profile daily task shortcut reflects task progress and claim updates', asy
await user.click(screen.getByRole('button', { name: /每日任务/u }));
const taskTitle = await screen.findByText('每日登录');
- const taskPanel = taskTitle.closest('.platform-subpanel') as HTMLElement;
+ const taskPanel = screen
+ .getByRole('button', { name: '领取' })
+ .closest('.rounded-\\[1rem\\]') as HTMLElement;
+ expect(taskTitle).toBeTruthy();
+ expect(taskPanel).toBeTruthy();
expect(taskPanel.className).toContain('rounded-[1rem]');
expect(taskPanel.className).toContain('p-4');
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
@@ -2892,6 +2903,46 @@ test('profile stats cards are centered without update timestamp', async () => {
await screen.findByText('1 / 1');
});
+test('profile page shows external generation queue status', async () => {
+ renderProfileView(
+ vi.fn(),
+ {},
+ {},
+ 0,
+ {
+ profileGenerationQueueStatus: {
+ currentStatus: 'queued',
+ currentProgress: 18,
+ pendingCount: 6,
+ runningCount: 2,
+ },
+ },
+ );
+
+ await screen.findByText('1 / 1');
+ const queueRegion = screen.getByRole('region', { name: '生成队列' });
+ expect(queueRegion.className).toContain(
+ 'platform-profile-generation-queue-card',
+ );
+ expect(within(queueRegion).getByText('排队中')).toBeTruthy();
+ expect(within(queueRegion).getByText('排队中 18%')).toBeTruthy();
+ expect(within(queueRegion).getByText('排队')).toBeTruthy();
+ expect(within(queueRegion).getByText('6')).toBeTruthy();
+ expect(within(queueRegion).getByText('生成')).toBeTruthy();
+ expect(within(queueRegion).getByText('2')).toBeTruthy();
+ const progressbar = within(queueRegion).getByRole('progressbar', {
+ name: '生成队列进度',
+ });
+ expect(progressbar.getAttribute('aria-valuenow')).toBe('18');
+});
+
+test('profile page hides external generation queue card without queue state', async () => {
+ renderProfileView();
+
+ await screen.findByText('1 / 1');
+ expect(screen.queryByRole('region', { name: '生成队列' })).toBeNull();
+});
+
test('mobile profile page matches the reference layout sections', async () => {
mockNarrowMobileLayout();
diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx
index 8d5f319c..ef754444 100644
--- a/src/components/rpg-entry/RpgEntryHomeView.tsx
+++ b/src/components/rpg-entry/RpgEntryHomeView.tsx
@@ -125,6 +125,7 @@ import {
ProfileStatCardSkeleton,
} from '../platform-entry/PlatformProfilePrimitives';
import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell';
+import { PlatformProfileGenerationQueueCard } from '../platform-entry/PlatformProfileGenerationQueueCard';
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
@@ -132,6 +133,7 @@ import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileR
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import { PlatformProfileTaskCenterModal } from '../platform-entry/PlatformProfileTaskCenterModal';
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
+import type { ExternalGenerationQueueStatus } from '../platform-entry/platformExternalGenerationQueueStatusModel';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import {
type RechargePaymentResult,
@@ -274,6 +276,7 @@ export interface RpgEntryHomeViewProps {
onOpenFeedback?: () => void;
onRechargeSuccess?: () => void | Promise;
profileTaskRefreshKey?: number;
+ profileGenerationQueueStatus?: ExternalGenerationQueueStatus | null;
createTabContent?: ReactNode;
draftTabContent?: ReactNode;
hasUnreadDraftUpdate?: boolean;
@@ -2568,6 +2571,7 @@ export function RpgEntryHomeView({
onOpenFeedback,
onRechargeSuccess,
profileTaskRefreshKey = 0,
+ profileGenerationQueueStatus = null,
createTabContent,
draftTabContent,
hasUnreadDraftUpdate = false,
@@ -4345,6 +4349,10 @@ export function RpgEntryHomeView({
/>
+
+
{
expect(screen.queryByText('云端糖果塔')).toBeNull();
});
- test('显示外部生成队列状态', () => {
+ test('生成页不再显示外部生成队列状态', () => {
render(
{}}
onEditSetting={() => {}}
onRetry={() => {}}
/>,
);
- expect(screen.getByText('排队中 18%')).toBeTruthy();
- expect(screen.getByText('排队 6')).toBeTruthy();
- expect(screen.getByText('生成 2')).toBeTruthy();
+ expect(screen.queryByRole('region', { name: '生成队列' })).toBeNull();
+ expect(screen.queryByText('排队中 18%')).toBeNull();
+ expect(screen.queryByText('排队 6')).toBeNull();
});
});
diff --git a/src/components/unified-creation/UnifiedGenerationPage.tsx b/src/components/unified-creation/UnifiedGenerationPage.tsx
index 04ddf4f5..b943f549 100644
--- a/src/components/unified-creation/UnifiedGenerationPage.tsx
+++ b/src/components/unified-creation/UnifiedGenerationPage.tsx
@@ -1,9 +1,6 @@
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
-import {
- CustomWorldGenerationView,
- type ExternalGenerationQueueStatus,
-} from '../CustomWorldGenerationView';
+import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
import type { UnifiedGenerationPlayId } from './unifiedGenerationCopy';
import { getUnifiedGenerationCopy } from './unifiedGenerationCopy';
@@ -18,7 +15,6 @@ type UnifiedGenerationPageProps = {
onEditSetting: () => void;
onRetry: () => void;
hideBatchModule?: boolean;
- queueStatus?: ExternalGenerationQueueStatus | null;
};
export function UnifiedGenerationPage({
@@ -32,7 +28,6 @@ export function UnifiedGenerationPage({
onEditSetting,
onRetry,
hideBatchModule = false,
- queueStatus = null,
}: UnifiedGenerationPageProps) {
const copy = getUnifiedGenerationCopy(playId);
@@ -56,7 +51,6 @@ export function UnifiedGenerationPage({
pausedBadgeLabel="素材生成已暂停"
idleBadgeLabel="等待返回工作区"
hideBatchModule={hideBatchModule}
- queueStatus={queueStatus}
/>
);
}
diff --git a/src/index.css b/src/index.css
index 32819c6e..51eed555 100644
--- a/src/index.css
+++ b/src/index.css
@@ -6411,6 +6411,49 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
margin-bottom: -0.2rem;
}
+.platform-profile-generation-queue-card {
+ width: 100%;
+ min-height: 6.2rem;
+ padding: 0.9rem 0.95rem;
+ border: 1px solid rgba(235, 221, 208, 0.82);
+ border-radius: 1.55rem;
+ background: rgba(255, 250, 246, 0.92);
+ box-shadow: 0 10px 28px rgba(112, 57, 30, 0.06);
+}
+
+.platform-profile-generation-queue-card__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.9rem;
+ height: 1.9rem;
+ flex: none;
+ border-radius: 9999px;
+ background: rgba(255, 237, 222, 0.95);
+ color: #bf673b;
+}
+
+.platform-profile-generation-queue-card__metric {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ min-width: 0;
+ padding: 0.55rem 0.65rem;
+ border-radius: 0.9rem;
+ background: rgba(255, 244, 235, 0.82);
+ color: var(--platform-text-base);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.platform-profile-generation-queue-card__metric strong {
+ color: var(--platform-text-strong);
+ font-size: 15px;
+ font-weight: 900;
+ line-height: 1;
+}
+
.platform-profile-shortcut-panel {
padding: 0.78rem 0.68rem 0.82rem;
}
@@ -6684,6 +6727,27 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
font-size: 12px;
}
+ .platform-profile-generation-queue-card {
+ min-height: 5.75rem;
+ padding: 0.72rem 0.76rem;
+ border-radius: 1.12rem;
+ }
+
+ .platform-profile-generation-queue-card__icon {
+ width: 1.72rem;
+ height: 1.72rem;
+ }
+
+ .platform-profile-generation-queue-card__metric {
+ padding: 0.48rem 0.56rem;
+ border-radius: 0.78rem;
+ font-size: 11px;
+ }
+
+ .platform-profile-generation-queue-card__metric strong {
+ font-size: 14px;
+ }
+
.platform-profile-shortcut-panel {
padding: 0.64rem 0.54rem 0.68rem;
}