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

账号设置弹窗复用认证壳层并保留 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

@@ -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 = (
<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"
role="dialog"
aria-modal="true"
aria-label={title}
role={dialog ? 'dialog' : undefined}
aria-modal={dialog ? true : undefined}
aria-label={dialog ? title : undefined}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
@@ -498,23 +501,37 @@ export function AccountModal({
};
return (
<div
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`}
style={{
<PlatformAuthModalShell
title={isDirectAccountMode ? '账号信息' : '设置与账号安全'}
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)',
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
className={
isDirectAccountMode
? '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 }}
onClick={(event) => event.stopPropagation()}
>
@@ -617,6 +634,7 @@ export function AccountModal({
<OverlayPanel
title="账号信息"
standalone={isDirectAccountMode}
dialog={!isDirectAccountMode}
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
onClose={onClose}
>
@@ -1203,6 +1221,6 @@ export function AccountModal({
</OverlayPanel>
) : null}
</div>
</div>
</PlatformAuthModalShell>
);
}

View File

@@ -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(
<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 { 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<string | false | null | undefined>) {
@@ -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 (
<UnifiedModal
@@ -42,16 +56,23 @@ export function PlatformAuthModalShell({
closeOnBackdrop
closeOnEscape={false}
portal={false}
size="sm"
size={size}
showHeader={showHeader}
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(
'platform-auth-card !rounded-[2rem] sm:!rounded-[2rem]',
authCardClassName,
panelClassName,
)}
headerClassName="!items-center !px-5 !py-4"
titleClassName="text-lg font-semibold text-[var(--platform-text-strong)]"
bodyClassName={bodyClassName}
panelStyle={panelStyle}
>
{children}
</UnifiedModal>

View File

@@ -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 (
<div
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
style={overlayStyle}
onClick={(event) => {
if (
closeOnBackdrop &&

View File

@@ -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(
<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('跳一跳草稿运行失败后不请求公开排行榜', () => {
render(
<JumpHopRuntimeShell

View File

@@ -13,6 +13,7 @@ import {
import jumpHopRuntimeLevelLogo from '../../../media/logo.png';
import type {
JumpHopRunStatus,
JumpHopRuntimeRunSnapshotResponse,
JumpHopTileAsset,
JumpHopTileFaceAsset,
@@ -88,6 +89,18 @@ type JumpHopRuntimeShellProps = {
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 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 (
<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({
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({
</div>
) : null}
{isSettled ? (
<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">
{getJumpHopStatusLabel(stageRun?.status)}
</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>
{settlementStatus ? (
<JumpHopRuntimeSettlementDialog
status={settlementStatus}
successfulJumpCount={successfulJumpCount}
durationLabel={durationLabel}
isBusy={isBusy}
showLeaderboard={shouldShowFailureLeaderboard}
profileId={profile?.summary.profileId}
runtimeRequestOptions={runtimeRequestOptions}
onRestart={onRestart}
onExit={exitHandler}
/>
) : null}
</section>

View File

@@ -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(
<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 倍且保留相对比例', () => {
const run = startLocalMatch3DRun(21);
const firstItemByType = [

View File

@@ -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 (
<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
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"
aria-modal="true"
aria-label={title}
data-testid={testId}
>
<div className="mb-4 flex items-center gap-3">
<span
className={`flex h-11 w-11 items-center justify-center rounded-full ${
won
? 'bg-emerald-100 text-emerald-700'
: 'bg-rose-100 text-rose-700'
}`}
className={`flex h-11 w-11 items-center justify-center rounded-full ${MATCH3D_RUNTIME_RESULT_ICON_CLASS[tone]}`}
aria-hidden="true"
>
{won ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
{icon}
</span>
<div>
<h2 className="text-xl font-black">{title}</h2>
@@ -946,29 +995,59 @@ function Match3DSettlement({
</p>
</div>
</div>
<div className={`grid gap-2 ${hideBackButton ? '' : 'grid-cols-2'}`}>
{!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>
{footer}
</section>
</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({
run,
generatedItemAssets = EMPTY_MATCH3D_GENERATED_ITEM_ASSETS,

View File

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

View File

@@ -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 (
<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">
<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)]">
<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"
<UnifiedModal
open
title={copy}
onClose={() => 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"
>
<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"
tone="accent"
size="lg"
fullWidth
disabled={isSaving}
onClick={onLogin}
size="md"
className="w-full font-semibold"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
/
</>
) : (
'注册账号 / 登录'
)}
</PlatformActionButton>
{error ? (
<PlatformStatusMessage
tone="error"
surface="editorDark"
size="md"
className="w-full font-semibold"
>
{error}
</PlatformStatusMessage>
) : null}
</section>
</div>
{error}
</PlatformStatusMessage>
) : null}
</UnifiedModal>
);
}

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 { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
PuzzleRuntimeDialogButton,
PuzzleRuntimeDialogFooter,
PuzzleRuntimeModalShell,
} from './PuzzleRuntimeModalShell';
import {
buildMergedGroupOutlinePath,
buildRoundedGridCellClipPath,
@@ -1372,13 +1377,11 @@ export function PuzzleRuntimeShell({
};
const clearResultDialog = isClearResultOpen ? (
<div className={clearResultOverlayClassName}>
<section
role="dialog"
aria-modal="true"
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)]"
>
<PuzzleRuntimeModalShell
titleId="puzzle-clear-result-title"
overlayClassName={clearResultOverlayClassName}
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)]"
>
<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="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}
</div>
</div>
<button
<PuzzleRuntimeDialogButton
type="button"
tone="secondary"
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={() => {
setDismissedClearKey(clearResultKey);
}}
>
<X className="h-4 w-4" />
</button>
</PuzzleRuntimeDialogButton>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
@@ -1493,7 +1497,7 @@ export function PuzzleRuntimeShell({
</div>
{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
type="button"
aria-label="下一关"
@@ -1523,10 +1527,9 @@ export function PuzzleRuntimeShell({
</>
)}
</button>
</footer>
</PuzzleRuntimeDialogFooter>
) : null}
</section>
</div>
</PuzzleRuntimeModalShell>
) : null;
const clearResultLayer =
embedded && clearResultDialog && typeof document !== 'undefined'
@@ -2174,290 +2177,261 @@ export function PuzzleRuntimeShell({
) : null}
{propDialog ? (
<div
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
onClick={() => {
<PuzzleRuntimeModalShell
titleId="puzzle-prop-confirm-title"
onOverlayClick={() => {
if (!isPropConfirming) {
setPropDialog(null);
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-prop-confirm-title"
className="puzzle-runtime-dialog w-full max-w-[22rem] overflow-hidden rounded-[1.35rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
onClick={(event) => event.stopPropagation()}
>
<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">
<Sparkles className="h-4 w-4" />
</span>
<h2
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">
<Sparkles className="h-4 w-4" />
</span>
<h2
id="puzzle-prop-confirm-title"
className="text-sm font-black"
>
{propDialog.title}
</h2>
</header>
<div className="puzzle-runtime-dialog__body px-5 py-4 text-sm">
消耗 1 泥点
{propConfirmError ? (
<PlatformRuntimeStatusToast
tone="error"
size="xs"
shape="rounded"
className="mt-3"
>
{propDialog.title}
</h2>
</header>
<div className="puzzle-runtime-dialog__body px-5 py-4 text-sm">
消耗 1 泥点
{propConfirmError ? (
<PlatformRuntimeStatusToast
tone="error"
size="xs"
shape="rounded"
className="mt-3"
>
{propConfirmError}
</PlatformRuntimeStatusToast>
{propConfirmError}
</PlatformRuntimeStatusToast>
) : null}
</div>
<PuzzleRuntimeDialogFooter className="flex items-center justify-end gap-3 px-5 py-4">
<PuzzleRuntimeDialogButton
tone="secondary"
onClick={() => setPropDialog(null)}
disabled={isPropConfirming}
className="rounded-full px-4 py-2 text-xs font-bold transition hover:brightness-105"
>
取消
</PuzzleRuntimeDialogButton>
<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}
</div>
<footer className="puzzle-runtime-dialog__line flex items-center justify-end gap-3 border-t px-5 py-4">
<button
type="button"
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>
确定
</PuzzleRuntimeDialogButton>
</PuzzleRuntimeDialogFooter>
</PuzzleRuntimeModalShell>
) : null}
{isSettingsPanelOpen ? (
<div
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-3 backdrop-blur-sm sm:p-4"
onClick={() => setIsSettingsPanelOpen(false)}
<PuzzleRuntimeModalShell
titleId="puzzle-settings-title"
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
role="dialog"
aria-modal="true"
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)]"
onClick={(event) => event.stopPropagation()}
>
<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>
<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>
<button
type="button"
aria-label="关闭拼图设置"
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>
</header>
</div>
<button
type="button"
aria-label="关闭拼图设置"
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>
</header>
<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="flex items-start justify-between gap-3">
<div>
<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 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="flex items-start justify-between gap-3">
<div>
<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="mt-4 flex items-center gap-3">
<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 className="puzzle-runtime-pill rounded-full px-2 py-1 text-xs">
{Math.round(musicVolume * 100)}%
</div>
</div>
<div className="puzzle-runtime-settings-card rounded-2xl px-4 py-3">
<div className="puzzle-runtime-dialog__soft text-[10px] uppercase tracking-[0.18em]">
本局进度
</div>
<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 className="mt-4 flex items-center gap-3">
<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>
<footer className="puzzle-runtime-dialog__line flex items-center justify-end gap-3 border-t px-4 py-3 sm:px-5">
<button
type="button"
onClick={() => setIsSettingsPanelOpen(false)}
className="puzzle-runtime-secondary-button rounded-full px-3 py-1.5 text-[11px] transition hover:brightness-105"
<div className="puzzle-runtime-settings-card rounded-2xl px-4 py-3">
<div className="puzzle-runtime-dialog__soft text-[10px] uppercase tracking-[0.18em]">
本局进度
</div>
<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>
{!hideExitControls ? (
<button
type="button"
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>
返回上一页
</PuzzleRuntimeDialogButton>
) : null}
</PuzzleRuntimeDialogFooter>
</PuzzleRuntimeModalShell>
) : null}
{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">
<section
role="dialog"
aria-modal="true"
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)]"
onClick={(event) => event.stopPropagation()}
<PuzzleRuntimeModalShell
titleId="puzzle-exit-remodel-title"
overlayClassName="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center px-4 py-6 backdrop-blur-md"
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)]"
>
<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" />
<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" />
<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>
<footer className="grid gap-3 px-5 pb-5 pt-6">
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsExitRemodelPromptOpen(false);
void onRemodelWork?.(exitPromptProfileId);
}}
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"
>
作品改造
</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>
<PuzzleRuntimeDialogButton
tone="primary"
disabled={isBusy}
onClick={() => {
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"
>
作品改造
</PuzzleRuntimeDialogButton>
<PuzzleRuntimeDialogButton
tone="secondary"
onClick={() => {
setIsExitRemodelPromptOpen(false);
onBack();
}}
className="min-h-[3rem] rounded-2xl px-5 text-sm font-bold transition hover:brightness-105 active:translate-y-px"
>
保存并退出
</PuzzleRuntimeDialogButton>
</PuzzleRuntimeDialogFooter>
</PuzzleRuntimeModalShell>
) : null}
{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">
<section
role="dialog"
aria-modal="true"
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">
关卡失败
</h2>
<div className="puzzle-runtime-dialog__soft mt-1 text-xs">
{currentLevel.levelName}
</div>
</header>
<footer className="puzzle-runtime-dialog__line grid grid-cols-2 gap-3 border-t px-5 py-4">
<button
type="button"
disabled={isBusy}
onClick={() => {
void onRestartLevel?.();
}}
className="puzzle-runtime-secondary-button rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
>
重新开始
</button>
<button
type="button"
disabled={isBusy}
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分钟
</button>
</footer>
</section>
</div>
<PuzzleRuntimeModalShell
titleId="puzzle-failed-title"
overlayClassName="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm"
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)]"
>
<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">
{currentLevel.levelName}
</div>
</header>
<PuzzleRuntimeDialogFooter className="grid grid-cols-2 gap-3 px-5 py-4">
<PuzzleRuntimeDialogButton
tone="secondary"
disabled={isBusy}
onClick={() => {
void onRestartLevel?.();
}}
className="rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
>
重新开始
</PuzzleRuntimeDialogButton>
<PuzzleRuntimeDialogButton
tone="primary"
disabled={isBusy}
onClick={() => openPropDialog('extendTime', '继续1分钟')}
className="rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
>
继续1分钟
</PuzzleRuntimeDialogButton>
</PuzzleRuntimeDialogFooter>
</PuzzleRuntimeModalShell>
) : null}
{clearResultLayer}