diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b7d49c34..c35cb343 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -2102,6 +2102,8 @@ - 决策:`PlatformToolModalShell` 继续承接方洞结果页图片槽弹窗;`SquareHoleResultView.tsx` 的封面 / 背景 / 形状 / 洞口图片查看与历史选择弹窗只保留当前图、上传、AI 生成和历史素材选择语义,不再直接维护 `createPortal`、主题 overlay、白底 remap panel、header close 和滚动 body。该弹窗使用 `ariaLabel` 保持“封面图查看 / 背景图查看”等固定可访问名称,历史生成区继续由 `PlatformAssetPickerGrid` 承接读取、错误和空态。 - 决策:`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` 只保留各自表单状态和提交流程。 +- 决策:账号弹窗可以继续复用 `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、标签强度按钮这类带独立业务语义的控件继续保留局部实现。 - 决策:详情页头部动作组合统一收口到 `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`。 diff --git a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md index 7cc7744d..3abdae98 100644 --- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md +++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md @@ -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.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.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.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`。 diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 5257a8ca..5012da2a 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -25,6 +25,7 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { PlatformTextField } from '../common/PlatformTextField'; import type { PlatformSettingsSection } from './AuthUiContext'; import { CaptchaChallengeField } from './CaptchaChallengeField'; +import { PlatformAuthModalShell } from './PlatformAuthModalShell'; type AccountModalProps = { user: AuthUser; @@ -185,6 +186,7 @@ function OverlayPanel({ description, action, standalone = false, + dialog = true, onBack, onClose, children, @@ -194,6 +196,7 @@ function OverlayPanel({ description?: string; action?: ReactNode; standalone?: boolean; + dialog?: boolean; onBack?: () => void; onClose: () => void; children: ReactNode; @@ -201,9 +204,9 @@ function OverlayPanel({ const panel = (
event.stopPropagation()} > @@ -498,23 +501,37 @@ export function AccountModal({ }; return ( -
event.stopPropagation()} > @@ -617,6 +634,7 @@ export function AccountModal({ @@ -1203,6 +1221,6 @@ export function AccountModal({ ) : null}
-
+ ); } diff --git a/src/components/auth/PlatformAuthModalShell.test.tsx b/src/components/auth/PlatformAuthModalShell.test.tsx index 40316eb4..fd566186 100644 --- a/src/components/auth/PlatformAuthModalShell.test.tsx +++ b/src/components/auth/PlatformAuthModalShell.test.tsx @@ -52,3 +52,34 @@ test('keeps escape disabled for auth flows', () => { expect(onClose).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: '取消填写邀请码' })).toBeTruthy(); }); + +test('allows account shell callers to own overlay spacing and panel size', () => { + render( + +
账号内容
+
, + ); + + 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(); +}); diff --git a/src/components/auth/PlatformAuthModalShell.tsx b/src/components/auth/PlatformAuthModalShell.tsx index ec68fbb4..5364639e 100644 --- a/src/components/auth/PlatformAuthModalShell.tsx +++ b/src/components/auth/PlatformAuthModalShell.tsx @@ -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 { UnifiedModal } from '../common/UnifiedModal'; @@ -9,9 +9,16 @@ type PlatformAuthModalShellProps = { onClose: () => void; children: ReactNode; closeLabel: string; + size?: 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; + showHeader?: boolean; + overlaySpacing?: 'default' | 'none'; zIndexClassName?: string; + overlayClassName?: string; + overlayStyle?: CSSProperties; + authCardClassName?: string; panelClassName?: string; bodyClassName?: string; + panelStyle?: CSSProperties; }; function joinClassNames(...classNames: Array) { @@ -28,9 +35,16 @@ export function PlatformAuthModalShell({ onClose, children, closeLabel, + size = 'sm', + showHeader = true, + overlaySpacing = 'default', zIndexClassName = 'z-[120]', + overlayClassName, + overlayStyle, + authCardClassName = 'platform-auth-card !rounded-[2rem] sm:!rounded-[2rem]', panelClassName, bodyClassName = '!p-0', + panelStyle, }: PlatformAuthModalShellProps) { return ( {children} diff --git a/src/components/common/UnifiedModal.tsx b/src/components/common/UnifiedModal.tsx index 54b68db5..6129beab 100644 --- a/src/components/common/UnifiedModal.tsx +++ b/src/components/common/UnifiedModal.tsx @@ -41,6 +41,7 @@ type UnifiedModalProps = { portal?: boolean; zIndexClassName?: string; overlayClassName?: string; + overlayStyle?: CSSProperties; panelClassName?: string; headerClassName?: string; titleClassName?: string; @@ -105,6 +106,7 @@ function UnifiedModalContent({ closeIcon, zIndexClassName = 'z-[90]', overlayClassName, + overlayStyle, panelClassName, headerClassName, titleClassName, @@ -172,6 +174,7 @@ function UnifiedModalContent({ return (
{ if ( closeOnBackdrop && diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index 61734a3d..a5875935 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -424,6 +424,43 @@ test('跳一跳运行态失败后在弹窗中展示排行榜', () => { 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( + , + ); + + 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('跳一跳草稿运行失败后不请求公开排行榜', () => { render( 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 DEFAULT_MAX_DRAG_DISTANCE_PX = 180; const JUMP_HOP_ANIMATION_DURATION_MS = 560; @@ -1179,6 +1192,93 @@ function JumpHopLeaderboardPanel({ ); } +function JumpHopRuntimeSettlementSummary({ + successfulJumpCount, + durationLabel, +}: Pick< + JumpHopRuntimeSettlementDialogProps, + 'successfulJumpCount' | 'durationLabel' +>) { + return ( +
+ {successfulJumpCount} 跳 + {durationLabel} +
+ ); +} + +function JumpHopRuntimeSettlementActions({ + isBusy, + onRestart, + onExit, +}: Pick< + JumpHopRuntimeSettlementDialogProps, + 'isBusy' | 'onRestart' | 'onExit' +>) { + return ( +
+ + 重开 + + + 返回 + +
+ ); +} + +function JumpHopRuntimeSettlementDialog({ + status, + successfulJumpCount, + durationLabel, + isBusy, + showLeaderboard, + profileId, + runtimeRequestOptions, + onRestart, + onExit, +}: JumpHopRuntimeSettlementDialogProps) { + const title = getJumpHopStatusLabel(status); + + return ( +
+
+
+ {title} +
+ + {showLeaderboard ? ( + + ) : null} + +
+
+ ); +} + export function JumpHopRuntimeShell({ profile = null, run, @@ -1489,10 +1589,12 @@ export function JumpHopRuntimeShell({ visualJump, ]); const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun); - const isSettled = - stageRun?.status === 'failed' || stageRun?.status === 'cleared'; + const settlementStatus = + stageRun?.status === 'failed' || stageRun?.status === 'cleared' + ? stageRun.status + : null; const shouldShowFailureLeaderboard = - stageRun?.status === 'failed' && + settlementStatus === 'failed' && profile?.summary.publicationStatus === 'published'; const successfulJumpCount = stageRun?.successfulJumpCount ?? 0; const durationLabel = formatJumpHopDurationLabel( @@ -2279,47 +2381,18 @@ export function JumpHopRuntimeShell({
) : null} - {isSettled ? ( -
-
-
- - {getJumpHopStatusLabel(stageRun?.status)} - -
-
- {successfulJumpCount} 跳 - {durationLabel} -
- {shouldShowFailureLeaderboard ? ( - - ) : null} -
- - 重开 - - - 返回 - -
-
-
+ {settlementStatus ? ( + ) : null} diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index f30c49ea..665ea613 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -1,6 +1,13 @@ /* @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 { afterEach, expect, test, vi } from 'vitest'; @@ -300,6 +307,65 @@ test('推荐页抓大鹅运行态隐藏返回按钮和结算返回入口', () => 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( + , + ); + + 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( + , + ); + + expect(screen.getByRole('dialog', { name: '本轮失败' })).toBeTruthy(); + expect(screen.getByText('已清除 3/12')).toBeTruthy(); +}); + test('显示层把可消除物整体半径放大 2 倍且保留相对比例', () => { const run = startLocalMatch3DRun(21); const firstItemByType = [ diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.tsx index 2ddef563..b2b0b780 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.tsx @@ -7,6 +7,7 @@ import { import { type CSSProperties, type PointerEvent, + type ReactNode, useCallback, useEffect, useLayoutEffect, @@ -923,21 +924,69 @@ function Match3DSettlement({ ? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}` : `已清除 ${run.clearedItemCount}/${run.totalItemCount}`; return ( -
+ : } + footer={ + + } + 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 ( +

{title}

@@ -946,29 +995,59 @@ function Match3DSettlement({

-
- {!hideBackButton ? ( - - ) : null} - -
+ {footer}
); } +function Match3DSettlementActions({ + hideBackButton, + onBack, + onRestart, +}: { + hideBackButton?: boolean; + onBack: () => void; + onRestart: () => void; +}) { + return ( +
+ {!hideBackButton ? ( + + 返回 + + ) : null} + + 再来一局 + +
+ ); +} + +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 ( + + ); +} + export function Match3DRuntimeShell({ run, generatedItemAssets = EMPTY_MATCH3D_GENERATED_ITEM_ASSETS, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx index 66f6129d..e8170da1 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx @@ -1,6 +1,12 @@ /* @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 { @@ -57,6 +63,7 @@ test('PuzzleOnboardingView uses shared dark textarea and error status chrome', ( expect(screen.getByText('拼图生成失败').className).toContain( 'border-rose-300/15', ); + expect(screen.queryByRole('dialog')).toBeNull(); }); 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: '注册账号 / 登录' })); expect(onLogin).toHaveBeenCalledTimes(1); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx index 44beecfe..301988ff 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx @@ -3,6 +3,7 @@ import { Loader2, Sparkles } from 'lucide-react'; import { PlatformActionButton } from '../../common/PlatformActionButton'; import { PlatformStatusMessage } from '../../common/PlatformStatusMessage'; import { PlatformTextField } from '../../common/PlatformTextField'; +import { UnifiedModal } from '../../common/UnifiedModal'; export type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated'; @@ -122,45 +123,57 @@ export function PuzzleOnboardingLoginOverlay({ onLogin, }: PuzzleOnboardingLoginOverlayProps) { return ( -
-
-
- {isSaving ? ( - - ) : ( - - )} -
-

{copy}

- undefined} + portal={false} + showHeader={false} + showCloseButton={false} + closeOnBackdrop={false} + closeOnEscape={false} + size="sm" + zIndexClassName="z-[110]" + 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" + > +
+ {isSaving ? ( + + ) : ( + + )} +
+

{copy}

+ + {isSaving ? ( + <> + + 注册账号 / 登录 + + ) : ( + '注册账号 / 登录' + )} + + {error ? ( + - {isSaving ? ( - <> - - 注册账号 / 登录 - - ) : ( - '注册账号 / 登录' - )} -
- {error ? ( - - {error} - - ) : null} -
-
+ {error} + + ) : null} + ); } diff --git a/src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx new file mode 100644 index 00000000..15431bd1 --- /dev/null +++ b/src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx @@ -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 & { + 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) => { + event.stopPropagation(); + }; + + return ( +
onOverlayClick() : undefined} + > +
+ {children} +
+
+ ); +} + +export function PuzzleRuntimeDialogFooter({ + children, + className, + framed = true, +}: PuzzleRuntimeDialogFooterProps) { + return ( +
+ {children} +
+ ); +} + +export function PuzzleRuntimeDialogButton({ + tone, + className = '', + type = 'button', + ...buttonProps +}: PuzzleRuntimeDialogButtonProps) { + const toneClassName = + tone === 'primary' + ? 'puzzle-runtime-primary-button' + : 'puzzle-runtime-secondary-button'; + + return ( + +
@@ -1493,7 +1497,7 @@ export function PuzzleRuntimeShell({
{canAdvanceDefaultNextLevel ? ( -
+ -
+ ) : null} - -
+ ) : null; const clearResultLayer = embedded && clearResultDialog && typeof document !== 'undefined' @@ -2174,290 +2177,261 @@ export function PuzzleRuntimeShell({ ) : null} {propDialog ? ( -
{ + { if (!isPropConfirming) { setPropDialog(null); } }} > -
event.stopPropagation()} - > -
- - - -

+ + + +

+ {propDialog.title} +

+
+
+ 消耗 1 泥点 + {propConfirmError ? ( + - {propDialog.title} - - -
- 消耗 1 泥点 - {propConfirmError ? ( - - {propConfirmError} - + {propConfirmError} + + ) : null} +
+ + setPropDialog(null)} + disabled={isPropConfirming} + className="rounded-full px-4 py-2 text-xs font-bold transition hover:brightness-105" + > + 取消 + + { + 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 ? ( + ) : null} -
-
- - -
-
-
+ 确定 + + + ) : null} {isSettingsPanelOpen ? ( -
setIsSettingsPanelOpen(false)} + setIsSettingsPanelOpen(false)} > -
event.stopPropagation()} - > -
-
-

- 拼图设置 -

-
- {hideExitControls - ? '调整音乐音量,查看本局进度。' - : '调整音乐音量,查看本局进度,或返回上一页。'} -
+
+
+

+ 拼图设置 +

+
+ {hideExitControls + ? '调整音乐音量,查看本局进度。' + : '调整音乐音量,查看本局进度,或返回上一页。'}
- -
+
+ +
-
-
-
-
-
- 音频 -
-
音乐音量
-
-
- {Math.round(musicVolume * 100)}% +
+
+
+
+
+ 音频
+
音乐音量
- -
- - onMusicVolumeChange( - Number(event.currentTarget.value) / 100, - ) - } - className="h-2 w-full cursor-pointer accent-[var(--puzzle-runtime-accent-text)]" - /> +
+ {Math.round(musicVolume * 100)}%
-
-
- 本局进度 -
-
-
- 关卡 - {levelLabel} -
-
- - 已完成关卡 - - - {run.clearedLevelCount} - -
-
- - 当前状态 - - {statusLabel} -
-
- - 当前用时 - - - {formatElapsedMs(displayElapsedMs)} - -
-
+
+ + onMusicVolumeChange(Number(event.currentTarget.value) / 100) + } + className="h-2 w-full cursor-pointer accent-[var(--puzzle-runtime-accent-text)]" + />
-
-
+ + + setIsSettingsPanelOpen(false)} + className="rounded-full px-3 py-1.5 text-[11px] transition hover:brightness-105" + > + 继续拼图 + + {!hideExitControls ? ( + { + setIsSettingsPanelOpen(false); + onBack(); + }} + className={`rounded-full px-4 py-2 text-sm font-bold transition hover:brightness-105 ${ + shouldHideBackButton ? 'hidden' : '' + }`} > - 继续拼图 - - {!hideExitControls ? ( - - ) : null} - -
-
+ 返回上一页 + + ) : null} + + ) : null} {isExitRemodelPromptOpen && !hideExitControls ? ( -
-
event.stopPropagation()} + +
+
+
+ +
+

+ 体验不佳? +
+ 试试改造功能! +

+
+ -
-
-
- -
-

- 体验不佳? -
- 试试改造功能! -

-
-
- - -
-
-
+ { + setIsExitRemodelPromptOpen(false); + void onRemodelWork?.(exitPromptProfileId); + }} + 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" + > + 作品改造 + + { + setIsExitRemodelPromptOpen(false); + onBack(); + }} + className="min-h-[3rem] rounded-2xl px-5 text-sm font-bold transition hover:brightness-105 active:translate-y-px" + > + 保存并退出 + + + ) : null} {runtimeStatus === 'failed' ? ( -
-
-
-

- 关卡失败 -

-
- {currentLevel.levelName} -
-
-
- - -
-
-
+ +
+

+ 关卡失败 +

+
+ {currentLevel.levelName} +
+
+ + { + void onRestartLevel?.(); + }} + className="rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50" + > + 重新开始 + + openPropDialog('extendTime', '继续1分钟')} + className="rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50" + > + 继续1分钟 + + +
) : null} {clearResultLayer}