From a51e63415f6d7c1c8cc3ab2f5c145e3454cfe026 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 13 Jun 2026 22:25:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E7=94=9F=E6=88=90=E9=98=9F?= =?UTF-8?q?=E5=88=97=E4=B8=8E=E5=9B=BE=E7=89=87=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将外部生成队列概览移到我的页签展示 移除生成页和进度页中的队列概览区域 新增全屏黑底图片预览器并支持缩放和边界拖拽 补充队列概览和图片预览的聚焦测试 同步更新玩法链路、运维、UI Kit 和团队共享记忆文档 --- .hermes/shared-memory/decision-log.md | 16 + .hermes/shared-memory/pitfalls.md | 8 + docs/README.md | 2 +- ...】PlatformUiKit弹窗组件收口计划-2026-06-08.md | 2 +- ...端架构】外部生成Worker化方案-2026-06-03.md | 4 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- src/components/CustomWorldGenerationView.tsx | 72 ---- .../common/CreativeImageInputPanel.test.tsx | 19 +- .../common/CreativeImageInputPanel.tsx | 54 +-- .../common/PlatformImagePreviewModal.test.tsx | 181 ++++++++++ .../common/PlatformImagePreviewModal.tsx | 332 ++++++++++++++++++ .../common/platformImagePreviewModel.ts | 96 +++++ .../PlatformEntryFlowShellImpl.test.ts | 37 +- .../PlatformEntryFlowShellImpl.tsx | 85 ++--- .../PlatformProfileGenerationQueueCard.tsx | 84 +++++ ...tformExternalGenerationQueueStatusModel.ts | 113 +++++- .../RpgEntryHomeView.recharge.test.tsx | 53 ++- src/components/rpg-entry/RpgEntryHomeView.tsx | 8 + .../UnifiedGenerationPage.test.tsx | 14 +- .../UnifiedGenerationPage.tsx | 8 +- src/index.css | 64 ++++ 22 files changed, 1063 insertions(+), 193 deletions(-) create mode 100644 src/components/common/PlatformImagePreviewModal.test.tsx create mode 100644 src/components/common/PlatformImagePreviewModal.tsx create mode 100644 src/components/common/platformImagePreviewModel.ts create mode 100644 src/components/platform-entry/PlatformProfileGenerationQueueCard.tsx 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 ? ( + {alt} + ) : 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; }