继续收口账号与运行态弹窗壳层

账号设置弹窗复用认证壳层并保留 direct mode 唯一 dialog 语义

拼图运行态新增本地弹窗壳层收口确认设置退出失败和通关结算

抓大鹅与跳一跳结算弹窗提取本地结算结构并补测试

拼图 onboarding 登录保存覆盖层迁入 UnifiedModal

更新 PlatformUiKit 收口文档和 Hermes 决策记录
This commit is contained in:
2026-06-11 21:53:10 +08:00
parent 59facaf14b
commit ed2c386603
14 changed files with 821 additions and 394 deletions

View File

@@ -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`

View File

@@ -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`

View File

@@ -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>
); );
} }

View File

@@ -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();
});

View File

@@ -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>

View File

@@ -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 &&

View File

@@ -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

View File

@@ -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>

View File

@@ -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 = [

View File

@@ -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,

View File

@@ -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);

View File

@@ -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>
); );
} }

View 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}`}
/>
);
}

View File

@@ -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}