继续收口账号与运行态弹窗壳层
账号设置弹窗复用认证壳层并保留 direct mode 唯一 dialog 语义 拼图运行态新增本地弹窗壳层收口确认设置退出失败和通关结算 抓大鹅与跳一跳结算弹窗提取本地结算结构并补测试 拼图 onboarding 登录保存覆盖层迁入 UnifiedModal 更新 PlatformUiKit 收口文档和 Hermes 决策记录
This commit is contained in:
@@ -2102,6 +2102,8 @@
|
|||||||
- 决策:`PlatformToolModalShell` 继续承接方洞结果页图片槽弹窗;`SquareHoleResultView.tsx` 的封面 / 背景 / 形状 / 洞口图片查看与历史选择弹窗只保留当前图、上传、AI 生成和历史素材选择语义,不再直接维护 `createPortal`、主题 overlay、白底 remap panel、header close 和滚动 body。该弹窗使用 `ariaLabel` 保持“封面图查看 / 背景图查看”等固定可访问名称,历史生成区继续由 `PlatformAssetPickerGrid` 承接读取、错误和空态。
|
- 决策:`PlatformToolModalShell` 继续承接方洞结果页图片槽弹窗;`SquareHoleResultView.tsx` 的封面 / 背景 / 形状 / 洞口图片查看与历史选择弹窗只保留当前图、上传、AI 生成和历史素材选择语义,不再直接维护 `createPortal`、主题 overlay、白底 remap panel、header close 和滚动 body。该弹窗使用 `ariaLabel` 保持“封面图查看 / 背景图查看”等固定可访问名称,历史生成区继续由 `PlatformAssetPickerGrid` 承接读取、错误和空态。
|
||||||
- 决策:`PlatformToolModalShell` 继续承接视觉小说结果页素材选择弹窗;`VisualNovelAssetPickerDialog` 只保留本地上传、AI 图片生成、历史素材读取、错误提示和素材选择回调,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close 和滚动 body。视觉小说音频生成弹窗需要保留生成中禁止关闭,实体编辑器弹窗需要保留编辑 footer,后续逐个迁移并补对应交互测试。
|
- 决策:`PlatformToolModalShell` 继续承接视觉小说结果页素材选择弹窗;`VisualNovelAssetPickerDialog` 只保留本地上传、AI 图片生成、历史素材读取、错误提示和素材选择回调,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close 和滚动 body。视觉小说音频生成弹窗需要保留生成中禁止关闭,实体编辑器弹窗需要保留编辑 footer,后续逐个迁移并补对应交互测试。
|
||||||
- 决策:认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该壳层只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx` 与 `RegistrationInviteModal.tsx` 只保留各自表单状态和提交流程。
|
- 决策:认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该壳层只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx` 与 `RegistrationInviteModal.tsx` 只保留各自表单状态和提交流程。
|
||||||
|
- 决策:账号弹窗可以继续复用 `PlatformAuthModalShell` 的平台主题 overlay 与 auth card 壳层,但通过 `overlaySpacing`、`overlayStyle`、`showHeader` 和尺寸透传保留账号 direct mode 的唯一 dialog 语义与 safe-area 布局,不把账号安全详情、换绑手机号或修改密码子面板并进登录表单语义。
|
||||||
|
- 决策:运行态弹窗先按玩法目录沉淀薄壳,只有跨玩法接口真正稳定后才上升到 `common/`。拼图运行态用 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx` 承接道具确认、设置、退出改造、失败和通关结算的 overlay / dialog / footer / button 骨架;抓大鹅和跳一跳结算分别保留在各自 runtime shell 内抽本地 settlement shell / summary / actions。`PlatformToolModalShell` 继续只服务平台白底工具弹窗,不强塞到像素风或游戏运行态 overlay;拖拽 ghost、飞行动画、原图查看和全屏 runtime 容器不按旧 modal 债务处理。
|
||||||
- 决策:NPC dark modal footer 和暗色明细空态也继续纳入同一条收口线。`NpcModals.tsx` 里的交易 / 赠礼 / 招募弹窗 footer 按钮和物品详情“关闭”按钮都改为委托 `PlatformActionButton surface="editorDark"`,交易右侧“请选择一件物品”提示改为 `PlatformEmptyState surface="editorDark"`;`CharacterInfoShared.tsx` 的 `BuildContributionDetailPanel` 空明细也改为 `PlatformEmptyState surface="editorDark"`。数量 stepper、赠礼 / 招募 option card、标签强度按钮这类带独立业务语义的控件继续保留局部实现。
|
- 决策:NPC dark modal footer 和暗色明细空态也继续纳入同一条收口线。`NpcModals.tsx` 里的交易 / 赠礼 / 招募弹窗 footer 按钮和物品详情“关闭”按钮都改为委托 `PlatformActionButton surface="editorDark"`,交易右侧“请选择一件物品”提示改为 `PlatformEmptyState surface="editorDark"`;`CharacterInfoShared.tsx` 的 `BuildContributionDetailPanel` 空明细也改为 `PlatformEmptyState surface="editorDark"`。数量 stepper、赠礼 / 招募 option card、标签强度按钮这类带独立业务语义的控件继续保留局部实现。
|
||||||
- 决策:详情页头部动作组合统一收口到 `src/components/common/PlatformDetailTopbar.tsx` 与 `src/components/common/PlatformDetailShareActions.tsx`。`PlatformDetailTopbar` 只负责返回按钮、标题居中槽位和右侧动作槽位的布局,可在 `pill` / `icon` 返回入口之间切换;`PlatformDetailShareActions` 只负责“前置 badge 区块 + 作品号复制 + 分享复制”这组稳定动作,并允许按页面关闭复制或分享其中一项。`RpgEntryWorldDetailView.tsx` 已接入 overlay 版完整动作组,`PlatformWorkDetailView.tsx` 已接入 icon topbar 与 solid 版作品号复制动作,同时继续保留公开详情页自己的顶部 icon 分享入口和分享反馈提示。后续详情页若只是复用返回、标题、作品号复制或分享动作排列,优先组合这两个薄组件,不把作者、摘要、封面、轮播或业务 CTA 塞进共享配置对象。
|
- 决策:详情页头部动作组合统一收口到 `src/components/common/PlatformDetailTopbar.tsx` 与 `src/components/common/PlatformDetailShareActions.tsx`。`PlatformDetailTopbar` 只负责返回按钮、标题居中槽位和右侧动作槽位的布局,可在 `pill` / `icon` 返回入口之间切换;`PlatformDetailShareActions` 只负责“前置 badge 区块 + 作品号复制 + 分享复制”这组稳定动作,并允许按页面关闭复制或分享其中一项。`RpgEntryWorldDetailView.tsx` 已接入 overlay 版完整动作组,`PlatformWorkDetailView.tsx` 已接入 icon topbar 与 solid 版作品号复制动作,同时继续保留公开详情页自己的顶部 icon 分享入口和分享反馈提示。后续详情页若只是复用返回、标题、作品号复制或分享动作排列,优先组合这两个薄组件,不把作者、摘要、封面、轮播或业务 CTA 塞进共享配置对象。
|
||||||
- 验证方式:`npm run test -- src/components/common/PlatformAsyncStatePanel.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/common/PlatformSegmentedTabs.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/PixelCloseButton.test.tsx src/components/CharacterChatModal.test.tsx src/components/MapModal.test.tsx`、`npm run test -- src/components/common/useMudPointConfirmController.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-creation-result/RpgCreationResultActionBar.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/AdventureEntityModal.test.tsx src/components/InventoryPanel.test.tsx src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/auth/AccountModal.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
- 验证方式:`npm run test -- src/components/common/PlatformAsyncStatePanel.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/common/PlatformSegmentedTabs.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/PixelCloseButton.test.tsx src/components/CharacterChatModal.test.tsx src/components/MapModal.test.tsx`、`npm run test -- src/components/common/useMudPointConfirmController.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-creation-result/RpgCreationResultActionBar.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/AdventureEntityModal.test.tsx src/components/InventoryPanel.test.tsx src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/auth/AccountModal.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
|||||||
@@ -281,6 +281,7 @@
|
|||||||
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.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.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`。
|
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`。
|
||||||
|
19.3.54. 账号 / 运行态 / onboarding 这轮继续分场景收口:`AccountModal.tsx` 的设置入口外层 overlay 与 auth card 壳层复用 `PlatformAuthModalShell`,并通过 `overlaySpacing`、`overlayStyle`、`showHeader` 和尺寸透传保留账号弹窗的 safe-area 与 direct account 唯一 dialog 语义;拼图运行态新增 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx`,只在 `puzzle-runtime` 内承接道具确认、设置、退出改造提示、失败弹窗和通关结算的 overlay / dialog / footer / button 骨架,原图查看、拖拽 ghost、飞行动画和全屏 runtime 容器不纳入 modal 收口;抓大鹅与跳一跳结算弹窗分别在 `Match3DRuntimeShell.tsx` 和 `JumpHopRuntimeShell.tsx` 内提取本地结算壳层 / summary / actions,保留玩法视觉身份;拼图 onboarding 首屏继续保留沉浸式全屏体验,只把登录保存覆盖层迁入 `UnifiedModal`,保持无关闭按钮、禁用遮罩关闭和禁用 Escape。后续 runtime 专属弹窗优先先抽玩法目录内薄壳;只有出现跨玩法稳定同构接口时再上升到 `common/`,不要把 `PlatformToolModalShell` 强行套到像素 / 游戏运行态 overlay。验证命令:`npm run test -- src/components/auth/AccountModal.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||||
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
|
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
|
||||||
19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
|
19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
|
||||||
19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx`;`PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`、`npm run typecheck`。
|
19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx`;`PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`、`npm run typecheck`。
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
|||||||
import { PlatformTextField } from '../common/PlatformTextField';
|
import { PlatformTextField } from '../common/PlatformTextField';
|
||||||
import type { PlatformSettingsSection } from './AuthUiContext';
|
import type { PlatformSettingsSection } from './AuthUiContext';
|
||||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||||
|
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
|
||||||
|
|
||||||
type AccountModalProps = {
|
type AccountModalProps = {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
@@ -185,6 +186,7 @@ function OverlayPanel({
|
|||||||
description,
|
description,
|
||||||
action,
|
action,
|
||||||
standalone = false,
|
standalone = false,
|
||||||
|
dialog = true,
|
||||||
onBack,
|
onBack,
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
@@ -194,6 +196,7 @@ function OverlayPanel({
|
|||||||
description?: string;
|
description?: string;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
standalone?: boolean;
|
standalone?: boolean;
|
||||||
|
dialog?: boolean;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -201,9 +204,9 @@ function OverlayPanel({
|
|||||||
const panel = (
|
const panel = (
|
||||||
<div
|
<div
|
||||||
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
|
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
|
||||||
role="dialog"
|
role={dialog ? 'dialog' : undefined}
|
||||||
aria-modal="true"
|
aria-modal={dialog ? true : undefined}
|
||||||
aria-label={title}
|
aria-label={dialog ? title : undefined}
|
||||||
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -498,23 +501,37 @@ export function AccountModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<PlatformAuthModalShell
|
||||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[70] flex items-end justify-center overflow-hidden px-4 sm:items-center`}
|
title={isDirectAccountMode ? '账号信息' : '设置与账号安全'}
|
||||||
style={{
|
platformTheme={platformTheme}
|
||||||
|
onClose={onClose}
|
||||||
|
closeLabel="关闭账号弹窗"
|
||||||
|
size="xl"
|
||||||
|
showHeader={false}
|
||||||
|
overlaySpacing="none"
|
||||||
|
zIndexClassName="z-[70]"
|
||||||
|
overlayClassName="!items-end !justify-center overflow-hidden !px-4 !py-0 sm:!items-center"
|
||||||
|
overlayStyle={{
|
||||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)',
|
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)',
|
||||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)',
|
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)',
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
authCardClassName={isDirectAccountMode ? '' : undefined}
|
||||||
|
panelClassName={
|
||||||
|
isDirectAccountMode
|
||||||
|
? 'relative !max-w-3xl !rounded-none !bg-transparent !shadow-none'
|
||||||
|
: 'relative !h-[min(100%,calc(100vh-2rem))] !max-w-5xl !rounded-[28px] !p-5 sm:!p-6'
|
||||||
|
}
|
||||||
|
bodyClassName="!flex !min-h-0 !flex-1 !overflow-hidden !p-0"
|
||||||
|
panelStyle={{
|
||||||
|
maxHeight: ACCOUNT_MODAL_MAX_HEIGHT,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDirectAccountMode
|
isDirectAccountMode
|
||||||
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden'
|
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden'
|
||||||
: 'platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6'
|
: 'relative flex h-full w-full min-h-0 flex-col overflow-hidden'
|
||||||
}
|
}
|
||||||
role={isDirectAccountMode ? undefined : 'dialog'}
|
|
||||||
aria-modal={isDirectAccountMode ? undefined : true}
|
|
||||||
aria-label={isDirectAccountMode ? undefined : '设置与账号安全'}
|
|
||||||
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -617,6 +634,7 @@ export function AccountModal({
|
|||||||
<OverlayPanel
|
<OverlayPanel
|
||||||
title="账号信息"
|
title="账号信息"
|
||||||
standalone={isDirectAccountMode}
|
standalone={isDirectAccountMode}
|
||||||
|
dialog={!isDirectAccountMode}
|
||||||
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
|
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
@@ -1203,6 +1221,6 @@ export function AccountModal({
|
|||||||
</OverlayPanel>
|
</OverlayPanel>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PlatformAuthModalShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,3 +52,34 @@ test('keeps escape disabled for auth flows', () => {
|
|||||||
expect(onClose).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
expect(screen.getByRole('button', { name: '取消填写邀请码' })).toBeTruthy();
|
expect(screen.getByRole('button', { name: '取消填写邀请码' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('allows account shell callers to own overlay spacing and panel size', () => {
|
||||||
|
render(
|
||||||
|
<PlatformAuthModalShell
|
||||||
|
title="账号信息"
|
||||||
|
platformTheme="light"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
closeLabel="关闭账号弹窗"
|
||||||
|
size="xl"
|
||||||
|
showHeader={false}
|
||||||
|
overlaySpacing="none"
|
||||||
|
overlayClassName="!items-end"
|
||||||
|
overlayStyle={{ paddingTop: '12px' }}
|
||||||
|
authCardClassName=""
|
||||||
|
panelClassName="!max-w-3xl !bg-transparent"
|
||||||
|
bodyClassName="!p-0"
|
||||||
|
>
|
||||||
|
<div>账号内容</div>
|
||||||
|
</PlatformAuthModalShell>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||||
|
const overlay = dialog.parentElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(overlay.className).toContain('platform-theme--light');
|
||||||
|
expect(overlay.className).not.toContain('!px-3');
|
||||||
|
expect(overlay.style.paddingTop).toBe('12px');
|
||||||
|
expect(dialog.className).toContain('!max-w-3xl');
|
||||||
|
expect(dialog.className).not.toContain('platform-auth-card');
|
||||||
|
expect(within(dialog).queryByRole('button', { name: '关闭账号弹窗' })).toBeNull();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { CSSProperties, ReactNode } from 'react';
|
||||||
|
|
||||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { UnifiedModal } from '../common/UnifiedModal';
|
import { UnifiedModal } from '../common/UnifiedModal';
|
||||||
@@ -9,9 +9,16 @@ type PlatformAuthModalShellProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
closeLabel: string;
|
closeLabel: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
|
||||||
|
showHeader?: boolean;
|
||||||
|
overlaySpacing?: 'default' | 'none';
|
||||||
zIndexClassName?: string;
|
zIndexClassName?: string;
|
||||||
|
overlayClassName?: string;
|
||||||
|
overlayStyle?: CSSProperties;
|
||||||
|
authCardClassName?: string;
|
||||||
panelClassName?: string;
|
panelClassName?: string;
|
||||||
bodyClassName?: string;
|
bodyClassName?: string;
|
||||||
|
panelStyle?: CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
|
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
|
||||||
@@ -28,9 +35,16 @@ export function PlatformAuthModalShell({
|
|||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
closeLabel,
|
closeLabel,
|
||||||
|
size = 'sm',
|
||||||
|
showHeader = true,
|
||||||
|
overlaySpacing = 'default',
|
||||||
zIndexClassName = 'z-[120]',
|
zIndexClassName = 'z-[120]',
|
||||||
|
overlayClassName,
|
||||||
|
overlayStyle,
|
||||||
|
authCardClassName = 'platform-auth-card !rounded-[2rem] sm:!rounded-[2rem]',
|
||||||
panelClassName,
|
panelClassName,
|
||||||
bodyClassName = '!p-0',
|
bodyClassName = '!p-0',
|
||||||
|
panelStyle,
|
||||||
}: PlatformAuthModalShellProps) {
|
}: PlatformAuthModalShellProps) {
|
||||||
return (
|
return (
|
||||||
<UnifiedModal
|
<UnifiedModal
|
||||||
@@ -42,16 +56,23 @@ export function PlatformAuthModalShell({
|
|||||||
closeOnBackdrop
|
closeOnBackdrop
|
||||||
closeOnEscape={false}
|
closeOnEscape={false}
|
||||||
portal={false}
|
portal={false}
|
||||||
size="sm"
|
size={size}
|
||||||
|
showHeader={showHeader}
|
||||||
zIndexClassName={zIndexClassName}
|
zIndexClassName={zIndexClassName}
|
||||||
overlayClassName={`platform-theme platform-theme--${platformTheme} !px-3 !py-4 text-[var(--platform-text-strong)] sm:!p-4`}
|
overlayClassName={joinClassNames(
|
||||||
|
`platform-theme platform-theme--${platformTheme} text-[var(--platform-text-strong)]`,
|
||||||
|
overlaySpacing === 'default' && '!px-3 !py-4 sm:!p-4',
|
||||||
|
overlayClassName,
|
||||||
|
)}
|
||||||
|
overlayStyle={overlayStyle}
|
||||||
panelClassName={joinClassNames(
|
panelClassName={joinClassNames(
|
||||||
'platform-auth-card !rounded-[2rem] sm:!rounded-[2rem]',
|
authCardClassName,
|
||||||
panelClassName,
|
panelClassName,
|
||||||
)}
|
)}
|
||||||
headerClassName="!items-center !px-5 !py-4"
|
headerClassName="!items-center !px-5 !py-4"
|
||||||
titleClassName="text-lg font-semibold text-[var(--platform-text-strong)]"
|
titleClassName="text-lg font-semibold text-[var(--platform-text-strong)]"
|
||||||
bodyClassName={bodyClassName}
|
bodyClassName={bodyClassName}
|
||||||
|
panelStyle={panelStyle}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</UnifiedModal>
|
</UnifiedModal>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type UnifiedModalProps = {
|
|||||||
portal?: boolean;
|
portal?: boolean;
|
||||||
zIndexClassName?: string;
|
zIndexClassName?: string;
|
||||||
overlayClassName?: string;
|
overlayClassName?: string;
|
||||||
|
overlayStyle?: CSSProperties;
|
||||||
panelClassName?: string;
|
panelClassName?: string;
|
||||||
headerClassName?: string;
|
headerClassName?: string;
|
||||||
titleClassName?: string;
|
titleClassName?: string;
|
||||||
@@ -105,6 +106,7 @@ function UnifiedModalContent({
|
|||||||
closeIcon,
|
closeIcon,
|
||||||
zIndexClassName = 'z-[90]',
|
zIndexClassName = 'z-[90]',
|
||||||
overlayClassName,
|
overlayClassName,
|
||||||
|
overlayStyle,
|
||||||
panelClassName,
|
panelClassName,
|
||||||
headerClassName,
|
headerClassName,
|
||||||
titleClassName,
|
titleClassName,
|
||||||
@@ -172,6 +174,7 @@ function UnifiedModalContent({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
|
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
|
||||||
|
style={overlayStyle}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (
|
if (
|
||||||
closeOnBackdrop &&
|
closeOnBackdrop &&
|
||||||
|
|||||||
@@ -424,6 +424,43 @@ test('跳一跳运行态失败后在弹窗中展示排行榜', () => {
|
|||||||
expect(within(leaderboard).getByText('00:08')).toBeTruthy();
|
expect(within(leaderboard).getByText('00:08')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('跳一跳运行态完成弹窗复用结算结构且不请求失败排行榜', () => {
|
||||||
|
const onRestart = vi.fn();
|
||||||
|
const onExit = vi.fn();
|
||||||
|
const clearedRun = {
|
||||||
|
...buildRun(),
|
||||||
|
status: 'cleared',
|
||||||
|
successfulJumpCount: 12,
|
||||||
|
durationMs: 19123,
|
||||||
|
score: 12,
|
||||||
|
combo: 2,
|
||||||
|
finishedAtMs: 20123,
|
||||||
|
} satisfies JumpHopRuntimeRunSnapshotResponse;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ publicationStatus: 'published' })}
|
||||||
|
run={clearedRun}
|
||||||
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onRestart={onRestart}
|
||||||
|
onExit={onExit}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '结束' });
|
||||||
|
|
||||||
|
expect(useJumpHopLeaderboard).not.toHaveBeenCalled();
|
||||||
|
expect(within(dialog).queryByTestId('jump-hop-runtime-leaderboard')).toBeNull();
|
||||||
|
expect(within(dialog).getByText('12 跳')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('00:19')).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: '重开' }));
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: '返回' }));
|
||||||
|
|
||||||
|
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onExit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('跳一跳草稿运行失败后不请求公开排行榜', () => {
|
test('跳一跳草稿运行失败后不请求公开排行榜', () => {
|
||||||
render(
|
render(
|
||||||
<JumpHopRuntimeShell
|
<JumpHopRuntimeShell
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
|
|
||||||
import jumpHopRuntimeLevelLogo from '../../../media/logo.png';
|
import jumpHopRuntimeLevelLogo from '../../../media/logo.png';
|
||||||
import type {
|
import type {
|
||||||
|
JumpHopRunStatus,
|
||||||
JumpHopRuntimeRunSnapshotResponse,
|
JumpHopRuntimeRunSnapshotResponse,
|
||||||
JumpHopTileAsset,
|
JumpHopTileAsset,
|
||||||
JumpHopTileFaceAsset,
|
JumpHopTileFaceAsset,
|
||||||
@@ -88,6 +89,18 @@ type JumpHopRuntimeShellProps = {
|
|||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type JumpHopRuntimeSettlementDialogProps = {
|
||||||
|
status: JumpHopRunStatus;
|
||||||
|
successfulJumpCount: number;
|
||||||
|
durationLabel: string;
|
||||||
|
isBusy: boolean;
|
||||||
|
showLeaderboard: boolean;
|
||||||
|
profileId?: string | null;
|
||||||
|
runtimeRequestOptions?: JumpHopRuntimeRequestOptions;
|
||||||
|
onRestart: () => void;
|
||||||
|
onExit?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_CHARGE_RATIO = 1;
|
const MAX_CHARGE_RATIO = 1;
|
||||||
const DEFAULT_MAX_DRAG_DISTANCE_PX = 180;
|
const DEFAULT_MAX_DRAG_DISTANCE_PX = 180;
|
||||||
const JUMP_HOP_ANIMATION_DURATION_MS = 560;
|
const JUMP_HOP_ANIMATION_DURATION_MS = 560;
|
||||||
@@ -1179,6 +1192,93 @@ function JumpHopLeaderboardPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function JumpHopRuntimeSettlementSummary({
|
||||||
|
successfulJumpCount,
|
||||||
|
durationLabel,
|
||||||
|
}: Pick<
|
||||||
|
JumpHopRuntimeSettlementDialogProps,
|
||||||
|
'successfulJumpCount' | 'durationLabel'
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex justify-center gap-4 text-sm font-bold text-slate-600">
|
||||||
|
<span>{successfulJumpCount} 跳</span>
|
||||||
|
<span>{durationLabel}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JumpHopRuntimeSettlementActions({
|
||||||
|
isBusy,
|
||||||
|
onRestart,
|
||||||
|
onExit,
|
||||||
|
}: Pick<
|
||||||
|
JumpHopRuntimeSettlementDialogProps,
|
||||||
|
'isBusy' | 'onRestart' | 'onExit'
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||||
|
<PlatformActionButton
|
||||||
|
onClick={onRestart}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="min-h-11 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
重开
|
||||||
|
</PlatformActionButton>
|
||||||
|
<PlatformActionButton
|
||||||
|
onClick={onExit}
|
||||||
|
tone="ghost"
|
||||||
|
className="min-h-11 bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</PlatformActionButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JumpHopRuntimeSettlementDialog({
|
||||||
|
status,
|
||||||
|
successfulJumpCount,
|
||||||
|
durationLabel,
|
||||||
|
isBusy,
|
||||||
|
showLeaderboard,
|
||||||
|
profileId,
|
||||||
|
runtimeRequestOptions,
|
||||||
|
onRestart,
|
||||||
|
onExit,
|
||||||
|
}: JumpHopRuntimeSettlementDialogProps) {
|
||||||
|
const title = getJumpHopStatusLabel(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-[120] grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="jump-hop-result-title"
|
||||||
|
className="w-full max-w-[24rem] rounded-[1.25rem] border border-white/70 bg-white/90 p-4 text-center shadow-[0_18px_50px_rgba(15,23,42,0.22)]"
|
||||||
|
>
|
||||||
|
<div className="text-2xl font-black">
|
||||||
|
<span id="jump-hop-result-title">{title}</span>
|
||||||
|
</div>
|
||||||
|
<JumpHopRuntimeSettlementSummary
|
||||||
|
successfulJumpCount={successfulJumpCount}
|
||||||
|
durationLabel={durationLabel}
|
||||||
|
/>
|
||||||
|
{showLeaderboard ? (
|
||||||
|
<JumpHopLeaderboardPanel
|
||||||
|
profileId={profileId}
|
||||||
|
runtimeRequestOptions={runtimeRequestOptions}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<JumpHopRuntimeSettlementActions
|
||||||
|
isBusy={isBusy}
|
||||||
|
onRestart={onRestart}
|
||||||
|
onExit={onExit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function JumpHopRuntimeShell({
|
export function JumpHopRuntimeShell({
|
||||||
profile = null,
|
profile = null,
|
||||||
run,
|
run,
|
||||||
@@ -1489,10 +1589,12 @@ export function JumpHopRuntimeShell({
|
|||||||
visualJump,
|
visualJump,
|
||||||
]);
|
]);
|
||||||
const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun);
|
const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun);
|
||||||
const isSettled =
|
const settlementStatus =
|
||||||
stageRun?.status === 'failed' || stageRun?.status === 'cleared';
|
stageRun?.status === 'failed' || stageRun?.status === 'cleared'
|
||||||
|
? stageRun.status
|
||||||
|
: null;
|
||||||
const shouldShowFailureLeaderboard =
|
const shouldShowFailureLeaderboard =
|
||||||
stageRun?.status === 'failed' &&
|
settlementStatus === 'failed' &&
|
||||||
profile?.summary.publicationStatus === 'published';
|
profile?.summary.publicationStatus === 'published';
|
||||||
const successfulJumpCount = stageRun?.successfulJumpCount ?? 0;
|
const successfulJumpCount = stageRun?.successfulJumpCount ?? 0;
|
||||||
const durationLabel = formatJumpHopDurationLabel(
|
const durationLabel = formatJumpHopDurationLabel(
|
||||||
@@ -2279,47 +2381,18 @@ export function JumpHopRuntimeShell({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isSettled ? (
|
{settlementStatus ? (
|
||||||
<div className="absolute inset-0 z-[120] grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]">
|
<JumpHopRuntimeSettlementDialog
|
||||||
<div
|
status={settlementStatus}
|
||||||
role="dialog"
|
successfulJumpCount={successfulJumpCount}
|
||||||
aria-modal="true"
|
durationLabel={durationLabel}
|
||||||
aria-labelledby="jump-hop-result-title"
|
isBusy={isBusy}
|
||||||
className="w-full max-w-[24rem] rounded-[1.25rem] border border-white/70 bg-white/90 p-4 text-center shadow-[0_18px_50px_rgba(15,23,42,0.22)]"
|
showLeaderboard={shouldShowFailureLeaderboard}
|
||||||
>
|
profileId={profile?.summary.profileId}
|
||||||
<div className="text-2xl font-black">
|
runtimeRequestOptions={runtimeRequestOptions}
|
||||||
<span id="jump-hop-result-title">
|
onRestart={onRestart}
|
||||||
{getJumpHopStatusLabel(stageRun?.status)}
|
onExit={exitHandler}
|
||||||
</span>
|
/>
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex justify-center gap-4 text-sm font-bold text-slate-600">
|
|
||||||
<span>{successfulJumpCount} 跳</span>
|
|
||||||
<span>{durationLabel}</span>
|
|
||||||
</div>
|
|
||||||
{shouldShowFailureLeaderboard ? (
|
|
||||||
<JumpHopLeaderboardPanel
|
|
||||||
profileId={profile?.summary.profileId}
|
|
||||||
runtimeRequestOptions={runtimeRequestOptions}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
|
||||||
<PlatformActionButton
|
|
||||||
onClick={onRestart}
|
|
||||||
disabled={isBusy}
|
|
||||||
className="min-h-11 px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
重开
|
|
||||||
</PlatformActionButton>
|
|
||||||
<PlatformActionButton
|
|
||||||
onClick={exitHandler}
|
|
||||||
tone="ghost"
|
|
||||||
className="min-h-11 bg-white px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
返回
|
|
||||||
</PlatformActionButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import {
|
||||||
|
act,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from '@testing-library/react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -300,6 +307,65 @@ test('推荐页抓大鹅运行态隐藏返回按钮和结算返回入口', () =>
|
|||||||
expect(screen.getByRole('button', { name: '再来一局' })).toBeTruthy();
|
expect(screen.getByRole('button', { name: '再来一局' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('结算弹窗使用运行态本地壳层并承接完成和失败状态', () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
const onRestart = vi.fn();
|
||||||
|
const wonRun: Match3DRunSnapshot = {
|
||||||
|
...startLocalMatch3DRun(4),
|
||||||
|
status: 'Won',
|
||||||
|
startedAtMs: 1000,
|
||||||
|
durationLimitMs: 10_000,
|
||||||
|
remainingMs: 6_000,
|
||||||
|
};
|
||||||
|
const failedRun: Match3DRunSnapshot = {
|
||||||
|
...startLocalMatch3DRun(4),
|
||||||
|
status: 'Failed',
|
||||||
|
clearedItemCount: 3,
|
||||||
|
totalItemCount: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<Match3DRuntimeShell
|
||||||
|
run={wonRun}
|
||||||
|
onBack={onBack}
|
||||||
|
onRestart={onRestart}
|
||||||
|
onOptimisticRunChange={vi.fn()}
|
||||||
|
onClickItem={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '通关完成' });
|
||||||
|
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
||||||
|
expect(screen.getByTestId('match3d-runtime-modal-overlay')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('match3d-runtime-settlement-modal')).toBe(dialog);
|
||||||
|
const settlementActions = screen.getByTestId(
|
||||||
|
'match3d-runtime-settlement-actions',
|
||||||
|
);
|
||||||
|
expect(settlementActions.className).toContain('grid-cols-2');
|
||||||
|
expect(screen.getByText('用时 0:04')).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(within(settlementActions).getByRole('button', { name: '返回' }));
|
||||||
|
fireEvent.click(
|
||||||
|
within(settlementActions).getByRole('button', { name: '再来一局' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onBack).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Match3DRuntimeShell
|
||||||
|
run={failedRun}
|
||||||
|
onBack={onBack}
|
||||||
|
onRestart={onRestart}
|
||||||
|
onOptimisticRunChange={vi.fn()}
|
||||||
|
onClickItem={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog', { name: '本轮失败' })).toBeTruthy();
|
||||||
|
expect(screen.getByText('已清除 3/12')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
test('显示层把可消除物整体半径放大 2 倍且保留相对比例', () => {
|
test('显示层把可消除物整体半径放大 2 倍且保留相对比例', () => {
|
||||||
const run = startLocalMatch3DRun(21);
|
const run = startLocalMatch3DRun(21);
|
||||||
const firstItemByType = [
|
const firstItemByType = [
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type PointerEvent,
|
type PointerEvent,
|
||||||
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
@@ -923,21 +924,69 @@ function Match3DSettlement({
|
|||||||
? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}`
|
? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}`
|
||||||
: `已清除 ${run.clearedItemCount}/${run.totalItemCount}`;
|
: `已清除 ${run.clearedItemCount}/${run.totalItemCount}`;
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-[80] flex items-center justify-center bg-slate-950/62 px-5 backdrop-blur-sm">
|
<Match3DRuntimeResultModalShell
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
tone={won ? 'success' : 'danger'}
|
||||||
|
icon={won ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
|
||||||
|
footer={
|
||||||
|
<Match3DSettlementActions
|
||||||
|
hideBackButton={hideBackButton}
|
||||||
|
onBack={onBack}
|
||||||
|
onRestart={onRestart}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
data-testid="match3d-runtime-settlement-modal"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Match3DRuntimeResultModalTone = 'success' | 'danger';
|
||||||
|
|
||||||
|
type Match3DRuntimeResultModalShellProps = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tone: Match3DRuntimeResultModalTone;
|
||||||
|
icon: ReactNode;
|
||||||
|
footer: ReactNode;
|
||||||
|
'data-testid'?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MATCH3D_RUNTIME_RESULT_ICON_CLASS: Record<
|
||||||
|
Match3DRuntimeResultModalTone,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
success: 'bg-emerald-100 text-emerald-700',
|
||||||
|
danger: 'bg-rose-100 text-rose-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 中文注释:运行态结算弹窗保留抓大鹅的白底品牌质感,只把遮罩、面板和 footer 骨架集中到本文件内复用。
|
||||||
|
function Match3DRuntimeResultModalShell({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
tone,
|
||||||
|
icon,
|
||||||
|
footer,
|
||||||
|
'data-testid': testId,
|
||||||
|
}: Match3DRuntimeResultModalShellProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-[80] flex items-center justify-center bg-slate-950/62 px-5 backdrop-blur-sm"
|
||||||
|
data-testid="match3d-runtime-modal-overlay"
|
||||||
|
>
|
||||||
<section
|
<section
|
||||||
className="w-full max-w-sm rounded-[1.5rem] border border-white/18 bg-white/94 p-5 text-slate-950 shadow-[0_26px_70px_rgba(15,23,42,0.34)]"
|
className="w-full max-w-sm rounded-[1.5rem] border border-white/18 bg-white/94 p-5 text-slate-950 shadow-[0_26px_70px_rgba(15,23,42,0.34)]"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
|
data-testid={testId}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
className={`flex h-11 w-11 items-center justify-center rounded-full ${
|
className={`flex h-11 w-11 items-center justify-center rounded-full ${MATCH3D_RUNTIME_RESULT_ICON_CLASS[tone]}`}
|
||||||
won
|
aria-hidden="true"
|
||||||
? 'bg-emerald-100 text-emerald-700'
|
|
||||||
: 'bg-rose-100 text-rose-700'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{won ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-black">{title}</h2>
|
<h2 className="text-xl font-black">{title}</h2>
|
||||||
@@ -946,29 +995,59 @@ function Match3DSettlement({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`grid gap-2 ${hideBackButton ? '' : 'grid-cols-2'}`}>
|
{footer}
|
||||||
{!hideBackButton ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-black text-slate-700"
|
|
||||||
onClick={onBack}
|
|
||||||
>
|
|
||||||
返回
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white"
|
|
||||||
onClick={onRestart}
|
|
||||||
>
|
|
||||||
再来一局
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Match3DSettlementActions({
|
||||||
|
hideBackButton,
|
||||||
|
onBack,
|
||||||
|
onRestart,
|
||||||
|
}: {
|
||||||
|
hideBackButton?: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
onRestart: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`grid gap-2 ${hideBackButton ? '' : 'grid-cols-2'}`}
|
||||||
|
data-testid="match3d-runtime-settlement-actions"
|
||||||
|
>
|
||||||
|
{!hideBackButton ? (
|
||||||
|
<Match3DSettlementActionButton variant="secondary" onClick={onBack}>
|
||||||
|
返回
|
||||||
|
</Match3DSettlementActionButton>
|
||||||
|
) : null}
|
||||||
|
<Match3DSettlementActionButton variant="primary" onClick={onRestart}>
|
||||||
|
再来一局
|
||||||
|
</Match3DSettlementActionButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Match3DSettlementActionButton({
|
||||||
|
variant,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
variant: 'primary' | 'secondary';
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const className =
|
||||||
|
variant === 'primary'
|
||||||
|
? 'rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white'
|
||||||
|
: 'rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-black text-slate-700';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className={className} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Match3DRuntimeShell({
|
export function Match3DRuntimeShell({
|
||||||
run,
|
run,
|
||||||
generatedItemAssets = EMPTY_MATCH3D_GENERATED_ITEM_ASSETS,
|
generatedItemAssets = EMPTY_MATCH3D_GENERATED_ITEM_ASSETS,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
within,
|
||||||
|
} from '@testing-library/react';
|
||||||
import { expect, test, vi } from 'vitest';
|
import { expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +63,7 @@ test('PuzzleOnboardingView uses shared dark textarea and error status chrome', (
|
|||||||
expect(screen.getByText('拼图生成失败').className).toContain(
|
expect(screen.getByText('拼图生成失败').className).toContain(
|
||||||
'border-rose-300/15',
|
'border-rose-300/15',
|
||||||
);
|
);
|
||||||
|
expect(screen.queryByRole('dialog')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PuzzleOnboardingView preserves submit, skip, and disabled phase behavior', () => {
|
test('PuzzleOnboardingView preserves submit, skip, and disabled phase behavior', () => {
|
||||||
@@ -120,6 +127,15 @@ test('PuzzleOnboardingLoginOverlay uses shared error status and keeps login acti
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '登录后保存你的拼图' });
|
||||||
|
|
||||||
|
expect(dialog.className).toContain('platform-modal-shell');
|
||||||
|
expect(dialog.className).toContain('!max-w-[24rem]');
|
||||||
|
expect(dialog.parentElement?.className).toContain('z-[110]');
|
||||||
|
expect(
|
||||||
|
within(dialog).queryByRole('button', { name: '关闭' }),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '注册账号 / 登录' }));
|
fireEvent.click(screen.getByRole('button', { name: '注册账号 / 登录' }));
|
||||||
|
|
||||||
expect(onLogin).toHaveBeenCalledTimes(1);
|
expect(onLogin).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Loader2, Sparkles } from 'lucide-react';
|
|||||||
import { PlatformActionButton } from '../../common/PlatformActionButton';
|
import { PlatformActionButton } from '../../common/PlatformActionButton';
|
||||||
import { PlatformStatusMessage } from '../../common/PlatformStatusMessage';
|
import { PlatformStatusMessage } from '../../common/PlatformStatusMessage';
|
||||||
import { PlatformTextField } from '../../common/PlatformTextField';
|
import { PlatformTextField } from '../../common/PlatformTextField';
|
||||||
|
import { UnifiedModal } from '../../common/UnifiedModal';
|
||||||
|
|
||||||
export type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated';
|
export type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated';
|
||||||
|
|
||||||
@@ -122,45 +123,57 @@ export function PuzzleOnboardingLoginOverlay({
|
|||||||
onLogin,
|
onLogin,
|
||||||
}: PuzzleOnboardingLoginOverlayProps) {
|
}: PuzzleOnboardingLoginOverlayProps) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-950/72 px-4 py-6 text-white backdrop-blur-md">
|
<UnifiedModal
|
||||||
<section className="flex w-full max-w-[24rem] flex-col items-center gap-5 rounded-[1.35rem] border border-white/14 bg-slate-950/94 px-5 py-6 text-center shadow-[0_28px_90px_rgba(0,0,0,0.5)]">
|
open
|
||||||
<div className="grid h-12 w-12 place-items-center rounded-[1rem] bg-amber-200 text-slate-950">
|
title={copy}
|
||||||
{isSaving ? (
|
onClose={() => undefined}
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
portal={false}
|
||||||
) : (
|
showHeader={false}
|
||||||
<Sparkles className="h-5 w-5" />
|
showCloseButton={false}
|
||||||
)}
|
closeOnBackdrop={false}
|
||||||
</div>
|
closeOnEscape={false}
|
||||||
<h2 className="text-2xl font-black leading-tight">{copy}</h2>
|
size="sm"
|
||||||
<PlatformActionButton
|
zIndexClassName="z-[110]"
|
||||||
type="button"
|
overlayClassName="!items-center bg-slate-950/72 !px-4 !py-6 text-white backdrop-blur-md"
|
||||||
|
panelClassName="!max-w-[24rem] !rounded-[1.35rem] border border-white/14 bg-slate-950/94 text-white shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
|
||||||
|
bodyClassName="flex flex-col items-center gap-5 !px-5 !py-6 text-center"
|
||||||
|
>
|
||||||
|
<div className="grid h-12 w-12 place-items-center rounded-[1rem] bg-amber-200 text-slate-950">
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-black leading-tight">{copy}</h2>
|
||||||
|
<PlatformActionButton
|
||||||
|
type="button"
|
||||||
|
surface="editorDark"
|
||||||
|
tone="accent"
|
||||||
|
size="lg"
|
||||||
|
fullWidth
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onLogin}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
注册账号 / 登录
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'注册账号 / 登录'
|
||||||
|
)}
|
||||||
|
</PlatformActionButton>
|
||||||
|
{error ? (
|
||||||
|
<PlatformStatusMessage
|
||||||
|
tone="error"
|
||||||
surface="editorDark"
|
surface="editorDark"
|
||||||
tone="accent"
|
size="md"
|
||||||
size="lg"
|
className="w-full font-semibold"
|
||||||
fullWidth
|
|
||||||
disabled={isSaving}
|
|
||||||
onClick={onLogin}
|
|
||||||
>
|
>
|
||||||
{isSaving ? (
|
{error}
|
||||||
<>
|
</PlatformStatusMessage>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
) : null}
|
||||||
注册账号 / 登录
|
</UnifiedModal>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'注册账号 / 登录'
|
|
||||||
)}
|
|
||||||
</PlatformActionButton>
|
|
||||||
{error ? (
|
|
||||||
<PlatformStatusMessage
|
|
||||||
tone="error"
|
|
||||||
surface="editorDark"
|
|
||||||
size="md"
|
|
||||||
className="w-full font-semibold"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</PlatformStatusMessage>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
93
src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx
Normal file
93
src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type {
|
||||||
|
ButtonHTMLAttributes,
|
||||||
|
MouseEvent,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
type PuzzleRuntimeModalShellProps = {
|
||||||
|
titleId: string;
|
||||||
|
children: ReactNode;
|
||||||
|
overlayClassName?: string;
|
||||||
|
dialogClassName?: string;
|
||||||
|
onOverlayClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PuzzleRuntimeDialogFooterProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
className: string;
|
||||||
|
framed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PuzzleRuntimeDialogButtonProps =
|
||||||
|
ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
tone: 'primary' | 'secondary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_OVERLAY_CLASS_NAME =
|
||||||
|
'puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm';
|
||||||
|
|
||||||
|
const DEFAULT_DIALOG_CLASS_NAME =
|
||||||
|
'puzzle-runtime-dialog w-full max-w-[22rem] overflow-hidden rounded-[1.35rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]';
|
||||||
|
|
||||||
|
export function PuzzleRuntimeModalShell({
|
||||||
|
titleId,
|
||||||
|
children,
|
||||||
|
overlayClassName = DEFAULT_OVERLAY_CLASS_NAME,
|
||||||
|
dialogClassName = DEFAULT_DIALOG_CLASS_NAME,
|
||||||
|
onOverlayClick,
|
||||||
|
}: PuzzleRuntimeModalShellProps) {
|
||||||
|
const handleDialogClick = (event: MouseEvent<HTMLElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={overlayClassName}
|
||||||
|
onClick={onOverlayClick ? () => onOverlayClick() : undefined}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
className={dialogClassName}
|
||||||
|
onClick={handleDialogClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PuzzleRuntimeDialogFooter({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
framed = true,
|
||||||
|
}: PuzzleRuntimeDialogFooterProps) {
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
className={`${framed ? 'puzzle-runtime-dialog__line border-t ' : ''}${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PuzzleRuntimeDialogButton({
|
||||||
|
tone,
|
||||||
|
className = '',
|
||||||
|
type = 'button',
|
||||||
|
...buttonProps
|
||||||
|
}: PuzzleRuntimeDialogButtonProps) {
|
||||||
|
const toneClassName =
|
||||||
|
tone === 'primary'
|
||||||
|
? 'puzzle-runtime-primary-button'
|
||||||
|
: 'puzzle-runtime-secondary-button';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...buttonProps}
|
||||||
|
type={type}
|
||||||
|
className={`${toneClassName} ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,6 +58,11 @@ import { useAuthUi } from '../auth/AuthUiContext';
|
|||||||
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
|
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
|
||||||
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
|
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
|
import {
|
||||||
|
PuzzleRuntimeDialogButton,
|
||||||
|
PuzzleRuntimeDialogFooter,
|
||||||
|
PuzzleRuntimeModalShell,
|
||||||
|
} from './PuzzleRuntimeModalShell';
|
||||||
import {
|
import {
|
||||||
buildMergedGroupOutlinePath,
|
buildMergedGroupOutlinePath,
|
||||||
buildRoundedGridCellClipPath,
|
buildRoundedGridCellClipPath,
|
||||||
@@ -1372,13 +1377,11 @@ export function PuzzleRuntimeShell({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearResultDialog = isClearResultOpen ? (
|
const clearResultDialog = isClearResultOpen ? (
|
||||||
<div className={clearResultOverlayClassName}>
|
<PuzzleRuntimeModalShell
|
||||||
<section
|
titleId="puzzle-clear-result-title"
|
||||||
role="dialog"
|
overlayClassName={clearResultOverlayClassName}
|
||||||
aria-modal="true"
|
dialogClassName="puzzle-runtime-dialog flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||||||
aria-labelledby="puzzle-clear-result-title"
|
>
|
||||||
className="puzzle-runtime-dialog flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
|
||||||
>
|
|
||||||
<header className="puzzle-runtime-dialog__line flex items-start justify-between gap-3 border-b px-5 py-4">
|
<header className="puzzle-runtime-dialog__line flex items-start justify-between gap-3 border-b px-5 py-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="puzzle-runtime-primary-button mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full">
|
<div className="puzzle-runtime-primary-button mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||||
@@ -1394,16 +1397,17 @@ export function PuzzleRuntimeShell({
|
|||||||
{currentLevel.levelName}
|
{currentLevel.levelName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<PuzzleRuntimeDialogButton
|
||||||
type="button"
|
type="button"
|
||||||
|
tone="secondary"
|
||||||
aria-label="关闭通关弹窗"
|
aria-label="关闭通关弹窗"
|
||||||
className="puzzle-runtime-secondary-button inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition hover:brightness-105"
|
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition hover:brightness-105"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDismissedClearKey(clearResultKey);
|
setDismissedClearKey(clearResultKey);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</PuzzleRuntimeDialogButton>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||||
@@ -1493,7 +1497,7 @@ export function PuzzleRuntimeShell({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canAdvanceDefaultNextLevel ? (
|
{canAdvanceDefaultNextLevel ? (
|
||||||
<footer className="puzzle-runtime-dialog__line flex items-center justify-end border-t px-5 py-4">
|
<PuzzleRuntimeDialogFooter className="flex items-center justify-end px-5 py-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="下一关"
|
aria-label="下一关"
|
||||||
@@ -1523,10 +1527,9 @@ export function PuzzleRuntimeShell({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</PuzzleRuntimeDialogFooter>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</PuzzleRuntimeModalShell>
|
||||||
</div>
|
|
||||||
) : null;
|
) : null;
|
||||||
const clearResultLayer =
|
const clearResultLayer =
|
||||||
embedded && clearResultDialog && typeof document !== 'undefined'
|
embedded && clearResultDialog && typeof document !== 'undefined'
|
||||||
@@ -2174,290 +2177,261 @@ export function PuzzleRuntimeShell({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{propDialog ? (
|
{propDialog ? (
|
||||||
<div
|
<PuzzleRuntimeModalShell
|
||||||
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
|
titleId="puzzle-prop-confirm-title"
|
||||||
onClick={() => {
|
onOverlayClick={() => {
|
||||||
if (!isPropConfirming) {
|
if (!isPropConfirming) {
|
||||||
setPropDialog(null);
|
setPropDialog(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<section
|
<header className="puzzle-runtime-dialog__line flex items-center gap-3 border-b px-5 py-4">
|
||||||
role="dialog"
|
<span className="puzzle-runtime-primary-button inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||||
aria-modal="true"
|
<Sparkles className="h-4 w-4" />
|
||||||
aria-labelledby="puzzle-prop-confirm-title"
|
</span>
|
||||||
className="puzzle-runtime-dialog w-full max-w-[22rem] overflow-hidden rounded-[1.35rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
<h2
|
||||||
onClick={(event) => event.stopPropagation()}
|
id="puzzle-prop-confirm-title"
|
||||||
>
|
className="text-sm font-black"
|
||||||
<header className="puzzle-runtime-dialog__line flex items-center gap-3 border-b px-5 py-4">
|
>
|
||||||
<span className="puzzle-runtime-primary-button inline-flex h-9 w-9 items-center justify-center rounded-full">
|
{propDialog.title}
|
||||||
<Sparkles className="h-4 w-4" />
|
</h2>
|
||||||
</span>
|
</header>
|
||||||
<h2
|
<div className="puzzle-runtime-dialog__body px-5 py-4 text-sm">
|
||||||
id="puzzle-prop-confirm-title"
|
消耗 1 泥点
|
||||||
className="text-sm font-black"
|
{propConfirmError ? (
|
||||||
|
<PlatformRuntimeStatusToast
|
||||||
|
tone="error"
|
||||||
|
size="xs"
|
||||||
|
shape="rounded"
|
||||||
|
className="mt-3"
|
||||||
>
|
>
|
||||||
{propDialog.title}
|
{propConfirmError}
|
||||||
</h2>
|
</PlatformRuntimeStatusToast>
|
||||||
</header>
|
) : null}
|
||||||
<div className="puzzle-runtime-dialog__body px-5 py-4 text-sm">
|
</div>
|
||||||
消耗 1 泥点
|
<PuzzleRuntimeDialogFooter className="flex items-center justify-end gap-3 px-5 py-4">
|
||||||
{propConfirmError ? (
|
<PuzzleRuntimeDialogButton
|
||||||
<PlatformRuntimeStatusToast
|
tone="secondary"
|
||||||
tone="error"
|
onClick={() => setPropDialog(null)}
|
||||||
size="xs"
|
disabled={isPropConfirming}
|
||||||
shape="rounded"
|
className="rounded-full px-4 py-2 text-xs font-bold transition hover:brightness-105"
|
||||||
className="mt-3"
|
>
|
||||||
>
|
取消
|
||||||
{propConfirmError}
|
</PuzzleRuntimeDialogButton>
|
||||||
</PlatformRuntimeStatusToast>
|
<PuzzleRuntimeDialogButton
|
||||||
|
tone="primary"
|
||||||
|
disabled={isPropConfirming}
|
||||||
|
onClick={() => {
|
||||||
|
void handleConfirmProp();
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-black transition hover:brightness-105 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isPropConfirming ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
确定
|
||||||
<footer className="puzzle-runtime-dialog__line flex items-center justify-end gap-3 border-t px-5 py-4">
|
</PuzzleRuntimeDialogButton>
|
||||||
<button
|
</PuzzleRuntimeDialogFooter>
|
||||||
type="button"
|
</PuzzleRuntimeModalShell>
|
||||||
onClick={() => setPropDialog(null)}
|
|
||||||
disabled={isPropConfirming}
|
|
||||||
className="puzzle-runtime-secondary-button rounded-full px-4 py-2 text-xs font-bold transition hover:brightness-105"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isPropConfirming}
|
|
||||||
onClick={() => {
|
|
||||||
void handleConfirmProp();
|
|
||||||
}}
|
|
||||||
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-black transition hover:brightness-105 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{isPropConfirming ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : null}
|
|
||||||
确定
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isSettingsPanelOpen ? (
|
{isSettingsPanelOpen ? (
|
||||||
<div
|
<PuzzleRuntimeModalShell
|
||||||
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-3 backdrop-blur-sm sm:p-4"
|
titleId="puzzle-settings-title"
|
||||||
onClick={() => setIsSettingsPanelOpen(false)}
|
overlayClassName="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-3 backdrop-blur-sm sm:p-4"
|
||||||
|
dialogClassName="puzzle-runtime-dialog flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
||||||
|
onOverlayClick={() => setIsSettingsPanelOpen(false)}
|
||||||
>
|
>
|
||||||
<section
|
<header className="puzzle-runtime-dialog__line relative border-b px-4 py-3 sm:px-5 sm:py-4">
|
||||||
role="dialog"
|
<div className="min-w-0 pr-10">
|
||||||
aria-modal="true"
|
<h2 id="puzzle-settings-title" className="text-sm font-semibold">
|
||||||
aria-labelledby="puzzle-settings-title"
|
拼图设置
|
||||||
className="puzzle-runtime-dialog flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
</h2>
|
||||||
onClick={(event) => event.stopPropagation()}
|
<div className="puzzle-runtime-dialog__soft mt-1 text-[11px]">
|
||||||
>
|
{hideExitControls
|
||||||
<header className="puzzle-runtime-dialog__line relative border-b px-4 py-3 sm:px-5 sm:py-4">
|
? '调整音乐音量,查看本局进度。'
|
||||||
<div className="min-w-0 pr-10">
|
: '调整音乐音量,查看本局进度,或返回上一页。'}
|
||||||
<h2
|
|
||||||
id="puzzle-settings-title"
|
|
||||||
className="text-sm font-semibold"
|
|
||||||
>
|
|
||||||
拼图设置
|
|
||||||
</h2>
|
|
||||||
<div className="puzzle-runtime-dialog__soft mt-1 text-[11px]">
|
|
||||||
{hideExitControls
|
|
||||||
? '调整音乐音量,查看本局进度。'
|
|
||||||
: '调整音乐音量,查看本局进度,或返回上一页。'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<button
|
||||||
aria-label="关闭拼图设置"
|
type="button"
|
||||||
onClick={() => setIsSettingsPanelOpen(false)}
|
aria-label="关闭拼图设置"
|
||||||
className="puzzle-runtime-dialog__soft absolute right-4 top-3 p-1 transition-colors hover:brightness-75 sm:right-5 sm:top-4"
|
onClick={() => setIsSettingsPanelOpen(false)}
|
||||||
>
|
className="puzzle-runtime-dialog__soft absolute right-4 top-3 p-1 transition-colors hover:brightness-75 sm:right-5 sm:top-4"
|
||||||
<X className="h-4 w-4" />
|
>
|
||||||
</button>
|
<X className="h-4 w-4" />
|
||||||
</header>
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
<div className="puzzle-runtime-settings-card rounded-2xl p-4">
|
<div className="puzzle-runtime-settings-card rounded-2xl p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="puzzle-runtime-tool-button__cool text-[10px] tracking-[0.24em]">
|
<div className="puzzle-runtime-tool-button__cool text-[10px] tracking-[0.24em]">
|
||||||
音频
|
音频
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm font-semibold">音乐音量</div>
|
|
||||||
</div>
|
|
||||||
<div className="puzzle-runtime-pill rounded-full px-2 py-1 text-xs">
|
|
||||||
{Math.round(musicVolume * 100)}%
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 text-sm font-semibold">音乐音量</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="puzzle-runtime-pill rounded-full px-2 py-1 text-xs">
|
||||||
<div className="mt-4 flex items-center gap-3">
|
{Math.round(musicVolume * 100)}%
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
aria-label="拼图音乐音量"
|
|
||||||
value={Math.round(musicVolume * 100)}
|
|
||||||
onChange={(event) =>
|
|
||||||
onMusicVolumeChange(
|
|
||||||
Number(event.currentTarget.value) / 100,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="h-2 w-full cursor-pointer accent-[var(--puzzle-runtime-accent-text)]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="puzzle-runtime-settings-card rounded-2xl px-4 py-3">
|
<div className="mt-4 flex items-center gap-3">
|
||||||
<div className="puzzle-runtime-dialog__soft text-[10px] uppercase tracking-[0.18em]">
|
<input
|
||||||
本局进度
|
type="range"
|
||||||
</div>
|
min={0}
|
||||||
<div className="puzzle-runtime-dialog__body mt-3 space-y-2 text-sm">
|
max={100}
|
||||||
<div className="flex items-center justify-between gap-3">
|
step={1}
|
||||||
<span className="puzzle-runtime-dialog__soft">关卡</span>
|
aria-label="拼图音乐音量"
|
||||||
<span className="font-semibold">{levelLabel}</span>
|
value={Math.round(musicVolume * 100)}
|
||||||
</div>
|
onChange={(event) =>
|
||||||
<div className="flex items-center justify-between gap-3">
|
onMusicVolumeChange(Number(event.currentTarget.value) / 100)
|
||||||
<span className="puzzle-runtime-dialog__soft">
|
}
|
||||||
已完成关卡
|
className="h-2 w-full cursor-pointer accent-[var(--puzzle-runtime-accent-text)]"
|
||||||
</span>
|
/>
|
||||||
<span className="font-semibold">
|
|
||||||
{run.clearedLevelCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="puzzle-runtime-dialog__soft">
|
|
||||||
当前状态
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold">{statusLabel}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="puzzle-runtime-dialog__soft">
|
|
||||||
当前用时
|
|
||||||
</span>
|
|
||||||
<span className="font-mono font-semibold">
|
|
||||||
{formatElapsedMs(displayElapsedMs)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="puzzle-runtime-dialog__line flex items-center justify-end gap-3 border-t px-4 py-3 sm:px-5">
|
<div className="puzzle-runtime-settings-card rounded-2xl px-4 py-3">
|
||||||
<button
|
<div className="puzzle-runtime-dialog__soft text-[10px] uppercase tracking-[0.18em]">
|
||||||
type="button"
|
本局进度
|
||||||
onClick={() => setIsSettingsPanelOpen(false)}
|
</div>
|
||||||
className="puzzle-runtime-secondary-button rounded-full px-3 py-1.5 text-[11px] transition hover:brightness-105"
|
<div className="puzzle-runtime-dialog__body mt-3 space-y-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="puzzle-runtime-dialog__soft">关卡</span>
|
||||||
|
<span className="font-semibold">{levelLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="puzzle-runtime-dialog__soft">
|
||||||
|
已完成关卡
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{run.clearedLevelCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="puzzle-runtime-dialog__soft">当前状态</span>
|
||||||
|
<span className="font-semibold">{statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="puzzle-runtime-dialog__soft">当前用时</span>
|
||||||
|
<span className="font-mono font-semibold">
|
||||||
|
{formatElapsedMs(displayElapsedMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PuzzleRuntimeDialogFooter className="flex items-center justify-end gap-3 px-4 py-3 sm:px-5">
|
||||||
|
<PuzzleRuntimeDialogButton
|
||||||
|
tone="secondary"
|
||||||
|
onClick={() => setIsSettingsPanelOpen(false)}
|
||||||
|
className="rounded-full px-3 py-1.5 text-[11px] transition hover:brightness-105"
|
||||||
|
>
|
||||||
|
继续拼图
|
||||||
|
</PuzzleRuntimeDialogButton>
|
||||||
|
{!hideExitControls ? (
|
||||||
|
<PuzzleRuntimeDialogButton
|
||||||
|
tone="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsSettingsPanelOpen(false);
|
||||||
|
onBack();
|
||||||
|
}}
|
||||||
|
className={`rounded-full px-4 py-2 text-sm font-bold transition hover:brightness-105 ${
|
||||||
|
shouldHideBackButton ? 'hidden' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
继续拼图
|
返回上一页
|
||||||
</button>
|
</PuzzleRuntimeDialogButton>
|
||||||
{!hideExitControls ? (
|
) : null}
|
||||||
<button
|
</PuzzleRuntimeDialogFooter>
|
||||||
type="button"
|
</PuzzleRuntimeModalShell>
|
||||||
onClick={() => {
|
|
||||||
setIsSettingsPanelOpen(false);
|
|
||||||
onBack();
|
|
||||||
}}
|
|
||||||
className={`puzzle-runtime-primary-button rounded-full px-4 py-2 text-sm font-bold transition hover:brightness-105 ${
|
|
||||||
shouldHideBackButton ? 'hidden' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
返回上一页
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isExitRemodelPromptOpen && !hideExitControls ? (
|
{isExitRemodelPromptOpen && !hideExitControls ? (
|
||||||
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center px-4 py-6 backdrop-blur-md">
|
<PuzzleRuntimeModalShell
|
||||||
<section
|
titleId="puzzle-exit-remodel-title"
|
||||||
role="dialog"
|
overlayClassName="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center px-4 py-6 backdrop-blur-md"
|
||||||
aria-modal="true"
|
dialogClassName="puzzle-runtime-dialog relative flex w-full max-w-[21rem] flex-col overflow-hidden rounded-[1.35rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||||||
aria-labelledby="puzzle-exit-remodel-title"
|
>
|
||||||
className="puzzle-runtime-dialog relative flex w-full max-w-[21rem] flex-col overflow-hidden rounded-[1.35rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-[var(--puzzle-runtime-accent-text)] to-transparent" />
|
||||||
onClick={(event) => event.stopPropagation()}
|
<header className="flex flex-col items-center px-6 pt-7 text-center">
|
||||||
|
<div className="puzzle-runtime-stat-card mb-4 grid h-14 w-14 place-items-center rounded-2xl">
|
||||||
|
<Sparkles className="puzzle-runtime-tool-button__warm h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
id="puzzle-exit-remodel-title"
|
||||||
|
className="text-[1.75rem] font-black leading-[1.08]"
|
||||||
|
>
|
||||||
|
体验不佳?
|
||||||
|
<br />
|
||||||
|
试试改造功能!
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
<PuzzleRuntimeDialogFooter
|
||||||
|
className="grid gap-3 px-5 pb-5 pt-6"
|
||||||
|
framed={false}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-[var(--puzzle-runtime-accent-text)] to-transparent" />
|
<PuzzleRuntimeDialogButton
|
||||||
<header className="flex flex-col items-center px-6 pt-7 text-center">
|
tone="primary"
|
||||||
<div className="puzzle-runtime-stat-card mb-4 grid h-14 w-14 place-items-center rounded-2xl">
|
disabled={isBusy}
|
||||||
<Sparkles className="puzzle-runtime-tool-button__warm h-7 w-7" />
|
onClick={() => {
|
||||||
</div>
|
setIsExitRemodelPromptOpen(false);
|
||||||
<h2
|
void onRemodelWork?.(exitPromptProfileId);
|
||||||
id="puzzle-exit-remodel-title"
|
}}
|
||||||
className="text-[1.75rem] font-black leading-[1.08]"
|
className="min-h-[3.25rem] rounded-2xl px-5 text-sm font-black transition hover:brightness-105 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
体验不佳?
|
作品改造
|
||||||
<br />
|
</PuzzleRuntimeDialogButton>
|
||||||
试试改造功能!
|
<PuzzleRuntimeDialogButton
|
||||||
</h2>
|
tone="secondary"
|
||||||
</header>
|
onClick={() => {
|
||||||
<footer className="grid gap-3 px-5 pb-5 pt-6">
|
setIsExitRemodelPromptOpen(false);
|
||||||
<button
|
onBack();
|
||||||
type="button"
|
}}
|
||||||
disabled={isBusy}
|
className="min-h-[3rem] rounded-2xl px-5 text-sm font-bold transition hover:brightness-105 active:translate-y-px"
|
||||||
onClick={() => {
|
>
|
||||||
setIsExitRemodelPromptOpen(false);
|
保存并退出
|
||||||
void onRemodelWork?.(exitPromptProfileId);
|
</PuzzleRuntimeDialogButton>
|
||||||
}}
|
</PuzzleRuntimeDialogFooter>
|
||||||
className="puzzle-runtime-primary-button min-h-[3.25rem] rounded-2xl px-5 text-sm font-black transition hover:brightness-105 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50"
|
</PuzzleRuntimeModalShell>
|
||||||
>
|
|
||||||
作品改造
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setIsExitRemodelPromptOpen(false);
|
|
||||||
onBack();
|
|
||||||
}}
|
|
||||||
className="puzzle-runtime-secondary-button min-h-[3rem] rounded-2xl px-5 text-sm font-bold transition hover:brightness-105 active:translate-y-px"
|
|
||||||
>
|
|
||||||
保存并退出
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{runtimeStatus === 'failed' ? (
|
{runtimeStatus === 'failed' ? (
|
||||||
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm">
|
<PuzzleRuntimeModalShell
|
||||||
<section
|
titleId="puzzle-failed-title"
|
||||||
role="dialog"
|
overlayClassName="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm"
|
||||||
aria-modal="true"
|
dialogClassName="puzzle-runtime-dialog flex w-full max-w-[24rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||||||
aria-labelledby="puzzle-failed-title"
|
>
|
||||||
className="puzzle-runtime-dialog flex w-full max-w-[24rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
<header className="puzzle-runtime-dialog__line border-b px-5 py-4">
|
||||||
>
|
<h2 id="puzzle-failed-title" className="text-lg font-black">
|
||||||
<header className="puzzle-runtime-dialog__line border-b px-5 py-4">
|
关卡失败
|
||||||
<h2 id="puzzle-failed-title" className="text-lg font-black">
|
</h2>
|
||||||
关卡失败
|
<div className="puzzle-runtime-dialog__soft mt-1 text-xs">
|
||||||
</h2>
|
{currentLevel.levelName}
|
||||||
<div className="puzzle-runtime-dialog__soft mt-1 text-xs">
|
</div>
|
||||||
{currentLevel.levelName}
|
</header>
|
||||||
</div>
|
<PuzzleRuntimeDialogFooter className="grid grid-cols-2 gap-3 px-5 py-4">
|
||||||
</header>
|
<PuzzleRuntimeDialogButton
|
||||||
<footer className="puzzle-runtime-dialog__line grid grid-cols-2 gap-3 border-t px-5 py-4">
|
tone="secondary"
|
||||||
<button
|
disabled={isBusy}
|
||||||
type="button"
|
onClick={() => {
|
||||||
disabled={isBusy}
|
void onRestartLevel?.();
|
||||||
onClick={() => {
|
}}
|
||||||
void onRestartLevel?.();
|
className="rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
|
||||||
}}
|
>
|
||||||
className="puzzle-runtime-secondary-button rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
|
重新开始
|
||||||
>
|
</PuzzleRuntimeDialogButton>
|
||||||
重新开始
|
<PuzzleRuntimeDialogButton
|
||||||
</button>
|
tone="primary"
|
||||||
<button
|
disabled={isBusy}
|
||||||
type="button"
|
onClick={() => openPropDialog('extendTime', '继续1分钟')}
|
||||||
disabled={isBusy}
|
className="rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
|
||||||
onClick={() => openPropDialog('extendTime', '继续1分钟')}
|
>
|
||||||
className="puzzle-runtime-primary-button rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
|
继续1分钟
|
||||||
>
|
</PuzzleRuntimeDialogButton>
|
||||||
继续1分钟
|
</PuzzleRuntimeDialogFooter>
|
||||||
</button>
|
</PuzzleRuntimeModalShell>
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{clearResultLayer}
|
{clearResultLayer}
|
||||||
|
|||||||
Reference in New Issue
Block a user