From f7404a07ef9db6c9b44e9e01f8fd7fad3b9760e6 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 11 Jun 2026 22:35:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E6=8B=BC=E6=B6=88=E6=B6=88?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=80=81=E7=AD=89=E5=BE=85=E5=B1=82=E4=B8=8E?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提取 PuzzleClearRuntimeShell 内部 overlay 薄壳并统一等待层与结算层结构 补充等待层与结算弹层语义断言测试 --- .../PuzzleClearRuntimeShell.test.tsx | 21 +++ .../PuzzleClearRuntimeShell.tsx | 170 ++++++++++++++---- 2 files changed, 156 insertions(+), 35 deletions(-) diff --git a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx index 3fbf02e1..66252d06 100644 --- a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx +++ b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx @@ -296,6 +296,25 @@ test('翻牌只在开局阶段出现,交换后的新卡不会重新翻转', as ); }); +test('未拿到 active run 时显示本地等待层薄壳', () => { + render( + , + ); + + const pendingOverlay = screen.getByTestId( + 'puzzle-clear-runtime-pending-overlay', + ); + expect(pendingOverlay.getAttribute('role')).toBe('status'); + expect(pendingOverlay.textContent).toContain('等待开局'); +}); + test('指针拖拽到另一张卡片时提交后端交换动作', async () => { const onSwapCards = vi.fn().mockResolvedValue(undefined); @@ -1148,6 +1167,7 @@ test('关卡结算弹层提供下一关和失败重试动作', () => { />, ); + expect(screen.getByRole('dialog', { name: '本关完成' })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: /下一关/u })); expect(onNextLevel).toHaveBeenCalledTimes(1); @@ -1162,6 +1182,7 @@ test('关卡结算弹层提供下一关和失败重试动作', () => { />, ); + expect(screen.getByRole('dialog', { name: '本关失败' })).toBeTruthy(); fireEvent.click(screen.getAllByRole('button', { name: '重试' }).at(-1)!); expect(onRetryLevel).toHaveBeenCalledTimes(1); }); diff --git a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx index 558a82a5..a6dab42d 100644 --- a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx +++ b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx @@ -1,6 +1,7 @@ import { ArrowLeft, ChevronRight, RotateCcw } from 'lucide-react'; import { type CSSProperties, + type ReactNode, type PointerEvent as ReactPointerEvent, useEffect, useMemo, @@ -122,6 +123,28 @@ type PuzzleClearMergeHighlightState = { type PuzzleClearLockedGroupViewModel = PuzzleClearDragGroupState; +type PuzzleClearRuntimeOverlayShellProps = { + backdropClassName: string; + panelClassName: string; + children: ReactNode; + panelRole?: 'dialog' | 'status'; + panelLabelledBy?: string; + panelLiveMode?: 'polite' | 'assertive'; + panelTestId?: string; +}; + +type PuzzleClearRuntimeSettlementStatus = Extract< + PuzzleClearRuntimeSnapshotResponse['status'], + 'level_cleared' | 'finished' | 'level_failed' +>; + +type PuzzleClearRuntimeSettlementDialogProps = { + status: PuzzleClearRuntimeSettlementStatus; + isBusy: boolean; + onRetryLevel: () => void; + onPrimaryAction?: () => void; +}; + const PUZZLE_CLEAR_CLEAR_TRANSITION_MS = 1120; const PUZZLE_CLEAR_REFILL_DROP_DELAY_MS = 520; const PUZZLE_CLEAR_DROP_STAGGER_MAX_MS = 180; @@ -240,6 +263,100 @@ function getCardImageStyle(card: PuzzleClearCardAsset | null) { } as CSSProperties; } +function getPuzzleClearSettlementTitle( + status: PuzzleClearRuntimeSettlementStatus, +) { + if (status === 'level_failed') { + return '本关失败'; + } + if (status === 'finished') { + return '已完成'; + } + return '本关完成'; +} + +// 运行态 overlay 先在玩法目录内做薄壳收口,保留拼消消自己的视觉和交互语义。 +function PuzzleClearRuntimeOverlayShell({ + backdropClassName, + panelClassName, + children, + panelRole, + panelLabelledBy, + panelLiveMode, + panelTestId, +}: PuzzleClearRuntimeOverlayShellProps) { + return ( +
+
+ {children} +
+
+ ); +} + +function PuzzleClearRuntimePendingOverlay() { + return ( + + 等待开局 + + ); +} + +function PuzzleClearRuntimeSettlementDialog({ + status, + isBusy, + onRetryLevel, + onPrimaryAction, +}: PuzzleClearRuntimeSettlementDialogProps) { + const isExitAction = status === 'finished' || status === 'level_failed'; + const title = getPuzzleClearSettlementTitle(status); + + return ( + +
+ {title} +
+
+ + 重试 + + + {isExitAction ? '返回' : '下一关'} + {!isExitAction ? : null} + +
+
+ ); +} + function readPointerClientPoint(event: ReactPointerEvent) { return { x: Number.isFinite(event.clientX) ? event.clientX : 0, @@ -568,6 +685,14 @@ export function PuzzleClearRuntimeShell({ const hasWonLevel = activeRun?.status === 'level_cleared'; const hasFinished = activeRun?.status === 'finished'; const hasFailed = activeRun?.status === 'level_failed'; + const settlementStatus: PuzzleClearRuntimeSettlementStatus | null = + hasFailed + ? 'level_failed' + : hasFinished + ? 'finished' + : hasWonLevel + ? 'level_cleared' + : null; const clearRatio = activeRun ? Math.min(1, activeRun.clearsDone / Math.max(1, activeRun.targetClears)) : 0; @@ -1467,42 +1592,17 @@ export function PuzzleClearRuntimeShell({ ) : null} - {!activeRun ? ( -
-
- 等待开局 -
-
- ) : null} + {!activeRun ? : null} - {hasWonLevel || hasFinished || hasFailed ? ( -
-
-
- {hasFailed ? '本关失败' : hasFinished ? '已完成' : '本关完成'} -
-
- - 重试 - - - {hasFinished || hasFailed ? '返回' : '下一关'} - {!hasFinished && !hasFailed ? ( - - ) : null} - -
-
-
+ {settlementStatus ? ( + ) : null} );