继续收口账号与运行态弹窗壳层
账号设置弹窗复用认证壳层并保留 direct mode 唯一 dialog 语义 拼图运行态新增本地弹窗壳层收口确认设置退出失败和通关结算 抓大鹅与跳一跳结算弹窗提取本地结算结构并补测试 拼图 onboarding 登录保存覆盖层迁入 UnifiedModal 更新 PlatformUiKit 收口文档和 Hermes 决策记录
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
93
src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx
Normal file
93
src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
type PuzzleRuntimeModalShellProps = {
|
||||
titleId: string;
|
||||
children: ReactNode;
|
||||
overlayClassName?: string;
|
||||
dialogClassName?: string;
|
||||
onOverlayClick?: () => void;
|
||||
};
|
||||
|
||||
type PuzzleRuntimeDialogFooterProps = {
|
||||
children: ReactNode;
|
||||
className: string;
|
||||
framed?: boolean;
|
||||
};
|
||||
|
||||
type PuzzleRuntimeDialogButtonProps =
|
||||
ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
tone: 'primary' | 'secondary';
|
||||
};
|
||||
|
||||
const DEFAULT_OVERLAY_CLASS_NAME =
|
||||
'puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm';
|
||||
|
||||
const DEFAULT_DIALOG_CLASS_NAME =
|
||||
'puzzle-runtime-dialog w-full max-w-[22rem] overflow-hidden rounded-[1.35rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]';
|
||||
|
||||
export function PuzzleRuntimeModalShell({
|
||||
titleId,
|
||||
children,
|
||||
overlayClassName = DEFAULT_OVERLAY_CLASS_NAME,
|
||||
dialogClassName = DEFAULT_DIALOG_CLASS_NAME,
|
||||
onOverlayClick,
|
||||
}: PuzzleRuntimeModalShellProps) {
|
||||
const handleDialogClick = (event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={overlayClassName}
|
||||
onClick={onOverlayClick ? () => onOverlayClick() : undefined}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className={dialogClassName}
|
||||
onClick={handleDialogClick}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PuzzleRuntimeDialogFooter({
|
||||
children,
|
||||
className,
|
||||
framed = true,
|
||||
}: PuzzleRuntimeDialogFooterProps) {
|
||||
return (
|
||||
<footer
|
||||
className={`${framed ? 'puzzle-runtime-dialog__line border-t ' : ''}${className}`}
|
||||
>
|
||||
{children}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function PuzzleRuntimeDialogButton({
|
||||
tone,
|
||||
className = '',
|
||||
type = 'button',
|
||||
...buttonProps
|
||||
}: PuzzleRuntimeDialogButtonProps) {
|
||||
const toneClassName =
|
||||
tone === 'primary'
|
||||
? 'puzzle-runtime-primary-button'
|
||||
: 'puzzle-runtime-secondary-button';
|
||||
|
||||
return (
|
||||
<button
|
||||
{...buttonProps}
|
||||
type={type}
|
||||
className={`${toneClassName} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,11 @@ import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
|
||||
import { 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}
|
||||
|
||||
Reference in New Issue
Block a user