Merge remote-tracking branch 'origin/master' into codex/editor-asset-library

# Conflicts:
#	server-rs/crates/spacetime-client/src/lib.rs
#	server-rs/crates/spacetime-client/src/mapper.rs
#	server-rs/crates/spacetime-client/src/module_bindings.rs
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
2026-06-13 16:52:03 +08:00
464 changed files with 51434 additions and 13822 deletions

View File

@@ -0,0 +1,49 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import {
PlatformDraftGenerationPointNoticeDialog,
} from './PlatformDraftGenerationPointNoticeDialog';
test('renders the insufficient-points notice with the shared blocking copy', () => {
const onClose = vi.fn();
render(
<PlatformDraftGenerationPointNoticeDialog
notice={{
kind: 'insufficient-points',
requiredPoints: 30,
currentPoints: 12,
}}
onClose={onClose}
/>,
);
expect(screen.getByRole('dialog', { name: '泥点不足' })).toBeTruthy();
expect(screen.getByText('本次需要 30 泥点,当前 12 泥点。')).toBeTruthy();
expect(
screen.getByText('当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。'),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('renders the balance-load-failed notice without the amber icon override', () => {
render(
<PlatformDraftGenerationPointNoticeDialog
notice={{ kind: 'balance-load-failed' }}
onClose={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: '读取泥点余额失败' });
expect(screen.getByText('请稍后重试。')).toBeTruthy();
expect(
screen.getByText('当前表单不会丢失,关闭后可继续编辑,稍后再试。'),
).toBeTruthy();
expect(dialog.innerHTML).not.toContain('bg-amber-100/80');
});

View File

@@ -0,0 +1,79 @@
import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog';
export type DraftGenerationPointNotice =
| {
kind: 'insufficient-points';
requiredPoints: number;
currentPoints: number;
}
| {
kind: 'balance-load-failed';
};
type PlatformDraftGenerationPointNoticeDialogProps = {
notice: DraftGenerationPointNotice | null;
onClose: () => void;
overlayClassName?: string;
panelClassName?: string;
zIndexClassName?: string;
};
function resolveDraftGenerationPointNoticeTitle(
notice: DraftGenerationPointNotice,
) {
return notice.kind === 'balance-load-failed' ? '读取泥点余额失败' : '泥点不足';
}
function resolveDraftGenerationPointNoticeDescription(
notice: DraftGenerationPointNotice,
) {
return notice.kind === 'balance-load-failed'
? '当前表单不会丢失,关闭后可继续编辑,稍后再试。'
: '当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。';
}
function resolveDraftGenerationPointNoticeMessage(
notice: DraftGenerationPointNotice,
) {
return notice.kind === 'balance-load-failed'
? '请稍后重试。'
: `本次需要 ${notice.requiredPoints} 泥点,当前 ${notice.currentPoints} 泥点。`;
}
/**
* 创作前置泥点提示弹层。
* 只承接平台入口里“泥点不足 / 读取余额失败”这类阻断提示,避免 FlowShell 直接拼底层状态弹窗。
*/
export function PlatformDraftGenerationPointNoticeDialog({
notice,
onClose,
overlayClassName,
panelClassName,
zIndexClassName,
}: PlatformDraftGenerationPointNoticeDialogProps) {
if (!notice) {
return null;
}
return (
<PlatformAcknowledgeStatusDialog
status="error"
title={resolveDraftGenerationPointNoticeTitle(notice)}
description={resolveDraftGenerationPointNoticeDescription(notice)}
onClose={onClose}
showHeader
showCloseButton
closeOnBackdrop
overlayClassName={overlayClassName}
panelClassName={panelClassName}
zIndexClassName={zIndexClassName}
iconClassName={
notice.kind === 'balance-load-failed'
? undefined
: 'bg-amber-100/80 text-amber-600'
}
>
{resolveDraftGenerationPointNoticeMessage(notice)}
</PlatformAcknowledgeStatusDialog>
);
}

View File

@@ -68,7 +68,109 @@ test('dispatches wooden fish creation type selection', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
const woodenFishCard = screen.getByRole('button', { name: //u });
expect(woodenFishCard.className).toContain('platform-subpanel');
expect(woodenFishCard.className).toContain(
'platform-creation-reference-card',
);
expect(woodenFishCard.className).toContain('platform-interactive-card');
expect(woodenFishCard.getAttribute('type')).toBe('button');
fireEvent.click(woodenFishCard);
expect(onSelectWoodenFish).toHaveBeenCalledTimes(1);
});
test('disables open creation type card while busy', () => {
const onSelectWoodenFish = vi.fn();
render(
<PlatformEntryCreationTypeModal
isOpen
isBusy
entryConfig={entryConfig}
creationTypes={derivePlatformCreationTypes(entryConfig.creationTypes)}
onClose={() => {}}
onSelectRpg={() => {}}
onSelectBigFish={() => {}}
onSelectMatch3D={() => {}}
onSelectSquareHole={() => {}}
onSelectJumpHop={() => {}}
onSelectWoodenFish={onSelectWoodenFish}
onSelectPuzzle={() => {}}
onSelectCreativeAgent={() => {}}
onSelectBarkBattle={() => {}}
onSelectVisualNovel={() => {}}
onSelectBabyObjectMatch={() => {}}
/>,
);
const woodenFishCard = screen.getByRole('button', { name: //u });
expect((woodenFishCard as HTMLButtonElement).disabled).toBe(true);
expect(woodenFishCard.className).toContain('platform-subpanel');
expect(woodenFishCard.className).toContain('opacity-70');
fireEvent.click(woodenFishCard);
expect(onSelectWoodenFish).not.toHaveBeenCalled();
});
test('renders locked creation type badge with PlatformPillBadge', () => {
const onSelectWoodenFish = vi.fn();
const woodenFishEntry = entryConfig.creationTypes[0]!;
const lockedEntryConfig = {
...entryConfig,
creationTypes: [
{
...woodenFishEntry,
badge: '即将开放',
open: false,
},
],
} satisfies CreationEntryConfig;
render(
<PlatformEntryCreationTypeModal
isOpen
isBusy={false}
entryConfig={lockedEntryConfig}
creationTypes={derivePlatformCreationTypes(
lockedEntryConfig.creationTypes,
)}
onClose={() => {}}
onSelectRpg={() => {}}
onSelectBigFish={() => {}}
onSelectMatch3D={() => {}}
onSelectSquareHole={() => {}}
onSelectJumpHop={() => {}}
onSelectWoodenFish={onSelectWoodenFish}
onSelectPuzzle={() => {}}
onSelectCreativeAgent={() => {}}
onSelectBarkBattle={() => {}}
onSelectVisualNovel={() => {}}
onSelectBabyObjectMatch={() => {}}
/>,
);
const card = screen.getByRole('button', { name: //u });
const badge = screen.getByText('即将开放');
expect((card as HTMLButtonElement).disabled).toBe(true);
expect(badge.className).toContain('rounded-full');
expect(badge.className).toContain('bg-white/72');
expect(badge.querySelector('svg')).toBeTruthy();
const lockIconBadge = Array.from(
card.querySelectorAll('[aria-hidden="true"]'),
).find(
(element) =>
element instanceof HTMLElement && element.className.includes('h-7'),
);
expect(lockIconBadge?.className).toContain('bg-white/18');
expect(lockIconBadge?.className).toContain('text-white/72');
fireEvent.click(card);
expect(onSelectWoodenFish).not.toHaveBeenCalled();
});

View File

@@ -1,6 +1,9 @@
import { ArrowRight, LockKeyhole } from 'lucide-react';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { UnifiedModal } from '../common/UnifiedModal';
import {
getVisiblePlatformCreationTypes,
@@ -36,11 +39,16 @@ function CreationTypeCard(props: {
const lockedBadge = item.badge.trim() || '暂未开放';
return (
<button
<PlatformSubpanel
as="button"
type="button"
surface="platform"
radius="xl"
padding="none"
interactive={!item.locked}
disabled={disabled}
onClick={onSelect}
className={`platform-creation-reference-card platform-interactive-card relative flex min-h-[10rem] flex-col overflow-hidden rounded-[1.65rem] border p-0 text-left ${
className={`platform-creation-reference-card platform-interactive-card relative flex min-h-[10rem] flex-col overflow-hidden border text-left ${
item.locked
? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-white'
: 'border-[var(--platform-cool-border)] bg-white text-white'
@@ -61,15 +69,21 @@ function CreationTypeCard(props: {
/>
<div className="relative z-10 flex min-h-6 items-start justify-end gap-3 px-4 pt-4">
{item.locked ? (
<span className="platform-pill platform-pill--neutral gap-1 px-3 text-[var(--platform-text-soft)]">
<LockKeyhole className="h-3.5 w-3.5" />
<PlatformPillBadge
tone="neutral"
size="sm"
icon={<LockKeyhole className="h-3.5 w-3.5" />}
className="gap-1 px-3 text-[var(--platform-text-soft)]"
>
{lockedBadge}
</span>
</PlatformPillBadge>
) : null}
{item.locked ? (
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/18 text-white/72">
<LockKeyhole className="h-3.5 w-3.5" />
</span>
<PlatformIconBadge
icon={<LockKeyhole className="h-3.5 w-3.5" />}
size="xs"
tone="heroMuted"
/>
) : (
<ArrowRight className="h-4 w-4 text-white/80" />
)}
@@ -86,7 +100,7 @@ function CreationTypeCard(props: {
{item.subtitle}
</div>
</div>
</button>
</PlatformSubpanel>
);
}

View File

@@ -1,10 +1,11 @@
import { describe, expect, test } from 'vitest';
import {
resolveMiniGameGenerationViewBusy,
resolveMiniGameGenerationProgressTickState,
} from './PlatformEntryFlowShellImpl';
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
import {
resolveMiniGameGenerationProgressTickState,
resolveMiniGameGenerationViewBusy,
} from './PlatformEntryFlowShellImpl';
import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel';
import { resolveFinishedMiniGameDraftGenerationState } from './platformMiniGameDraftGenerationStateModel';
describe('resolveMiniGameGenerationProgressTickState', () => {
@@ -57,3 +58,34 @@ describe('resolveMiniGameGenerationViewBusy', () => {
);
});
});
describe('buildExternalGenerationQueueStatus', () => {
test('合并队列概览和当前任务状态', () => {
expect(
buildExternalGenerationQueueStatus(
{
pendingCount: 7,
runningCount: 3,
updatedAtMicros: 1_781_222_400_000_000,
},
{
operationId: 'extgen-1',
status: 'running',
phaseLabel: '正在生成。',
phaseDetail: '正在生成。',
progress: 35,
updatedAtMicros: 1_781_222_400_000_000,
},
),
).toEqual({
currentStatus: 'running',
currentProgress: 35,
pendingCount: 7,
runningCount: 3,
});
});
test('没有队列或任务信息时不显示状态条', () => {
expect(buildExternalGenerationQueueStatus(null, null)).toBeNull();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
/* @vitest-environment jsdom */
import {
cleanup,
fireEvent,
render,
screen,
within,
} from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import {
PuzzleOnboardingLoginOverlay,
PuzzleOnboardingView,
type PuzzleOnboardingPhase,
} from './PuzzleOnboardingView';
function renderOnboarding({
prompt = '月亮糖果工厂',
phase = 'input',
error = null,
onPromptChange = vi.fn(),
onSubmit = vi.fn(),
onSkip = vi.fn(),
}: {
prompt?: string;
phase?: PuzzleOnboardingPhase;
error?: string | null;
onPromptChange?: (value: string) => void;
onSubmit?: () => void;
onSkip?: () => void;
} = {}) {
render(
<PuzzleOnboardingView
prompt={prompt}
phase={phase}
error={error}
copy="把梦做成拼图"
onPromptChange={onPromptChange}
onSubmit={onSubmit}
onSkip={onSkip}
/>,
);
return { onPromptChange, onSubmit, onSkip };
}
test('PuzzleOnboardingView uses shared dark textarea and error status chrome', () => {
const { onPromptChange } = renderOnboarding({
error: '拼图生成失败',
});
const textarea = screen.getByPlaceholderText('把你的梦讲给我听吧');
fireEvent.change(textarea, { target: { value: '一座会唱歌的城堡' } });
expect(textarea.tagName).toBe('TEXTAREA');
expect(textarea.className).toContain('platform-text-field--editor-dark');
expect(textarea.className).toContain('min-h-32');
expect(onPromptChange).toHaveBeenCalledWith('一座会唱歌的城堡');
expect(screen.getByText('拼图生成失败').className).toContain(
'platform-status-message',
);
expect(screen.getByText('拼图生成失败').className).toContain(
'border-rose-300/15',
);
expect(screen.queryByRole('dialog')).toBeNull();
});
test('PuzzleOnboardingView preserves submit, skip, and disabled phase behavior', () => {
const { onSubmit, onSkip } = renderOnboarding();
const submitButton = screen.getByRole('button', { name: '生成' });
const skipButton = screen.getByRole('button', { name: '跳过' });
expect(submitButton.className).toContain('platform-action-button--accent');
expect(submitButton.className).toContain('bg-amber-200');
expect(submitButton.className).toContain('w-full');
expect(skipButton.className).toContain(
'platform-action-button--editor-dark',
);
expect(skipButton.className).toContain('rounded-full');
expect(skipButton.className).toContain('absolute');
fireEvent.click(submitButton);
fireEvent.click(skipButton);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSkip).toHaveBeenCalledTimes(1);
cleanup();
renderOnboarding({ prompt: '', phase: 'input' });
expect(screen.getByRole('button', { name: '生成' })).toHaveProperty(
'disabled',
true,
);
cleanup();
renderOnboarding({ phase: 'generating' });
expect(screen.getByPlaceholderText('把你的梦讲给我听吧')).toHaveProperty(
'disabled',
true,
);
expect(screen.getByRole('button', { name: '跳过' })).toHaveProperty(
'disabled',
true,
);
cleanup();
renderOnboarding({ phase: 'generated' });
expect(screen.getByPlaceholderText('把你的梦讲给我听吧')).toHaveProperty(
'disabled',
true,
);
expect(screen.getByRole('button', { name: '生成' })).toHaveProperty(
'disabled',
true,
);
});
test('PuzzleOnboardingLoginOverlay uses shared error status and keeps login action', () => {
const onLogin = vi.fn();
const { rerender } = render(
<PuzzleOnboardingLoginOverlay
isSaving={false}
error="保存首访拼图失败"
copy="登录后保存你的拼图"
onLogin={onLogin}
/>,
);
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);
expect(screen.getByRole('button', { name: '注册账号 / 登录' }).className).toContain(
'platform-action-button--accent',
);
expect(screen.getByText('保存首访拼图失败').className).toContain(
'platform-status-message',
);
rerender(
<PuzzleOnboardingLoginOverlay
isSaving
error={null}
copy="登录后保存你的拼图"
onLogin={onLogin}
/>,
);
expect(screen.getByRole('button', { name: '注册账号 / 登录' })).toHaveProperty(
'disabled',
true,
);
});

View File

@@ -1,5 +1,10 @@
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';
type PuzzleOnboardingViewProps = {
@@ -28,14 +33,18 @@ export function PuzzleOnboardingView({
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_30%_15%,rgba(251,191,36,0.22),transparent_30%),linear-gradient(135deg,#0f172a,#111827_46%,#1e1b4b)] px-4 py-8 text-white">
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.045)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:38px_38px] opacity-30" />
<button
<PlatformActionButton
type="button"
surface="editorDark"
tone="ghost"
size="sm"
shape="pill"
disabled={isGenerating}
onClick={onSkip}
className="absolute right-4 top-4 z-10 inline-flex min-h-10 items-center justify-center rounded-full border border-white/14 bg-black/24 px-4 text-sm font-black text-white/86 shadow-[0_12px_28px_rgba(0,0,0,0.22)] backdrop-blur transition hover:border-amber-200/45 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-45 sm:right-6 sm:top-6"
className="absolute right-4 top-4 z-10 min-h-10 shadow-[0_12px_28px_rgba(0,0,0,0.22)] backdrop-blur sm:right-6 sm:top-6"
>
</button>
</PlatformActionButton>
<section className="relative flex w-full max-w-[34rem] flex-col items-center gap-5 text-center">
<div className="grid h-14 w-14 place-items-center rounded-[1.2rem] border border-amber-200/32 bg-amber-200/14 text-amber-100 shadow-[0_18px_48px_rgba(251,191,36,0.18)]">
{isGenerating ? (
@@ -54,18 +63,26 @@ export function PuzzleOnboardingView({
onSubmit();
}}
>
<textarea
<PlatformTextField
variant="textarea"
surface="editorDark"
tone="warm"
density="roomy"
size="lg"
value={prompt}
disabled={isGenerating || isGenerated}
onChange={(event) => onPromptChange(event.target.value)}
placeholder="把你的梦讲给我听吧"
rows={4}
className="min-h-32 w-full resize-none rounded-[1.25rem] border border-white/14 bg-black/28 px-4 py-4 text-base font-semibold leading-7 text-white shadow-[0_18px_50px_rgba(0,0,0,0.24)] outline-none backdrop-blur placeholder:text-white/42 focus:border-amber-200/70 focus:ring-2 focus:ring-amber-200/20 disabled:opacity-70"
className="min-h-32 rounded-[1.25rem] border-white/14 bg-black/28 py-4 leading-7 shadow-[0_18px_50px_rgba(0,0,0,0.24)] backdrop-blur placeholder:text-white/42 focus:border-amber-200/70 focus:ring-2 focus:ring-amber-200/20 disabled:opacity-70"
/>
<button
<PlatformActionButton
type="submit"
surface="editorDark"
tone="accent"
size="lg"
fullWidth
disabled={!canSubmit}
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isGenerating ? (
<>
@@ -75,12 +92,17 @@ export function PuzzleOnboardingView({
) : (
'生成'
)}
</button>
</PlatformActionButton>
</form>
{error ? (
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
<PlatformStatusMessage
tone="error"
surface="editorDark"
size="md"
className="w-full font-semibold"
>
{error}
</div>
</PlatformStatusMessage>
) : null}
</section>
</div>
@@ -101,37 +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>
<button
type="button"
disabled={isSaving}
onClick={onLogin}
className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
<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"
size="md"
className="w-full font-semibold"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
/
</>
) : (
'注册账号 / 登录'
)}
</button>
{error ? (
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
{error}
</div>
) : null}
</section>
</div>
{error}
</PlatformStatusMessage>
) : null}
</UnifiedModal>
);
}

View File

@@ -0,0 +1,24 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { PuzzleRuntimeBlockingOverlay } from './PuzzleRuntimeBlockingOverlay';
describe('PuzzleRuntimeBlockingOverlay', () => {
test('展示阻断标题与说明,并关闭背景与 Escape 关闭', () => {
render(
<PuzzleRuntimeBlockingOverlay
title="正在准备下一关"
description="广场暂无可接续作品,正在生成新的候选图。"
/>,
);
expect(screen.getByRole('dialog', { name: '正在准备下一关' })).toBeTruthy();
expect(screen.getByText('正在准备下一关')).toBeTruthy();
expect(
screen.getByText('广场暂无可接续作品,正在生成新的候选图。'),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '关闭' })).toBeNull();
});
});

View File

@@ -0,0 +1,39 @@
import { Loader2 } from 'lucide-react';
import { UnifiedModal } from '../../common/UnifiedModal';
type PuzzleRuntimeBlockingOverlayProps = {
title: string;
description: string;
};
/**
* 拼图运行态局部阻断层壳子。
* 仅承接平台入口里拼图运行态的短暂等待态,不把玩法局部视觉强行上推到 common。
*/
export function PuzzleRuntimeBlockingOverlay({
title,
description,
}: PuzzleRuntimeBlockingOverlayProps) {
return (
<UnifiedModal
open
title={title}
onClose={() => undefined}
portal={false}
showHeader={false}
showCloseButton={false}
closeOnBackdrop={false}
closeOnEscape={false}
size="sm"
zIndexClassName="z-[120]"
overlayClassName="!items-center bg-slate-950/62 !px-5 !py-6 text-white backdrop-blur-sm"
panelClassName="!max-w-[18rem] !rounded-[1.5rem] border border-white/12 bg-slate-950/92 text-white shadow-[0_28px_80px_rgba(0,0,0,0.35)]"
bodyClassName="flex flex-col items-center gap-3 !px-6 !py-5 text-center"
>
<Loader2 className="h-6 w-6 animate-spin text-amber-200" />
<div className="text-sm font-bold">{title}</div>
<div className="text-xs leading-5 text-white/68">{description}</div>
</UnifiedModal>
);
}

View File

@@ -36,15 +36,18 @@ describe('PlatformErrorDialog', () => {
);
const dialog = screen.getByRole('dialog', { name: '发生错误' });
expect(within(dialog).getByText('拼图草稿 puzzle-session-123')).toBeTruthy();
expect(
within(dialog).getByText('拼图草稿 puzzle-session-123'),
).toBeTruthy();
expect(within(dialog).getByText('图片生成失败,请稍后再试。')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '复制报错' }));
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
['来源:拼图草稿 puzzle-session-123', '错误:图片生成失败,请稍后再试。'].join(
'\n',
),
[
'来源:拼图草稿 puzzle-session-123',
'错误:图片生成失败,请稍后再试。',
].join('\n'),
);
await waitFor(() => {
expect(
@@ -64,8 +67,7 @@ describe('PlatformErrorDialog', () => {
<PlatformErrorDialog
error={{
source: '大鱼草稿',
message:
'creation_entry_disabledrequestId: req-big-fish-gallery',
message: 'creation_entry_disabledrequestId: req-big-fish-gallery',
}}
onClose={() => {}}
/>,

View File

@@ -1,8 +1,4 @@
import { Check, Copy } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { UnifiedModal } from '../common/UnifiedModal';
import { PlatformReportDialog } from '../common/PlatformReportDialog';
export type PlatformErrorDialogPayload = {
source: string;
@@ -16,10 +12,6 @@ type PlatformErrorDialogProps = {
panelClassName?: string;
};
function buildPlatformErrorReport(error: PlatformErrorDialogPayload) {
return [`来源:${error.source}`, `错误:${error.message}`].join('\n');
}
function isBlacklistedPlatformError(error: PlatformErrorDialogPayload | null) {
// 中文注释:入口关闭是平台开关状态,不作为全局错误弹窗打扰用户。
return Boolean(error?.message.includes('creation_entry_disabled'));
@@ -31,96 +23,31 @@ export function PlatformErrorDialog({
overlayClassName = 'platform-theme platform-theme--light !items-center',
panelClassName = 'platform-remap-surface rounded-[1.5rem]',
}: PlatformErrorDialogProps) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const resetTimerRef = useRef<number | null>(null);
const dialogError = isBlacklistedPlatformError(error) ? null : error;
const reportText = useMemo(
() => (dialogError ? buildPlatformErrorReport(dialogError) : ''),
[dialogError],
);
useEffect(
() => () => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
},
[],
);
useEffect(() => {
setCopyState('idle');
}, [dialogError?.source, dialogError?.message]);
const copyError = () => {
if (!reportText) {
return;
}
void copyTextToClipboard(reportText).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
resetTimerRef.current = window.setTimeout(() => {
resetTimerRef.current = null;
setCopyState('idle');
}, 1400);
});
};
return (
<UnifiedModal
<PlatformReportDialog
open={Boolean(dialogError)}
title="发生错误"
onClose={onClose}
size="sm"
copyIdleLabel="复制报错"
fields={
dialogError
? [
{
label: '来源',
value: dialogError.source,
},
{
label: '错误',
value: dialogError.message,
multiline: true,
},
]
: []
}
overlayClassName={overlayClassName}
panelClassName={panelClassName}
bodyClassName="space-y-3 px-4 py-4 sm:px-5 sm:py-5"
footerClassName="justify-end px-4 py-4 sm:px-5"
footer={
<button
type="button"
onClick={copyError}
disabled={!reportText}
className="platform-button platform-button--primary w-full justify-center gap-2 sm:w-auto"
>
{copyState === 'copied' ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
{copyState === 'copied'
? '已复制'
: copyState === 'failed'
? '复制失败'
: '复制报错'}
</button>
}
>
{dialogError ? (
<>
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
</div>
<div className="mt-1 break-words text-sm font-semibold leading-5 text-[var(--platform-text-strong)]">
{dialogError.source}
</div>
</div>
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
</div>
<div className="mt-1 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
{dialogError.message}
</div>
</div>
</>
) : null}
</UnifiedModal>
/>
);
}

View File

@@ -25,13 +25,46 @@ test('PlatformFeedbackView renders reference feedback fields', () => {
expect(screen.getByText('帮助与反馈')).toBeTruthy();
expect(screen.getByText('反馈问题')).toBeTruthy();
expect(screen.getByLabelText('问题描述')).toBeTruthy();
const descriptionField = screen.getByLabelText('问题描述');
expect(descriptionField).toBeTruthy();
expect(descriptionField.tagName).toBe('TEXTAREA');
expect(descriptionField.className).toContain('platform-text-field');
expect(descriptionField.className).toContain('!bg-transparent');
expect(screen.getByText('问题描述').className).toContain(
'text-[var(--platform-text-strong)]',
);
expect(screen.getByText('0/200')).toBeTruthy();
expect(screen.getByText('上传凭证(提供问题截图)')).toBeTruthy();
expect(screen.getByText('上传凭证')).toBeTruthy();
expect(screen.getByLabelText('联系电话')).toBeTruthy();
const contactPhoneField = screen.getByLabelText('联系电话');
expect(contactPhoneField).toBeTruthy();
expect(contactPhoneField.tagName).toBe('INPUT');
expect(contactPhoneField.className).toContain('platform-text-field');
expect(contactPhoneField.className).toContain('!bg-transparent');
expect(screen.getByText('联系电话').className).toContain(
'text-[var(--platform-text-strong)]',
);
expect(screen.getByRole('button', { name: '提交' })).toBeTruthy();
expect(screen.getByRole('button', { name: '查看反馈与投诉记录' })).toBeTruthy();
const feedbackHistoryButton = screen.getByRole('button', {
name: '查看反馈与投诉记录',
});
expect(feedbackHistoryButton).toBeTruthy();
expect(feedbackHistoryButton.className).toContain('platform-button--ghost');
expect(feedbackHistoryButton.className).toContain('rounded-full');
fireEvent.click(feedbackHistoryButton);
expect(screen.getByText('反馈记录暂未开放')).toBeTruthy();
const descriptionPanel = screen.getByLabelText('问题描述').closest('section');
const evidencePanel = screen
.getByText('上传凭证(提供问题截图)')
.closest('section');
const phonePanel = screen.getByLabelText('联系电话').closest('section');
for (const panel of [descriptionPanel, evidencePanel, phonePanel]) {
expect(panel?.className).toContain('platform-subpanel');
expect(panel?.className).toContain('rounded-[1.25rem]');
expect(panel?.className).toContain('p-4');
}
});
test('PlatformFeedbackView validates minimum description length before submit', () => {
@@ -73,9 +106,12 @@ test('PlatformFeedbackView previews image data urls and submits evidence items',
render(<PlatformFeedbackView onBack={vi.fn()} onSubmit={onSubmit} />);
const file = new File(['feedback'], 'preview.png', { type: 'image/png' });
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
target: { files: [file] },
});
fireEvent.change(
document.querySelector('input[type="file"]') as HTMLInputElement,
{
target: { files: [file] },
},
);
const preview = await screen.findByAltText('反馈凭证预览');
expect(preview.getAttribute('src')).toBe(

View File

@@ -1,7 +1,14 @@
import { ArrowLeft, CheckCircle2, ImagePlus, Send, X } from 'lucide-react';
import { ArrowLeft, CheckCircle2, Send } from 'lucide-react';
import { useRef, useState } from 'react';
import type { ProfileFeedbackEvidenceItemInput } from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
import { PlatformUploadTile } from '../common/PlatformUploadTile';
const MIN_FEEDBACK_DESCRIPTION_LENGTH = 10;
const MAX_FEEDBACK_DESCRIPTION_LENGTH = 200;
@@ -55,7 +62,9 @@ export function PlatformFeedbackView({
const evidenceInputRef = useRef<HTMLInputElement | null>(null);
const [description, setDescription] = useState('');
const [contactPhone, setContactPhone] = useState('');
const [evidencePreviews, setEvidencePreviews] = useState<EvidencePreview[]>([]);
const [evidencePreviews, setEvidencePreviews] = useState<EvidencePreview[]>(
[],
);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -97,7 +106,8 @@ export function PlatformFeedbackView({
setError('反馈凭证只支持图片类型');
return;
}
const remainingCount = MAX_FEEDBACK_EVIDENCE_COUNT - evidencePreviews.length;
const remainingCount =
MAX_FEEDBACK_EVIDENCE_COUNT - evidencePreviews.length;
if (remainingCount <= 0 || selectedFiles.length > remainingCount) {
setError('最多上传四张凭证');
}
@@ -142,7 +152,9 @@ export function PlatformFeedbackView({
setSubmitted(false);
})
.catch((readError: unknown) => {
setError(readError instanceof Error ? readError.message : '图片读取失败');
setError(
readError instanceof Error ? readError.message : '图片读取失败',
);
});
};
@@ -199,7 +211,9 @@ export function PlatformFeedbackView({
.then(() => setSubmitted(true))
.catch((submitError: unknown) => {
setSubmitted(false);
setError(submitError instanceof Error ? submitError.message : '提交失败');
setError(
submitError instanceof Error ? submitError.message : '提交失败',
);
})
.finally(() => setIsSubmitting(false));
};
@@ -208,14 +222,16 @@ export function PlatformFeedbackView({
<div className="platform-page-stage platform-remap-surface min-h-0 flex-1 overflow-y-auto text-[var(--platform-text-strong)]">
<div className="mx-auto flex min-h-full w-full max-w-[30rem] flex-col px-4 pb-6 pt-4">
<header className="flex shrink-0 items-center gap-3 pb-3">
<button
type="button"
<PlatformActionButton
onClick={onBack}
aria-label="返回我的页签"
className="platform-button platform-button--ghost h-10 w-10 shrink-0 justify-center rounded-full p-0"
tone="ghost"
shape="pill"
size="xs"
className="h-10 w-10 shrink-0 p-0"
>
<ArrowLeft className="h-[1.125rem] w-[1.125rem]" />
</button>
</PlatformActionButton>
<div className="min-w-0 text-base font-black text-[var(--platform-text-strong)]">
</div>
@@ -226,61 +242,54 @@ export function PlatformFeedbackView({
</div>
<section className="platform-subpanel rounded-[1.2rem] px-4 py-4">
<PlatformSubpanel radius="md">
<label
htmlFor="profile-feedback-description"
className="block text-base font-semibold text-[var(--platform-text-strong)]"
className="block"
>
<PlatformFieldLabel
variant="form"
className="mb-0 text-base font-semibold"
>
</PlatformFieldLabel>
</label>
<textarea
<PlatformTextField
variant="textarea"
id="profile-feedback-description"
value={description}
maxLength={MAX_FEEDBACK_DESCRIPTION_LENGTH}
onChange={(event) => updateDescription(event.target.value)}
placeholder="请填写10个字以上的问题描述以便我们提供更好的帮助温馨提醒您请勿填写身份证号等个人隐私信息。"
className="mt-3 min-h-[10.5rem] w-full resize-none border-0 bg-transparent text-sm leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
density="roomy"
size="md"
className="mt-3 min-h-[10.5rem] !rounded-none !border-0 !bg-transparent !px-0 !py-0 text-[var(--platform-text-strong)] placeholder:text-[var(--platform-text-soft)] focus:!bg-transparent focus:!ring-0"
/>
<div className="text-right text-xs text-[var(--platform-text-soft)]">
{descriptionLength}/{MAX_FEEDBACK_DESCRIPTION_LENGTH}
</div>
</section>
</PlatformSubpanel>
<section className="platform-subpanel rounded-[1.2rem] px-4 py-4">
<PlatformSubpanel radius="md">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
()
</div>
<div className="mt-4 flex flex-wrap gap-3">
{evidencePreviews.map((preview) => (
<div
<PlatformUploadPreviewCard
key={preview.id}
className="relative h-[5.75rem] w-[5.75rem] overflow-hidden rounded-xl border border-[var(--platform-subpanel-border)] bg-[var(--platform-input-fill)]"
>
<img
src={preview.dataUrl}
alt="反馈凭证预览"
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={() => removeEvidencePreview(preview.id)}
aria-label="移除上传凭证"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/55 text-white"
>
<X className="h-3 w-3" />
</button>
</div>
imageSrc={preview.dataUrl}
imageAlt="反馈凭证预览"
removeLabel="移除上传凭证"
onRemove={() => removeEvidencePreview(preview.id)}
/>
))}
{evidencePreviews.length < MAX_FEEDBACK_EVIDENCE_COUNT ? (
<button
type="button"
<PlatformUploadTile
onClick={openEvidencePicker}
className="flex h-[5.75rem] w-[5.75rem] flex-col items-center justify-center rounded-xl border border-dashed border-[var(--platform-subpanel-border)] bg-[var(--platform-input-fill)] text-[var(--platform-text-soft)] transition hover:border-[var(--platform-surface-hover-border)] hover:text-[var(--platform-text-strong)]"
>
<ImagePlus className="h-6 w-6" />
<span className="mt-2 text-xs font-medium"></span>
<span className="mt-0.5 text-[11px]">()</span>
</button>
label="上传凭证"
hint="(最多四张)"
/>
) : null}
</div>
<input
@@ -291,60 +300,70 @@ export function PlatformFeedbackView({
className="hidden"
onChange={(event) => addEvidenceFiles(event.target.files)}
/>
</section>
</PlatformSubpanel>
<section className="platform-subpanel rounded-[1.2rem] px-4 py-4">
<PlatformSubpanel radius="md">
<label
htmlFor="profile-feedback-phone"
className="block text-base font-semibold text-[var(--platform-text-strong)]"
className="block"
>
<PlatformFieldLabel
variant="form"
className="mb-0 text-base font-semibold"
>
</PlatformFieldLabel>
</label>
<input
<PlatformTextField
id="profile-feedback-phone"
type="tel"
value={contactPhone}
maxLength={MAX_CONTACT_PHONE_LENGTH}
onChange={(event) => updateContactPhone(event.target.value)}
placeholder="选填,如您填写则将会同步开发者与您联系"
className="mt-3 w-full border-0 bg-transparent text-sm leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
size="md"
density="compact"
className="mt-3 !rounded-none !border-0 !bg-transparent !px-0 !py-0 text-[var(--platform-text-strong)] placeholder:text-[var(--platform-text-soft)] focus:!bg-transparent focus:!ring-0"
/>
</section>
</PlatformSubpanel>
{error ? (
<div className="rounded-[1.2rem] border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] px-4 py-3 text-sm font-medium text-[var(--platform-button-danger-text)]">
<PlatformStatusMessage tone="error" surface="profile">
{error}
</div>
</PlatformStatusMessage>
) : null}
{submitted ? (
<div className="flex items-center gap-2 rounded-[1.2rem] border border-[var(--platform-success-border)] bg-[var(--platform-success-bg)] px-4 py-3 text-sm font-medium text-[var(--platform-success-text)]">
<PlatformStatusMessage tone="success" surface="profile">
<CheckCircle2 className="h-4 w-4" />
</div>
</PlatformStatusMessage>
) : null}
{notice ? (
<div className="rounded-[1.2rem] border border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] px-4 py-3 text-sm font-medium text-[var(--platform-cool-text)]">
<PlatformStatusMessage tone="info" surface="profile">
{notice}
</div>
</PlatformStatusMessage>
) : null}
<button
type="button"
<PlatformActionButton
fullWidth
size="md"
className="mt-2 h-12 text-base"
onClick={submitFeedback}
disabled={isSubmitting}
className="platform-button platform-button--primary mt-2 h-12 w-full justify-center text-base disabled:cursor-not-allowed disabled:opacity-60"
>
<Send className="h-4 w-4" />
{isSubmitting ? '提交中' : '提交'}
</button>
</PlatformActionButton>
<button
type="button"
<PlatformActionButton
tone="ghost"
shape="pill"
size="xs"
onClick={() => showTemporaryNotice('反馈记录暂未开放')}
className="self-center px-3 py-2 text-sm font-semibold text-[var(--platform-cool-text)]"
className="self-center"
>
</button>
</PlatformActionButton>
</main>
</div>
</div>

View File

@@ -0,0 +1,39 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
test('PlatformProfileModalShell forwards footer content into shared modal footer chrome', () => {
render(
<PlatformProfileModalShell
title="修改昵称"
onClose={vi.fn()}
panelClassName="platform-remap-surface !max-w-sm rounded-[1.4rem]"
bodyClassName="px-5 py-5"
footerClassName="grid grid-cols-2 gap-3 px-5 pb-5 pt-0 sm:px-5"
footer={
<>
<PlatformActionButton tone="secondary"></PlatformActionButton>
<PlatformActionButton></PlatformActionButton>
</>
}
>
<div></div>
</PlatformProfileModalShell>,
);
const dialog = screen.getByRole('dialog', { name: '修改昵称' });
const body = screen.getByText('昵称输入区').parentElement;
const footerButton = screen.getByRole('button', { name: '保存' });
const footer = footerButton.closest('div');
expect(dialog).toBeTruthy();
expect(body?.className).toContain('px-5');
expect(body?.className).toContain('py-5');
expect(footer?.className).toContain('border-t');
expect(footer?.className).toContain('grid');
expect(footer?.className).toContain('pt-0');
});

View File

@@ -0,0 +1,152 @@
import type { ReactNode } from 'react';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { UnifiedModal } from '../common/UnifiedModal';
type PlatformProfileModalShellProps = {
title: string;
description?: ReactNode;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
closeLabel?: string;
closeVariant?: 'profile' | 'profileCompact';
closeDisabled?: boolean;
showHeader?: boolean;
showCloseButton?: boolean;
size?: 'sm' | 'md';
zIndexClassName?: string;
panelClassName: string;
bodyClassName?: string;
descriptionClassName?: string;
footerClassName?: string;
};
type PlatformProfileSecondaryModalShellProps = {
title: string;
onClose: () => void;
children: ReactNode;
closeLabel?: string;
closeVariant?: 'floating' | 'floatingPlain';
closeIcon?: ReactNode;
closeButtonClassName?: string;
overlayTone?: 'default' | 'soft';
size?: 'sm' | 'md';
zIndexClassName?: string;
panelClassName: string;
contentClassName: string;
};
const PROFILE_MODAL_OVERLAY_CLASS =
'platform-modal-backdrop !items-center !justify-center !px-4 !py-6';
const PROFILE_MODAL_HEADER_CLASS = 'border-white/10 px-5 py-4';
const PROFILE_MODAL_TITLE_CLASS = 'text-base font-black';
const PROFILE_MODAL_DESCRIPTION_CLASS =
'mt-1 text-xs font-semibold text-[var(--platform-text-soft)]';
const PROFILE_SECONDARY_MODAL_OVERLAY_CLASS_BY_TONE = {
default: '!items-center !bg-black/48 !px-3 !py-5 !backdrop-blur-none',
soft: '!items-center !bg-black/42 !px-3 !py-5 !backdrop-blur-none',
} as const;
/**
* 个人中心标准弹窗壳层。
* 统一收口账户侧弹窗常用的 overlay、header 和 close button 配置。
*/
export function PlatformProfileModalShell({
title,
description,
onClose,
children,
footer,
closeLabel,
closeVariant = 'profile',
closeDisabled = false,
showHeader = true,
showCloseButton = true,
size = 'sm',
zIndexClassName = 'z-[80]',
panelClassName,
bodyClassName = 'px-5 py-5',
descriptionClassName = PROFILE_MODAL_DESCRIPTION_CLASS,
footerClassName,
}: PlatformProfileModalShellProps) {
return (
<UnifiedModal
open
title={title}
description={description}
onClose={onClose}
closeLabel={closeLabel ?? `关闭${title}`}
closeVariant={closeVariant}
closeDisabled={closeDisabled}
showHeader={showHeader}
showCloseButton={showCloseButton}
closeOnBackdrop={false}
closeOnEscape={false}
portal={false}
size={size}
zIndexClassName={zIndexClassName}
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
panelClassName={panelClassName}
headerClassName={PROFILE_MODAL_HEADER_CLASS}
titleClassName={PROFILE_MODAL_TITLE_CLASS}
descriptionClassName={descriptionClassName}
bodyClassName={bodyClassName}
footer={footer}
footerClassName={footerClassName}
>
{children}
</UnifiedModal>
);
}
/**
* 个人中心副弹层壳层。
* 用于“玩过 / 账单 / 邀请”这类白底浮层,统一收口 overlay、floating close 和 body 外壳。
*/
export function PlatformProfileSecondaryModalShell({
title,
onClose,
children,
closeLabel,
closeVariant = 'floating',
closeIcon = '×',
closeButtonClassName,
overlayTone = 'default',
size = 'sm',
zIndexClassName = 'z-[80]',
panelClassName,
contentClassName,
}: PlatformProfileSecondaryModalShellProps) {
return (
<UnifiedModal
open
title={title}
onClose={onClose}
showHeader={false}
showCloseButton={false}
closeOnBackdrop={false}
closeOnEscape={false}
portal={false}
size={size}
zIndexClassName={zIndexClassName}
overlayClassName={
PROFILE_SECONDARY_MODAL_OVERLAY_CLASS_BY_TONE[overlayTone]
}
panelClassName={panelClassName}
bodyClassName="!p-0"
>
<div className={contentClassName}>
<PlatformModalCloseButton
label={closeLabel ?? `关闭${title}`}
variant={closeVariant}
onClick={onClose}
className={closeButtonClassName}
icon={closeIcon}
/>
{children}
</div>
</UnifiedModal>
);
}

View File

@@ -0,0 +1,91 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import { PlatformProfilePlayedWorksModal } from './PlatformProfilePlayedWorksModal';
describe('PlatformProfilePlayedWorksModal', () => {
test('renders save archives and played works in one modal', async () => {
const user = userEvent.setup();
const onResumeSave = vi.fn();
const onOpenWork = vi.fn();
const saveEntry = {
worldKey: 'custom:save-1',
ownerUserId: 'user-1',
profileId: 'save-1',
worldType: 'custom',
worldName: '回声群岛',
subtitle: '雾海码头',
summaryText: '继续推进上一次保存的故事。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
};
const playedWork = {
worldKey: 'custom:world-1',
ownerUserId: 'user-1',
profileId: 'world-1',
worldType: 'CUSTOM',
worldTitle: '潮雾列岛',
worldSubtitle: '旧灯塔与失控航路',
firstPlayedAt: '2026-04-18T12:00:00.000Z',
lastPlayedAt: '2026-04-19T12:00:00.000Z',
lastObservedPlayTimeMs: 30 * 60 * 1000,
};
render(
<PlatformProfilePlayedWorksModal
stats={{
totalPlayTimeMs: 90 * 60 * 1000,
playedWorks: [playedWork],
updatedAt: '2026-04-19T12:00:00.000Z',
}}
isLoading={false}
error={null}
saveEntries={[saveEntry]}
saveError={null}
isResumingSaveWorldKey={null}
onClose={vi.fn()}
onOpenWork={onOpenWork}
onResumeSave={onResumeSave}
/>,
);
const dialog = screen.getByRole('dialog', { name: '玩过' });
expect(within(dialog).getByText('可继续')).toBeTruthy();
expect(within(dialog).getAllByText('玩过').length).toBeGreaterThan(0);
expect(within(dialog).getByText('1.5小时')).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: //u }));
expect(onResumeSave).toHaveBeenCalledWith(saveEntry);
await user.click(within(dialog).getByRole('button', { name: //u }));
expect(onOpenWork).toHaveBeenCalledWith(playedWork);
});
test('renders platform empty state when no history exists', () => {
render(
<PlatformProfilePlayedWorksModal
stats={{
totalPlayTimeMs: 0,
playedWorks: [],
updatedAt: '2026-04-19T12:00:00.000Z',
}}
isLoading={false}
error={null}
saveEntries={[]}
saveError={null}
isResumingSaveWorldKey={null}
onClose={vi.fn()}
onResumeSave={vi.fn()}
/>,
);
const emptyState = screen.getByText('暂无玩过');
expect(emptyState.className).toContain('platform-empty-state');
expect(emptyState.className).toContain('text-left');
});
});

View File

@@ -0,0 +1,267 @@
import { ArrowRight, Clock3 } from 'lucide-react';
import type {
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHeader';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
import {
formatCompactPlayTime,
formatPlayedWorkId,
formatPlayedWorkType,
formatSnapshotTime,
formatTotalPlayTimeHours,
} from '../rpg-entry/rpgEntryProfileDashboardPresentation';
import { formatPlatformWorkDisplayName } from '../rpg-entry/rpgEntryWorldPresentation';
type PlatformProfilePlayedWorksModalProps = {
stats: ProfilePlayStatsResponse | null;
isLoading: boolean;
error: string | null;
saveEntries: ProfileSaveArchiveSummary[];
saveError: string | null;
isResumingSaveWorldKey: string | null;
onClose: () => void;
onOpenWork?: (work: ProfilePlayedWorkSummary) => void;
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
};
function SaveArchivePreview({
entry,
className,
}: {
entry: ProfileSaveArchiveSummary;
className: string;
}) {
return (
<div
aria-hidden="true"
className={`platform-remap-surface relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[var(--platform-desktop-hover-shadow)] ${className}`}
>
{entry.coverImageSrc ? (
<ResolvedAssetImage
src={entry.coverImageSrc}
alt=""
aria-hidden
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_34%),linear-gradient(145deg,rgba(255,94,125,0.92),rgba(255,150,116,0.88))]" />
)}
<div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" />
</div>
);
}
function SaveArchiveCard({
entry,
onClick,
loading = false,
}: {
entry: ProfileSaveArchiveSummary;
onClick: () => void;
loading?: boolean;
}) {
const summaryText =
entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
return (
<button
type="button"
onClick={onClick}
disabled={loading}
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[13rem] w-full overflow-hidden p-3.5 text-left sm:min-h-[12.5rem] sm:p-4 ${loading ? 'opacity-80' : ''}`}
>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-deep)]" />
<div className="relative z-10 flex h-full w-full flex-col gap-3">
<div className="flex flex-wrap justify-end gap-2">
<PlatformPillBadge
tone="darkNeutral"
size="xs"
className="font-medium text-[var(--platform-text-base)]"
>
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
</PlatformPillBadge>
</div>
<div className="flex min-w-0 flex-1 items-stretch gap-3 sm:gap-4">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 break-words text-[1.35rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-2xl">
{displayName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-2 break-words text-sm font-semibold text-[var(--platform-text-base)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-[var(--platform-text-soft)] sm:text-sm">
{summaryText}
</div>
<div className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-zinc-200">
<span>{loading ? '正在恢复' : '继续游玩'}</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
</div>
</div>
<SaveArchivePreview
entry={entry}
className="aspect-square w-[6.5rem] self-start sm:w-[7.5rem]"
/>
</div>
</div>
</button>
);
}
export function PlatformProfilePlayedWorksModal({
stats,
isLoading,
error,
saveEntries,
saveError,
isResumingSaveWorldKey,
onClose,
onOpenWork,
onResumeSave,
}: PlatformProfilePlayedWorksModalProps) {
// 中文注释:个人中心“玩过”弹层同时承接“可继续”的存档列表,保持同一入口下的历史/恢复语义。
const playedWorks = stats?.playedWorks ?? [];
const hasArchiveEntries = saveEntries.length > 0;
const hasPlayedWorks = playedWorks.length > 0;
return (
<PlatformProfileSecondaryModalShell
title="玩过"
onClose={onClose}
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[38rem] bg-white text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
contentClassName="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
>
<PlatformProfileSummaryHeader
kicker="PLAYED"
title="玩过"
badge={
<PlatformPillBadge
tone="profile"
icon={<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />}
>
{formatTotalPlayTimeHours(stats?.totalPlayTimeMs ?? 0)}
</PlatformPillBadge>
}
badgeClassName="mt-2"
/>
{error ? (
<PlatformStatusMessage tone="error" className="mt-4">
{error}
</PlatformStatusMessage>
) : null}
{saveError ? (
<PlatformStatusMessage tone="error" className="mt-4">
{saveError}
</PlatformStatusMessage>
) : null}
<PlatformAsyncStatePanel
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={4}
containerClassName="mt-5 space-y-3"
itemClassName="h-20"
/>
}
isEmpty={!hasArchiveEntries && !hasPlayedWorks}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
tone="base"
className="mt-5 text-left"
>
</PlatformEmptyState>
}
>
<div className="mt-5 space-y-5">
{hasArchiveEntries ? (
<section>
<PlatformFieldLabel variant="section" className="mb-2 block">
</PlatformFieldLabel>
<div className="grid gap-3">
{saveEntries.map((entry) => (
<SaveArchiveCard
key={`${entry.worldKey}:played-archive`}
entry={entry}
loading={isResumingSaveWorldKey === entry.worldKey}
onClick={() => onResumeSave(entry)}
/>
))}
</div>
</section>
) : null}
{hasPlayedWorks ? (
<section>
<PlatformFieldLabel variant="section" className="mb-2 block">
</PlatformFieldLabel>
<div className="space-y-3">
{playedWorks.map((work) => (
<PlatformProfileContentRow
as="button"
key={`${work.worldKey}:${work.lastPlayedAt}`}
onClick={() => onOpenWork?.(work)}
surface="flat"
radius="sm"
padding="md"
interactive
className="w-full hover:border-[#ff4056]"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="line-clamp-1 text-base font-black text-zinc-950">
{work.worldTitle}
</div>
{work.worldSubtitle ? (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{work.worldSubtitle}
</div>
) : null}
</div>
<PlatformPillBadge
tone="profileAccent"
size="xs"
className="shrink-0 border-transparent"
>
{formatPlayedWorkType(work.worldType)}
</PlatformPillBadge>
</div>
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
<span className="truncate"> {formatPlayedWorkId(work)}</span>
<span className="truncate">
{formatSnapshotTime(work.lastPlayedAt)}
</span>
<span className="truncate">
{formatCompactPlayTime(work.lastObservedPlayTimeMs)}
</span>
</div>
</PlatformProfileContentRow>
))}
</div>
</section>
) : null}
</div>
</PlatformAsyncStatePanel>
</PlatformProfileSecondaryModalShell>
);
}

View File

@@ -0,0 +1,87 @@
/* @vitest-environment jsdom */
import { Settings } from 'lucide-react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import { ICP_RECORD_NUMBER, LEGAL_DOCUMENTS } from '../common/legalDocuments';
import {
ProfileLegalSection,
ProfileSettingsRow,
ProfileShortcutButton,
ProfileStatCard,
} from './PlatformProfilePrimitives';
function TestIcon({ className }: { className?: string }) {
return <span className={className}>I</span>;
}
describe('PlatformProfilePrimitives', () => {
test('ProfileStatCard reports its dashboard card key on click', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<ProfileStatCard
cardKey="wallet"
label="泥点余额"
value="88"
icon={TestIcon}
onClick={onClick}
/>,
);
await user.click(screen.getByRole('button', { name: /\s*88/u }));
expect(onClick).toHaveBeenCalledWith('wallet');
});
test('ProfileShortcutButton keeps shortcut label and sub label visible', () => {
render(
<ProfileShortcutButton
label="玩家社区"
subLabel="交流心得"
icon={TestIcon}
onClick={vi.fn()}
/>,
);
const button = screen.getByRole('button', { name: //u });
expect(button.className).toContain('platform-profile-shortcut-button');
expect(screen.getByText('交流心得')).toBeTruthy();
});
test('ProfileSettingsRow and ProfileLegalSection keep their click affordances', async () => {
const user = userEvent.setup();
const onSettingsClick = vi.fn();
const onOpenDocument = vi.fn();
const firstLegalDocument = LEGAL_DOCUMENTS[0];
if (!firstLegalDocument) {
throw new Error('expected legal documents fixtures');
}
render(
<>
<ProfileSettingsRow
label="通用设置"
icon={Settings}
onClick={onSettingsClick}
/>
<ProfileLegalSection onOpenDocument={onOpenDocument} />
</>,
);
const settingsButton = screen.getByRole('button', { name: //u });
expect(settingsButton.className).toContain('platform-navigable-list-item');
await user.click(settingsButton);
expect(onSettingsClick).toHaveBeenCalledTimes(1);
await user.click(
screen.getByRole('button', { name: firstLegalDocument.title }),
);
expect(onOpenDocument).toHaveBeenCalledWith(firstLegalDocument.id);
expect(screen.getByText(ICP_RECORD_NUMBER)).toBeTruthy();
});
});

View File

@@ -0,0 +1,180 @@
import { ChevronRight } from 'lucide-react';
import type { ComponentType, ReactNode } from 'react';
import type { ProfileDashboardCardKey } from '../../../packages/shared/src/contracts/runtime';
import {
ICP_RECORD_NUMBER,
ICP_RECORD_URL,
LEGAL_DOCUMENTS,
type LegalDocumentId,
} from '../common/legalDocuments';
import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem';
type ProfileStatCardProps = {
cardKey: ProfileDashboardCardKey;
label: string;
value: string;
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
icon: ComponentType<{ className?: string }>;
imageSrc?: string;
};
export function ProfileStatCard({
cardKey,
label,
value,
onClick,
icon,
imageSrc,
}: ProfileStatCardProps) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick ? () => onClick(cardKey) : undefined}
aria-label={`${label} ${value}`}
className="platform-profile-stat-card flex min-h-[5.25rem] items-center justify-center gap-2 px-2.5 py-2.5 text-center transition"
>
<div className="platform-profile-stat-card__icon">
{imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
) : (
<Icon className="h-5 w-5" />
)}
</div>
<div className="min-w-0 text-left">
<div className="platform-profile-stat-card__value whitespace-nowrap text-[16px] font-black leading-none text-[var(--platform-text-strong)]">
{value}
</div>
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
{label}
</div>
</div>
</button>
);
}
export function ProfileStatCardSkeleton() {
return (
<div className="platform-subpanel flex min-h-[5.75rem] flex-col items-center justify-center rounded-[1.35rem] px-3 py-3 text-center">
<div className="h-4 w-20 animate-pulse rounded-full bg-[var(--platform-subpanel-border)]" />
<div className="mt-2 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
</div>
);
}
type ProfileShortcutButtonProps = {
label: string;
subLabel?: ReactNode;
icon: ComponentType<{ className?: string }>;
onClick?: (() => void) | null;
imageSrc?: string;
};
export function ProfileShortcutButton({
label,
subLabel,
icon,
onClick,
imageSrc,
}: ProfileShortcutButtonProps) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick ?? undefined}
className="platform-profile-shortcut-button flex min-h-[4.75rem] w-full flex-col items-center justify-center gap-1.5 px-2 py-2.5 text-center transition"
>
<div className="platform-profile-shortcut-button__icon">
{imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
) : (
<Icon className="h-[1.125rem] w-[1.125rem]" />
)}
</div>
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[12px] font-semibold text-[var(--platform-text-strong)]">
{label}
</div>
{subLabel ? (
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[10px] font-medium text-[var(--platform-text-soft)]">
{subLabel}
</div>
) : null}
</button>
);
}
type ProfileSettingsRowProps = {
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
};
export function ProfileSettingsRow({
label,
icon,
onClick,
}: ProfileSettingsRowProps) {
const Icon = icon;
return (
<PlatformNavigableListItem
onClick={onClick}
className="platform-profile-settings-row px-4 py-3 transition"
leading={
<span className="platform-profile-settings-row__icon">
<Icon className="h-4 w-4" />
</span>
}
bodyClassName="flex min-w-0 items-center"
trailing={
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
}
>
<span className="truncate text-[14px] font-semibold text-[var(--platform-text-strong)]">
{label}
</span>
</PlatformNavigableListItem>
);
}
type ProfileLegalSectionProps = {
onOpenDocument: (documentId: LegalDocumentId) => void;
};
export function ProfileLegalSection({
onOpenDocument,
}: ProfileLegalSectionProps) {
return (
<section className="platform-profile-legal-strip" aria-label="法律信息">
<div className="platform-profile-legal-strip__links">
{LEGAL_DOCUMENTS.map((document, index) => (
<button
key={document.id}
type="button"
onClick={() => onOpenDocument(document.id)}
className="platform-profile-legal-strip__link"
>
{document.title}
{index < LEGAL_DOCUMENTS.length - 1 ? (
<span
aria-hidden="true"
className="platform-profile-legal-strip__divider"
/>
) : null}
</button>
))}
</div>
<a
href={ICP_RECORD_URL}
target="_blank"
rel="noreferrer"
className="platform-profile-legal-strip__record"
>
{ICP_RECORD_NUMBER}
</a>
</section>
);
}

View File

@@ -0,0 +1,144 @@
/* @vitest-environment jsdom */
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { PlatformProfileQrScannerModal } from './PlatformProfileQrScannerModal';
type MockTrack = {
stop: ReturnType<typeof vi.fn>;
};
type MockStream = {
getTracks: () => MockTrack[];
};
const originalBarcodeDetector = (
globalThis as typeof globalThis & {
BarcodeDetector?: unknown;
}
).BarcodeDetector;
describe('PlatformProfileQrScannerModal', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
Object.defineProperty(HTMLMediaElement.prototype, 'readyState', {
configurable: true,
get: () => 4,
});
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.restoreAllMocks();
if (originalBarcodeDetector === undefined) {
delete (
globalThis as typeof globalThis & {
BarcodeDetector?: unknown;
}
).BarcodeDetector;
} else {
(
globalThis as typeof globalThis & {
BarcodeDetector?: unknown;
}
).BarcodeDetector = originalBarcodeDetector;
}
});
test('detects qr result and stops camera tracks', async () => {
const stop = vi.fn();
const stream = buildStream([{ stop }]);
const getUserMedia = vi.fn().mockResolvedValue(stream);
const detect = vi.fn().mockResolvedValue([{ rawValue: ' hello-world ' }]);
const onResult = vi.fn();
installMediaDevices(getUserMedia);
installBarcodeDetector(detect);
render(
<PlatformProfileQrScannerModal
error={null}
result={null}
onClose={vi.fn()}
onError={vi.fn()}
onResult={onResult}
/>,
);
await act(async () => {
await flushPromises();
});
expect(getUserMedia).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(360);
await flushPromises();
});
expect(onResult).toHaveBeenCalledWith('hello-world');
expect(detect).toHaveBeenCalledTimes(1);
expect(stop).toHaveBeenCalledTimes(1);
});
test('releases camera resource when modal unmounts before recognition', async () => {
const stop = vi.fn();
const stream = buildStream([{ stop }]);
const getUserMedia = vi.fn().mockResolvedValue(stream);
const detect = vi.fn().mockResolvedValue([]);
installMediaDevices(getUserMedia);
installBarcodeDetector(detect);
const { unmount } = render(
<PlatformProfileQrScannerModal
error={null}
result={null}
onClose={vi.fn()}
onError={vi.fn()}
onResult={vi.fn()}
/>,
);
await act(async () => {
await flushPromises();
});
expect(getUserMedia).toHaveBeenCalledTimes(1);
unmount();
expect(stop).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('dialog', { name: '扫码' })).toBeNull();
});
});
function buildStream(tracks: MockTrack[]): MockStream {
return {
getTracks: () => tracks,
};
}
function installMediaDevices(getUserMedia: ReturnType<typeof vi.fn>) {
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia },
});
}
function installBarcodeDetector(detect: ReturnType<typeof vi.fn>) {
class MockBarcodeDetector {
detect = detect;
}
(
globalThis as typeof globalThis & {
BarcodeDetector?: unknown;
}
).BarcodeDetector = MockBarcodeDetector;
}
async function flushPromises() {
await Promise.resolve();
}

View File

@@ -0,0 +1,196 @@
import { useEffect, useRef } from 'react';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
type BarcodeDetectorLike = {
detect: (source: CanvasImageSource) => Promise<Array<{ rawValue?: string }>>;
};
type BarcodeDetectorConstructorLike = new (options?: {
formats?: string[];
}) => BarcodeDetectorLike;
export type PlatformProfileQrScannerModalProps = {
error: string | null;
result: string | null;
onClose: () => void;
onError: (message: string) => void;
onResult: (value: string) => void;
};
function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null {
const maybeDetector = (
globalThis as unknown as {
BarcodeDetector?: BarcodeDetectorConstructorLike;
}
).BarcodeDetector;
return typeof maybeDetector === 'function' ? maybeDetector : null;
}
/**
* 个人中心共享扫码弹层。
* 保持首页现有扫码语义:申请摄像头、轮询识别、关闭时释放视频流。
*/
export function PlatformProfileQrScannerModal({
error,
result,
onClose,
onError,
onResult,
}: PlatformProfileQrScannerModalProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement) {
return;
}
let isMounted = true;
let scanTimer: number | null = null;
const detectorCtor = getBarcodeDetectorConstructor();
const detector = detectorCtor
? new detectorCtor({ formats: ['qr_code'] })
: null;
const clearScanTimer = () => {
if (scanTimer !== null) {
window.clearTimeout(scanTimer);
scanTimer = null;
}
};
const stopCamera = () => {
const stream = streamRef.current;
streamRef.current = null;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
videoElement.srcObject = null;
};
const scanVideo = async () => {
if (!isMounted || !detector || videoElement.readyState < 2) {
if (isMounted && detector) {
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
}
return;
}
try {
const codes = await detector.detect(videoElement);
const rawValue = codes[0]?.rawValue?.trim();
if (rawValue) {
clearScanTimer();
stopCamera();
onResult(rawValue);
return;
}
} catch {
onError('扫码识别失败,请调整二维码位置');
}
if (isMounted) {
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
}
};
const startCamera = async () => {
if (
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getUserMedia
) {
onError('当前浏览器不支持摄像头扫码');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: { facingMode: { ideal: 'environment' } },
});
if (!isMounted) {
stream.getTracks().forEach((track) => track.stop());
return;
}
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = stream;
videoElement.srcObject = stream;
await videoElement.play();
if (!detector) {
onError('当前浏览器暂不支持二维码识别');
return;
}
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
} catch {
onError('无法打开摄像头,请检查权限');
}
};
void startCamera();
return () => {
isMounted = false;
clearScanTimer();
stopCamera();
};
}, [onError, onResult]);
return (
<PlatformProfileModalShell
title="扫码"
onClose={onClose}
showHeader={false}
showCloseButton={false}
size="sm"
panelClassName="platform-qr-scanner-modal !max-w-sm rounded-[1.4rem]"
bodyClassName="!p-0"
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="text-base font-black"></div>
<PlatformModalCloseButton
label="关闭扫码"
onClick={onClose}
icon="×"
/>
</div>
<div className="space-y-3 px-5 py-5">
<div className="platform-qr-scanner-modal__viewport">
<video
ref={videoRef}
className="h-full w-full object-cover"
playsInline
muted
/>
<span className="platform-qr-scanner-modal__frame" />
</div>
{result ? (
<PlatformStatusMessage
tone="success"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
{result}
</PlatformStatusMessage>
) : error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
{error}
</PlatformStatusMessage>
) : null}
</div>
</PlatformProfileModalShell>
);
}

View File

@@ -0,0 +1,148 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import { PlatformProfileRechargeModal } from './PlatformProfileRechargeModal';
describe('PlatformProfileRechargeModal', () => {
test('renders point products and forwards buy action', async () => {
const user = userEvent.setup();
const onBuy = vi.fn();
render(
<PlatformProfileRechargeModal
center={{
walletBalance: 29,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [
{
productId: 'points_60',
title: '60泥点',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 30,
durationDays: 0,
badgeLabel: '首充加赠',
description: '首充加赠30泥点',
tier: 'normal',
},
],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
}}
isLoading={false}
error={null}
submittingProductId={null}
nativePayment={null}
activeTab="points"
onTabChange={vi.fn()}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={onBuy}
onConfirmNativePayment={vi.fn()}
/>,
);
expect(screen.getByText('账户充值')).toBeTruthy();
expect(screen.getByText('29泥点 · 普通用户')).toBeTruthy();
expect(screen.getByText('60+30泥点')).toBeTruthy();
await user.click(screen.getByRole('button', { name: /60.*/u }));
expect(onBuy).toHaveBeenCalledWith(
expect.objectContaining({ productId: 'points_60' }),
);
});
test('shows empty state when the selected tab has no products', () => {
render(
<PlatformProfileRechargeModal
center={{
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
}}
isLoading={false}
error={null}
submittingProductId={null}
nativePayment={null}
activeTab="membership"
onTabChange={vi.fn()}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
onConfirmNativePayment={vi.fn()}
/>,
);
expect(screen.getByText('暂无可购买套餐')).toBeTruthy();
});
test('uses shared segmented tabs for recharge type switching', async () => {
const user = userEvent.setup();
const onTabChange = vi.fn();
render(
<PlatformProfileRechargeModal
center={{
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
}}
isLoading={false}
error={null}
submittingProductId={null}
nativePayment={null}
activeTab="points"
onTabChange={onTabChange}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
onConfirmNativePayment={vi.fn()}
/>,
);
const tablist = screen.getByRole('tablist', { name: '充值类型' });
const pointsTab = screen.getByRole('tab', { name: '泥点充值' });
const membershipTab = screen.getByRole('tab', { name: '会员卡' });
expect(tablist.className).toContain('grid');
expect(tablist.className).toContain('grid-cols-2');
expect(pointsTab.getAttribute('aria-selected')).toBe('true');
expect(membershipTab.getAttribute('aria-selected')).toBe('false');
await user.click(membershipTab);
expect(onTabChange).toHaveBeenCalledWith('membership');
});
});

View File

@@ -0,0 +1,270 @@
import QRCode from 'qrcode';
import { useEffect, useState } from 'react';
import type {
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
} from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformOptionSegment } from '../common/PlatformSegmentedTabPresets';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
import type {
NativeWechatPaymentState,
RechargeTab,
} from './usePlatformProfileCenterController';
import { buildMembershipLabel } from '../rpg-entry/rpgEntryProfileFundsViewModel';
import { formatSnapshotTime } from '../rpg-entry/rpgEntryProfileDashboardPresentation';
import {
buildRechargeProductValueLabel,
formatRechargePrice,
} from '../rpg-entry/rpgEntryProfileFundsViewModel';
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
const RECHARGE_TAB_ITEMS: Array<{ id: RechargeTab; label: string }> = [
{ id: 'points', label: '泥点充值' },
{ id: 'membership', label: '会员卡' },
];
export type PlatformProfileRechargeModalProps = {
center: ProfileRechargeCenterResponse | null;
isLoading: boolean;
error: string | null;
submittingProductId: string | null;
nativePayment: NativeWechatPaymentState | null;
activeTab: RechargeTab;
onTabChange: (tab: RechargeTab) => void;
onClose: () => void;
onRetry: () => void;
onBuy: (product: ProfileRechargeProduct) => void;
onConfirmNativePayment: () => void;
};
/**
* 生成微信 Native 支付二维码图片,保持首页现有二维码尺寸与容错行为。
*/
function useWechatNativeQrCode(codeUrl: string | null) {
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setQrImageUrl(null);
if (!codeUrl) {
return () => {
cancelled = true;
};
}
void QRCode.toDataURL(codeUrl, {
errorCorrectionLevel: 'M',
margin: 1,
width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE,
}).then((dataUrl) => {
if (!cancelled) {
setQrImageUrl(dataUrl);
}
});
return () => {
cancelled = true;
};
}, [codeUrl]);
return qrImageUrl;
}
/**
* 充值套餐卡片,沿用 RPG 首页当前视觉和交互语义。
*/
function RechargeProductCard({
product,
submittingProductId,
onBuy,
}: {
product: ProfileRechargeProduct;
submittingProductId: string | null;
onBuy: (product: ProfileRechargeProduct) => void;
}) {
const submitting = submittingProductId === product.productId;
const badgeLabel = product.badgeLabel;
const value = buildRechargeProductValueLabel(product);
return (
<PlatformSubpanel
as="button"
type="button"
surface="platform"
onClick={() => onBuy(product)}
disabled={Boolean(submittingProductId)}
interactive
radius="sm"
padding="none"
className="platform-interactive-card relative min-h-[7.25rem] px-3.5 py-3.5 text-left"
>
{badgeLabel ? (
<PlatformPillBadge
tone="warning"
size="xxs"
className="absolute right-3 top-3 max-w-[7rem] truncate px-2 py-0.5 tracking-[0.18em]"
>
{badgeLabel}
</PlatformPillBadge>
) : null}
<div className="pr-20 text-sm font-black text-[var(--platform-text-strong)]">
{product.title}
</div>
<div className="mt-3 text-2xl font-black text-[var(--platform-text-strong)]">
{value}
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-sm font-bold text-[var(--platform-text-soft)]">
{formatRechargePrice(product.priceCents)}
</span>
<span className="platform-primary-button rounded-full px-3 py-1.5 text-xs font-black">
{submitting ? '处理中' : '购买'}
</span>
</div>
</PlatformSubpanel>
);
}
/**
* 个人中心充值弹窗,共享给不同入口复用,但保持现有 props 与文案不变。
*/
export function PlatformProfileRechargeModal({
center,
isLoading,
error,
submittingProductId,
nativePayment,
activeTab,
onTabChange,
onClose,
onRetry,
onBuy,
onConfirmNativePayment,
}: PlatformProfileRechargeModalProps) {
const nativeQrImageUrl = useWechatNativeQrCode(
nativePayment?.codeUrl ?? null,
);
const products =
activeTab === 'points'
? (center?.pointProducts ?? [])
: (center?.membershipProducts ?? []);
const memberLabel = buildMembershipLabel(
center?.membership,
formatSnapshotTime,
);
return (
<PlatformProfileModalShell
title="账户充值"
description={
center ? `${center.walletBalance}泥点 · ${memberLabel}` : '读取中'
}
onClose={onClose}
closeLabel="关闭账户充值"
size="md"
panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]"
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
>
<PlatformOptionSegment
items={RECHARGE_TAB_ITEMS}
activeId={activeTab}
onChange={onTabChange}
variant="profile"
ariaLabel="充值类型"
/>
<PlatformAsyncStatePanel
errorState={
error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-4 rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
</PlatformActionButton>
</PlatformStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={4}
containerClassName="mt-4 grid gap-3 sm:grid-cols-2"
itemClassName="h-28 rounded-[1.15rem] bg-white/10"
/>
}
isEmpty={products.length === 0}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-4"
>
</PlatformEmptyState>
}
>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{products.map((product) => (
<RechargeProductCard
key={product.productId}
product={product}
submittingProductId={submittingProductId}
onBuy={onBuy}
/>
))}
</div>
</PlatformAsyncStatePanel>
{nativePayment ? (
<PlatformSubpanel
as="div"
radius="sm"
padding="md"
className="mt-4 text-center"
>
<div className="text-sm font-black"></div>
<div className="mx-auto mt-3 flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
{nativeQrImageUrl ? (
<img
src={nativeQrImageUrl}
alt="微信 Native 支付二维码"
className="h-full w-full"
/>
) : (
<span className="text-xs font-semibold text-slate-500">
</span>
)}
</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-4 disabled:cursor-wait"
onClick={onConfirmNativePayment}
disabled={nativePayment.isConfirming}
>
{nativePayment.isConfirming ? '确认中' : '我已支付'}
</PlatformActionButton>
</PlatformSubpanel>
) : null}
</PlatformProfileModalShell>
);
}

View File

@@ -0,0 +1,124 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import { PlatformProfileReferralModal } from './PlatformProfileReferralModal';
function buildCenter(
overrides: Partial<ProfileReferralInviteCenterResponse> = {},
): ProfileReferralInviteCenterResponse {
return {
inviteCode: 'ABCD1234',
inviteLinkPath: '/invite/ABCD1234',
invitedCount: 1,
rewardedInviteCount: 1,
todayInviterRewardCount: 1,
todayInviterRewardRemaining: 9,
rewardPoints: 66,
invitedUsers: [
{
userId: 'user-2',
displayName: '海盐',
avatarUrl: null,
boundAt: '2026-06-10T08:00:00.000Z',
},
],
hasRedeemedCode: false,
boundInviterUserId: null,
boundAt: null,
updatedAt: '2026-06-10T08:00:00.000Z',
...overrides,
};
}
describe('PlatformProfileReferralModal', () => {
test('renders invite panel with shared profile content', () => {
render(
<PlatformProfileReferralModal
panel="invite"
center={buildCenter()}
isLoading={false}
isSubmittingRedeem={false}
redeemCode=""
copyInviteState="idle"
error={null}
success={null}
onClose={vi.fn()}
onCopyInvite={vi.fn()}
onRedeemCodeChange={vi.fn()}
onSubmitRedeemCode={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '邀请好友' });
expect(within(dialog).getByText('邀请码')).toBeTruthy();
expect(within(dialog).getByText('ABCD1234')).toBeTruthy();
expect(within(dialog).getByText('海盐')).toBeTruthy();
expect(within(dialog).getByText('成功邀请')).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: //u }),
).toBeTruthy();
});
test('submits redeem panel with the shared form shell', async () => {
const user = userEvent.setup();
const onRedeemCodeChange = vi.fn();
const onSubmitRedeemCode = vi.fn();
render(
<PlatformProfileReferralModal
panel="redeem"
center={buildCenter()}
isLoading={false}
isSubmittingRedeem={false}
redeemCode="ab12"
copyInviteState="idle"
error={null}
success={null}
onClose={vi.fn()}
onCopyInvite={vi.fn()}
onRedeemCodeChange={onRedeemCodeChange}
onSubmitRedeemCode={onSubmitRedeemCode}
/>,
);
const dialog = screen.getByRole('dialog', { name: '填邀请码' });
const input = within(dialog).getByRole('textbox', { name: '邀请码' });
await user.type(input, ' c');
expect(onRedeemCodeChange).toHaveBeenCalled();
await user.click(within(dialog).getByRole('button', { name: '提交' }));
expect(onSubmitRedeemCode).toHaveBeenCalledTimes(1);
});
test('renders community QR panels', () => {
render(
<PlatformProfileReferralModal
panel="community"
center={buildCenter()}
isLoading={false}
isSubmittingRedeem={false}
redeemCode=""
copyInviteState="idle"
error={null}
success={null}
onClose={vi.fn()}
onCopyInvite={vi.fn()}
onRedeemCodeChange={vi.fn()}
onSubmitRedeemCode={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '玩家社区' });
expect(within(dialog).getByAltText('玩家社区微信群二维码')).toBeTruthy();
expect(within(dialog).getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
expect(within(dialog).getByText('微信群')).toBeTruthy();
expect(within(dialog).getByText('QQ群')).toBeTruthy();
});
});

View File

@@ -0,0 +1,317 @@
import { Copy } from 'lucide-react';
import type { ReactNode } from 'react';
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
import type { CopyFeedbackState } from '../common/useCopyFeedback';
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
import type { ProfileReferralPanel } from './usePlatformProfileCenterController';
type PlatformProfileReferralModalProps = {
panel: ProfileReferralPanel;
center: ProfileReferralInviteCenterResponse | null;
isLoading: boolean;
isSubmittingRedeem: boolean;
redeemCode: string;
copyInviteState: CopyFeedbackState;
error: string | null;
success: string | null;
onClose: () => void;
onCopyInvite: () => void;
onRedeemCodeChange: (value: string) => void;
onSubmitRedeemCode: () => void;
};
const COMMUNITY_QR_CODES = [
{
label: '微信群',
src: communityWechatQrImage,
alt: '玩家社区微信群二维码',
},
{
label: 'QQ群',
src: communityQqQrImage,
alt: '玩家社区 QQ 群二维码',
},
] as const;
function ProfileReferralUserAvatar({
name,
avatarUrl,
}: {
name: string;
avatarUrl: string | null;
}) {
const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
return (
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#ff4056] text-xs font-black text-white">
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
avatarLabel
)}
</span>
);
}
function resolvePanelTitle(panel: ProfileReferralPanel) {
if (panel === 'invite') {
return '邀请好友';
}
if (panel === 'redeem') {
return '填邀请码';
}
return '玩家社区';
}
/**
* 个人中心邀请能力统一弹层。
* 承接邀请码、填码和社区二维码三种 profile panel避免首页继续内联重复白底浮层实现。
*/
export function PlatformProfileReferralModal({
panel,
center,
isLoading,
isSubmittingRedeem,
redeemCode,
copyInviteState,
error,
success,
onClose,
onCopyInvite,
onRedeemCodeChange,
onSubmitRedeemCode,
}: PlatformProfileReferralModalProps) {
const title = resolvePanelTitle(panel);
const normalizedRedeemCode = redeemCode
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase();
let content: ReactNode;
if (panel === 'community') {
content = (
<div className="mt-5 grid grid-cols-2 gap-3">
{COMMUNITY_QR_CODES.map((qrCode) => (
<PlatformSubpanel
as="div"
key={qrCode.label}
surface="flat"
radius="xs"
padding="xs"
className="text-center"
>
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-200 bg-white p-1.5">
<img
src={qrCode.src}
alt={qrCode.alt}
className="h-full w-full object-contain"
loading="lazy"
decoding="async"
/>
</div>
<div className="mt-2 text-sm font-bold text-zinc-700">
{qrCode.label}
</div>
</PlatformSubpanel>
))}
</div>
);
} else if (panel === 'redeem') {
content = (
<PlatformAsyncStatePanel
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={2}
containerClassName="mt-5 space-y-3"
itemClassName="h-12 even:h-11"
/>
}
isEmpty={Boolean(center?.hasRedeemedCode)}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
tone="base"
className="mt-5"
>
</PlatformEmptyState>
}
>
<form
className="mt-5 space-y-3"
onSubmit={(event) => {
event.preventDefault();
onSubmitRedeemCode();
}}
>
<PlatformTextField
value={redeemCode}
onChange={(event) => onRedeemCodeChange(event.target.value)}
size="lg"
density="roomy"
tone="rose"
className="rounded-xl text-center font-black uppercase tracking-[0.16em]"
placeholder="邀请码"
aria-label="邀请码"
autoComplete="off"
autoFocus
/>
<PlatformActionButton
type="submit"
surface="profile"
fullWidth
size="md"
className="rounded-xl"
disabled={isSubmittingRedeem || !normalizedRedeemCode}
>
{isSubmittingRedeem ? '提交中' : '提交'}
</PlatformActionButton>
</form>
</PlatformAsyncStatePanel>
);
} else {
content = (
<PlatformAsyncStatePanel
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={2}
containerClassName="mt-5 space-y-3"
itemClassName="h-20 odd:h-20 even:h-10"
/>
}
>
<div className="mt-5 space-y-3">
<PlatformSubpanel
as="div"
surface="flat"
radius="xs"
padding="md"
className="text-center"
>
<PlatformFieldLabel
variant="section"
className="block text-[11px] text-zinc-500"
>
</PlatformFieldLabel>
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
{center?.inviteCode ?? '--------'}
</div>
</PlatformSubpanel>
<PlatformStatusMessage
tone="warning"
surface="profile"
size="md"
className="space-y-0.5 px-3.5 font-semibold"
>
<div>
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
</div>
<div></div>
</PlatformStatusMessage>
<CopyFeedbackButton
state={copyInviteState}
onClick={onCopyInvite}
disabled={!center?.inviteCode}
idleLabel="复制邀请"
copiedLabel="已复制"
failedLabel="复制失败"
idleIcon={<Copy className="h-4 w-4" />}
actionSurface="profile"
actionSize="md"
actionFullWidth
className="gap-2 rounded-xl"
/>
<PlatformSubpanel as="div" surface="flat" radius="xs" padding="sm">
<PlatformFieldLabel
variant="section"
className="block text-zinc-900"
>
</PlatformFieldLabel>
{center?.invitedUsers?.length ? (
<div className="mt-3 max-h-44 space-y-2 overflow-y-auto pr-1">
{center.invitedUsers.map((user) => (
<PlatformProfileContentRow
key={`${user.userId}-${user.boundAt}`}
surface="soft"
radius="xs"
padding="row"
className="flex items-center gap-3"
>
<ProfileReferralUserAvatar
name={user.displayName}
avatarUrl={user.avatarUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-zinc-900">
{user.displayName || '玩家'}
</div>
</div>
</PlatformProfileContentRow>
))}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="compact"
className="mt-3 text-center text-xs font-semibold leading-normal"
>
</PlatformEmptyState>
)}
</PlatformSubpanel>
</div>
</PlatformAsyncStatePanel>
);
}
return (
<PlatformProfileSecondaryModalShell
title={title}
onClose={onClose}
closeVariant="floatingPlain"
closeIcon="×"
overlayTone="soft"
panelClassName="relative !max-w-[24rem] bg-white text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
contentClassName="relative px-5 pb-5 pt-4"
>
<div className="text-center text-xl font-black">{title}</div>
{content}
{error ? (
<PlatformStatusMessage tone="error" className="mt-4">
{error}
</PlatformStatusMessage>
) : null}
{success ? (
<PlatformStatusMessage tone="success" className="mt-4">
{success}
</PlatformStatusMessage>
) : null}
</PlatformProfileSecondaryModalShell>
);
}

View File

@@ -0,0 +1,75 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import { PlatformProfileRewardCodeRedeemModal } from './PlatformProfileRewardCodeRedeemModal';
describe('PlatformProfileRewardCodeRedeemModal', () => {
test('submits on button click and enter key', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const onChange = vi.fn();
render(
<PlatformProfileRewardCodeRedeemModal
value="ab12"
isSubmitting={false}
error={null}
success={null}
onChange={onChange}
onSubmit={onSubmit}
onClose={vi.fn()}
/>,
);
const input = screen.getByRole('textbox', { name: '兑换码' });
await user.type(input, 'c');
await user.keyboard('{Enter}');
await user.click(screen.getByRole('button', { name: '兑换' }));
expect(onChange).toHaveBeenCalled();
expect(onSubmit).toHaveBeenCalledTimes(2);
});
test('disables submit when the code is blank', () => {
render(
<PlatformProfileRewardCodeRedeemModal
value=" "
isSubmitting={false}
error={null}
success={null}
onChange={vi.fn()}
onSubmit={vi.fn()}
onClose={vi.fn()}
/>,
);
expect(
screen.getByRole('button', { name: '兑换' }).hasAttribute('disabled'),
).toBe(true);
});
test('reuses the shared profile modal footer chrome for submit action', () => {
render(
<PlatformProfileRewardCodeRedeemModal
value="ab12"
isSubmitting={false}
error={null}
success={null}
onChange={vi.fn()}
onSubmit={vi.fn()}
onClose={vi.fn()}
/>,
);
const submitButton = screen.getByRole('button', { name: '兑换' });
const footer = submitButton.closest('div');
expect(footer?.className).toContain('border-t');
expect(footer?.className).toContain('pb-5');
expect(footer?.className).toContain('pt-0');
});
});

View File

@@ -0,0 +1,87 @@
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
export type PlatformProfileRewardCodeRedeemModalProps = {
value: string;
isSubmitting: boolean;
error: string | null;
success: string | null;
onChange: (value: string) => void;
onSubmit: () => void;
onClose: () => void;
};
/**
* 个人中心兑换码弹窗。
* 保持原有输入、回车提交、禁用态和反馈消息语义不变。
*/
export function PlatformProfileRewardCodeRedeemModal({
value,
isSubmitting,
error,
success,
onChange,
onSubmit,
onClose,
}: PlatformProfileRewardCodeRedeemModalProps) {
return (
<PlatformProfileModalShell
title="兑换码"
onClose={onClose}
closeLabel="关闭兑换码"
panelClassName="platform-recharge-modal !max-w-sm rounded-[1.4rem]"
bodyClassName="space-y-3 px-5 py-5"
footerClassName="px-5 pb-5 pt-0"
footer={
<PlatformActionButton
surface="profile"
fullWidth
size="md"
className="disabled:opacity-50"
onClick={onSubmit}
disabled={isSubmitting || !value.trim()}
>
{isSubmitting ? '兑换中' : '兑换'}
</PlatformActionButton>
}
>
<PlatformTextField
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSubmit();
}
}}
size="sm"
density="roomy"
className="uppercase tracking-normal"
placeholder="输入兑换码"
aria-label="兑换码"
autoFocus
/>
{error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
{error}
</PlatformStatusMessage>
) : null}
{success ? (
<PlatformStatusMessage
tone="success"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
{success}
</PlatformStatusMessage>
) : null}
</PlatformProfileModalShell>
);
}

View File

@@ -0,0 +1,98 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import { PlatformProfileTaskCenterModal } from './PlatformProfileTaskCenterModal';
describe('PlatformProfileTaskCenterModal', () => {
test('renders claimable tasks and forwards claim action', async () => {
const user = userEvent.setup();
const onClaim = vi.fn();
render(
<PlatformProfileTaskCenterModal
center={{
dayKey: 20260610,
walletBalance: 66,
updatedAt: '2026-06-10T08:00:00.000Z',
tasks: [
{
taskId: 'task-1',
title: '每日登录',
description: '登录一次',
eventKey: 'daily_login',
cycle: 'daily',
rewardPoints: 10,
status: 'claimable',
progressCount: 1,
threshold: 1,
dayKey: 20260610,
claimedAt: null,
updatedAt: '2026-06-10T08:00:00.000Z',
},
],
}}
isLoading={false}
error={null}
success={null}
claimingTaskId={null}
fallbackBalance={12}
onClose={vi.fn()}
onRetry={vi.fn()}
onClaim={onClaim}
/>,
);
const dialog = screen.getByRole('dialog', { name: '每日任务' });
expect(within(dialog).getByText('66泥点')).toBeTruthy();
expect(within(dialog).getByText('每日登录')).toBeTruthy();
expect(within(dialog).getByText('1/1')).toBeTruthy();
expect(within(dialog).getByText('可领取')).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: '领取' }));
expect(onClaim).toHaveBeenCalledWith('task-1');
});
test('keeps incomplete tasks disabled', () => {
render(
<PlatformProfileTaskCenterModal
center={{
dayKey: 20260610,
walletBalance: 20,
updatedAt: '2026-06-10T08:00:00.000Z',
tasks: [
{
taskId: 'task-2',
title: '分享一次',
description: '完成一次分享',
eventKey: 'daily_share',
cycle: 'daily',
rewardPoints: 8,
status: 'incomplete',
progressCount: 0,
threshold: 1,
dayKey: 20260610,
claimedAt: null,
updatedAt: '2026-06-10T08:00:00.000Z',
},
],
}}
isLoading={false}
error={null}
success={null}
claimingTaskId={null}
fallbackBalance={12}
onClose={vi.fn()}
onRetry={vi.fn()}
onClaim={vi.fn()}
/>,
);
expect(
screen.getByRole('button', { name: '未完成' }).hasAttribute('disabled'),
).toBe(true);
});
});

View File

@@ -0,0 +1,148 @@
import type { ProfileTaskCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
import {
buildProfileTaskProgressLabel,
getProfileTaskClaimButtonLabel,
getProfileTaskStatusLabel,
selectProfileTaskCenterTasks,
} from '../rpg-entry/rpgEntryProfileTaskViewModel';
export type PlatformProfileTaskCenterModalProps = {
center: ProfileTaskCenterResponse | null;
isLoading: boolean;
error: string | null;
success: string | null;
claimingTaskId: string | null;
fallbackBalance: number;
onClose: () => void;
onRetry: () => void;
onClaim: (taskId: string) => void;
};
/**
* 个人中心每日任务弹窗。
* 复用任务中心 view model保持原有任务筛选、状态文案和领取交互不变。
*/
export function PlatformProfileTaskCenterModal({
center,
isLoading,
error,
success,
claimingTaskId,
fallbackBalance,
onClose,
onRetry,
onClaim,
}: PlatformProfileTaskCenterModalProps) {
const tasks = selectProfileTaskCenterTasks(center?.tasks ?? []);
const walletBalance = center?.walletBalance ?? fallbackBalance;
return (
<PlatformProfileModalShell
title="每日任务"
description={`${walletBalance}泥点`}
onClose={onClose}
closeLabel="关闭每日任务"
panelClassName="platform-recharge-modal !max-w-md rounded-[1.4rem]"
bodyClassName="space-y-3 px-5 py-5"
>
{success ? (
<PlatformStatusMessage
tone="success"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
{success}
</PlatformStatusMessage>
) : null}
<PlatformAsyncStatePanel
errorState={
error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
</PlatformActionButton>
</PlatformStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={2}
containerClassName="space-y-3"
itemClassName="h-20 rounded-2xl bg-white/10"
/>
}
isEmpty={tasks.length === 0}
emptyState={
<PlatformEmptyState surface="subpanel" size="inline">
</PlatformEmptyState>
}
>
<div className="space-y-3">
{tasks.map((task) => {
const isClaimable = task.status === 'claimable';
const isClaiming = claimingTaskId === task.taskId;
const progressLabel = buildProfileTaskProgressLabel(task);
return (
<PlatformProfileContentRow
key={task.taskId}
radius="sm"
padding="md"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-base font-black text-[var(--platform-text-strong)]">
{task.title}
</div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{progressLabel}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
+{task.rewardPoints}
</div>
<div className="mt-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
{getProfileTaskStatusLabel(task.status)}
</div>
</div>
</div>
<PlatformActionButton
surface="profile"
fullWidth
size="sm"
className="mt-3 disabled:opacity-50"
disabled={!isClaimable || Boolean(claimingTaskId)}
onClick={() => onClaim(task.taskId)}
>
{getProfileTaskClaimButtonLabel(task, isClaiming)}
</PlatformActionButton>
</PlatformProfileContentRow>
);
})}
</div>
</PlatformAsyncStatePanel>
</PlatformProfileModalShell>
);
}

View File

@@ -0,0 +1,58 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import { PlatformProfileWalletLedgerModal } from './PlatformProfileWalletLedgerModal';
describe('PlatformProfileWalletLedgerModal', () => {
test('renders ledger entries with shared balance presentation', () => {
render(
<PlatformProfileWalletLedgerModal
ledger={{
entries: [
{
id: 'ledger-1',
sourceType: 'daily_task_reward',
amountDelta: 12,
balanceAfter: 88,
createdAt: '2026-06-10T08:00:00.000Z',
},
],
}}
fallbackBalance={40}
isLoading={false}
error={null}
onClose={vi.fn()}
onRetry={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '泥点账单' });
expect(within(dialog).getByText('88泥点')).toBeTruthy();
expect(within(dialog).getByText('每日任务奖励')).toBeTruthy();
expect(within(dialog).getByText('+12')).toBeTruthy();
expect(within(dialog).getByText('余额 88')).toBeTruthy();
});
test('retries from the shared error state', async () => {
const user = userEvent.setup();
const onRetry = vi.fn();
render(
<PlatformProfileWalletLedgerModal
ledger={null}
fallbackBalance={40}
isLoading={false}
error="账单加载失败"
onClose={vi.fn()}
onRetry={onRetry}
/>,
);
await user.click(screen.getByRole('button', { name: '重新加载' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,140 @@
import { Coins } from 'lucide-react';
import type { ProfileWalletLedgerResponse } from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHeader';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
import { buildWalletLedgerPresentation } from '../rpg-entry/rpgEntryProfileFundsViewModel';
import { formatPlatformWorldTime } from '../rpg-entry/rpgEntryWorldPresentation';
export type PlatformProfileWalletLedgerModalProps = {
ledger: ProfileWalletLedgerResponse | null;
fallbackBalance: number;
isLoading: boolean;
error: string | null;
onClose: () => void;
onRetry: () => void;
};
/**
* 个人中心泥点账单弹窗。
* 保持 RPG 首页里既有的展示文案、状态分支和交互,仅把实现提取为共享组件。
*/
export function PlatformProfileWalletLedgerModal({
ledger,
fallbackBalance,
isLoading,
error,
onClose,
onRetry,
}: PlatformProfileWalletLedgerModalProps) {
const walletLedgerPresentation = buildWalletLedgerPresentation(
ledger,
fallbackBalance,
);
const entries = walletLedgerPresentation.entries;
return (
<PlatformProfileSecondaryModalShell
title="泥点账单"
onClose={onClose}
closeLabel="关闭泥点账单"
closeButtonClassName="bg-white/78"
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[30rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
contentClassName="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
>
<PlatformProfileSummaryHeader
kicker="LEDGER"
title="泥点账单"
badge={
<PlatformPillBadge
tone="profile"
icon={<Coins className="h-3.5 w-3.5 text-[#ff4056]" />}
className="bg-white/70"
>
{walletLedgerPresentation.balanceLabel}
</PlatformPillBadge>
}
/>
<PlatformAsyncStatePanel
errorState={
error ? (
<PlatformStatusMessage
tone="error"
className="mt-4 rounded-xl py-3"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
shape="pill"
size="xs"
className="mt-3"
onClick={onRetry}
>
</PlatformActionButton>
</PlatformStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={5}
containerClassName="mt-5 space-y-3"
itemClassName="h-16"
/>
}
isEmpty={entries.length === 0}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-5 py-8"
>
</PlatformEmptyState>
}
>
<div className="mt-5 space-y-2.5">
{entries.map((entry) => (
<PlatformProfileContentRow
key={entry.id}
surface="flat"
radius="xs"
padding="none"
className="flex items-center justify-between gap-3 px-3 py-3 shadow-sm"
>
<div className="min-w-0">
<div className="truncate text-sm font-black text-zinc-900">
{entry.sourceLabel}
</div>
<div className="mt-1 text-xs font-semibold text-zinc-500">
{formatPlatformWorldTime(entry.createdAt)}
</div>
</div>
<div className="shrink-0 text-right">
<div
className={`text-base font-black ${
entry.isIncome ? 'text-emerald-600' : 'text-rose-500'
}`}
>
{entry.amountLabel}
</div>
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
{entry.balanceLabel}
</div>
</div>
</PlatformProfileContentRow>
))}
</div>
</PlatformAsyncStatePanel>
</PlatformProfileSecondaryModalShell>
);
}

View File

@@ -1,8 +1,4 @@
import { CheckCircle2, Copy } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { UnifiedModal } from '../common/UnifiedModal';
import { PlatformReportDialog } from '../common/PlatformReportDialog';
export type PlatformTaskCompletionDialogPayload = {
source: string;
@@ -16,109 +12,35 @@ type PlatformTaskCompletionDialogProps = {
panelClassName?: string;
};
function buildPlatformTaskCompletionReport(
completion: PlatformTaskCompletionDialogPayload,
) {
return [`来源:${completion.source}`, `状态:${completion.message}`].join(
'\n',
);
}
export function PlatformTaskCompletionDialog({
completion,
onClose,
overlayClassName = 'platform-theme platform-theme--light !items-center',
panelClassName = 'platform-remap-surface rounded-[1.5rem]',
}: PlatformTaskCompletionDialogProps) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const resetTimerRef = useRef<number | null>(null);
const reportText = useMemo(
() => (completion ? buildPlatformTaskCompletionReport(completion) : ''),
[completion],
);
useEffect(
() => () => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
},
[],
);
useEffect(() => {
setCopyState('idle');
}, [completion?.source, completion?.message]);
const copyCompletion = () => {
if (!reportText) {
return;
}
void copyTextToClipboard(reportText).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
resetTimerRef.current = window.setTimeout(() => {
resetTimerRef.current = null;
setCopyState('idle');
}, 1400);
});
};
return (
<UnifiedModal
<PlatformReportDialog
open={Boolean(completion)}
title="生成完成"
onClose={onClose}
size="sm"
copyIdleLabel="复制内容"
fields={
completion
? [
{
label: '来源',
value: completion.source,
},
{
label: '状态',
value: completion.message,
multiline: true,
},
]
: []
}
overlayClassName={overlayClassName}
panelClassName={panelClassName}
bodyClassName="space-y-3 px-4 py-4 sm:px-5 sm:py-5"
footerClassName="justify-end px-4 py-4 sm:px-5"
footer={
<button
type="button"
onClick={copyCompletion}
disabled={!reportText}
className="platform-button platform-button--primary w-full justify-center gap-2 sm:w-auto"
>
{copyState === 'copied' ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
{copyState === 'copied'
? '已复制'
: copyState === 'failed'
? '复制失败'
: '复制内容'}
</button>
}
>
{completion ? (
<>
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
</div>
<div className="mt-1 break-words text-sm font-semibold leading-5 text-[var(--platform-text-strong)]">
{completion.source}
</div>
</div>
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
</div>
<div className="mt-1 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
{completion.message}
</div>
</div>
</>
) : null}
</UnifiedModal>
/>
);
}

View File

@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import * as clipboardService from '../../services/clipboard';
import {
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
@@ -34,6 +35,10 @@ vi.mock('../ResolvedAssetImage', () => ({
),
}));
vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
function createPuzzleEntry(): PlatformPuzzleGalleryCard {
return {
sourceType: 'puzzle',
@@ -108,6 +113,7 @@ function createWoodenFishEntry(): PlatformWoodenFishGalleryCard {
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
test('PlatformWorkDetailView renders compact stats and date time', () => {
@@ -134,9 +140,30 @@ test('PlatformWorkDetailView renders compact stats and date time', () => {
expect(screen.getByText('2026-04-25')).toBeTruthy();
expect(screen.getAllByText('次')).toHaveLength(2);
expect(screen.getByText('赞')).toBeTruthy();
expect(screen.getByRole('button', { name: '点赞 4赞' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品改造' })).toBeTruthy();
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
const tagChip = screen
.getAllByText('拼图')
.find((element) =>
element.className.includes('platform-work-detail__chip'),
);
expect(tagChip?.className).toContain('rounded-full');
expect(tagChip?.className).toContain('bg-[var(--platform-neutral-bg)]');
expect(screen.getByRole('button', { name: '返回' }).className).toContain(
'platform-icon-button',
);
expect(screen.getByRole('button', { name: '分享' }).className).toContain(
'platform-icon-button',
);
const likeButton = screen.getByRole('button', { name: '点赞 4赞' });
expect(likeButton).toBeTruthy();
expect(likeButton.className).toContain('platform-action-button--accent-soft');
expect(likeButton.className).toContain('platform-work-detail__like');
expect(likeButton.className).toContain('flex-col');
const remixAction = screen.getByRole('button', { name: '作品改造' });
const startAction = screen.getByRole('button', { name: '启动' });
expect(remixAction.className).toContain('platform-button');
expect(remixAction.className).toContain('platform-work-detail__remix');
expect(startAction.className).toContain('platform-button');
expect(startAction.className).toContain('platform-work-detail__start');
});
test('PlatformWorkDetailView prefers resolved public user display name', () => {
@@ -209,6 +236,70 @@ test('PlatformWorkDetailView calls like handler', () => {
expect(onLike).toHaveBeenCalledTimes(1);
});
test('PlatformWorkDetailView copies public work code and share text', async () => {
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
render(
<PlatformWorkDetailView
entry={createPuzzleEntry()}
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
const publicWorkCodeButton = screen.getByRole('button', { name: 'PZ-001' });
expect(publicWorkCodeButton.className).toContain('rounded-full');
expect(publicWorkCodeButton.className).toContain(
'bg-[var(--platform-neutral-bg)]',
);
fireEvent.click(publicWorkCodeButton);
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith('PZ-001');
expect(await screen.findByText('已复制')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '分享' }));
expect(clipboardService.copyTextToClipboard).toHaveBeenLastCalledWith(
expect.stringContaining('作品号PZ-001'),
);
const shareCopiedMessage = await screen.findByText('分享内容已复制');
expect(shareCopiedMessage.className).toContain('platform-status-message');
expect(shareCopiedMessage.className).toContain(
'bg-[var(--platform-success-bg)]',
);
});
test('PlatformWorkDetailView shows failed share feedback as an error status', async () => {
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(false);
render(
<PlatformWorkDetailView
entry={createPuzzleEntry()}
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '分享' }));
const shareFailedMessage = await screen.findByText('分享失败');
expect(shareFailedMessage.className).toContain('platform-status-message');
expect(shareFailedMessage.className).toContain(
'bg-[var(--platform-button-danger-fill)]',
);
});
test('PlatformWorkDetailView switches remix action label for owned work edit', () => {
render(
<PlatformWorkDetailView
@@ -280,6 +371,12 @@ test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
'.platform-work-detail__app-icon img',
);
expect(appIconImage?.getAttribute('src')).toBe('/level-1.png');
expect(
screen.getByRole('button', { name: '上一张关卡图' }).className,
).toContain('platform-icon-button');
expect(
screen.getByRole('button', { name: '下一张关卡图' }).className,
).toContain('platform-icon-button');
fireEvent.click(screen.getByRole('button', { name: '下一张关卡图' }));

View File

@@ -1,10 +1,8 @@
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
CircleHelp,
Clock3,
Copy,
Gamepad2,
GitFork,
Heart,
@@ -16,7 +14,13 @@ import { useEffect, useMemo, useState } from 'react';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformDetailShareActions } from '../common/PlatformDetailShareActions';
import { PlatformDetailTopbar } from '../common/PlatformDetailTopbar';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPlatformWorldDisplayTags,
@@ -30,8 +34,8 @@ import {
isPuzzleClearGalleryEntry,
isWoodenFishGalleryEntry,
type PlatformPublicGalleryCard,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformPublicWorkCode,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldStats,
} from '../rpg-entry/rpgEntryWorldPresentation';
@@ -130,12 +134,16 @@ export function PlatformWorkDetailView({
entry,
authorSummary,
);
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const {
copyState,
copyText: copyWorkCodeText,
resetCopyState: resetWorkCodeCopyState,
} = useCopyFeedback();
const {
copyState: shareState,
copyText: copyShareText,
resetCopyState: resetShareCopyState,
} = useCopyFeedback();
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = useMemo(
() =>
@@ -181,7 +189,14 @@ export function PlatformWorkDetailView({
useEffect(() => {
setActiveCoverIndex(0);
}, [entry.profileId, coverSlides.length]);
resetWorkCodeCopyState();
resetShareCopyState();
}, [
entry.profileId,
coverSlides.length,
resetShareCopyState,
resetWorkCodeCopyState,
]);
useEffect(() => {
setActiveCoverIndex((current) =>
@@ -224,10 +239,7 @@ export function PlatformWorkDetailView({
return;
}
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
void copyWorkCodeText(publicWorkCode);
};
const sharePublicWork = () => {
@@ -236,36 +248,32 @@ export function PlatformWorkDetailView({
}
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
void copyShareText(shareText);
};
return (
<div className="platform-work-detail">
<div className="platform-work-detail__topbar">
<button
type="button"
className="platform-work-detail__icon-button"
onClick={onBack}
aria-label="返回"
title="返回"
>
<ArrowLeft className="h-6 w-6" />
</button>
<div className="platform-work-detail__title"></div>
<button
type="button"
className="platform-work-detail__icon-button"
onClick={sharePublicWork}
disabled={!publicWorkCode}
aria-label="分享"
title="分享"
>
<Share2 className="h-5 w-5" />
</button>
</div>
<PlatformDetailTopbar
onBack={onBack}
backVariant="icon"
title={
<div className="platform-work-detail__title">
</div>
}
className="platform-work-detail__topbar"
backButtonClassName="platform-work-detail__icon-button"
trailing={
<PlatformIconButton
label="分享"
className="platform-work-detail__icon-button"
onClick={sharePublicWork}
disabled={!publicWorkCode}
title="分享"
icon={<Share2 className="h-5 w-5" />}
/>
}
/>
<div className="platform-work-detail__scroll">
<section className="platform-work-detail__cover">
@@ -296,24 +304,20 @@ export function PlatformWorkDetailView({
) : null}
{hasCoverCarousel ? (
<>
<button
type="button"
<PlatformIconButton
label="上一张关卡图"
className="platform-work-detail__cover-nav platform-work-detail__cover-nav--prev"
onClick={showPreviousCover}
aria-label="上一张关卡图"
title="上一张关卡图"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
icon={<ChevronLeft className="h-5 w-5" />}
/>
<PlatformIconButton
label="下一张关卡图"
className="platform-work-detail__cover-nav platform-work-detail__cover-nav--next"
onClick={showNextCover}
aria-label="下一张关卡图"
title="下一张关卡图"
>
<ChevronRight className="h-5 w-5" />
</button>
icon={<ChevronRight className="h-5 w-5" />}
/>
<div className="platform-work-detail__cover-dots">
{coverSlides.map((slide, index) => (
<button
@@ -376,17 +380,18 @@ export function PlatformWorkDetailView({
</span>
</div>
</div>
<button
<PlatformActionButton
type="button"
className="platform-work-detail__like"
tone="accentSoft"
onClick={onLike}
disabled={isBusy}
aria-label={`点赞 ${formatCompactCount(stats.likeCount)}`}
title="点赞"
className="platform-work-detail__like min-w-[5.2rem] flex-col gap-1 px-3 py-2.5 text-[0.8125rem] [--platform-action-accent:var(--platform-work-like-accent,#c7653d)]"
>
<Heart className="h-5 w-5 fill-current" />
</button>
</PlatformActionButton>
</div>
<div className="platform-work-detail__stats">
@@ -425,52 +430,66 @@ export function PlatformWorkDetailView({
<section className="platform-work-detail__body">
<div className="platform-work-detail__chips">
{tags.map((tag) => (
<span key={tag} className="platform-work-detail__chip">
<PlatformPillBadge
key={tag}
tone="neutralSolid"
size="sm"
className="platform-work-detail__chip"
>
{tag}
</span>
</PlatformPillBadge>
))}
</div>
<p className="platform-work-detail__copy">{entry.summaryText}</p>
{publicWorkCode ? (
<button
type="button"
className="platform-work-detail__code"
onClick={copyPublicWorkCode}
>
<Copy className="h-4 w-4" />
<span>{publicWorkCode}</span>
{copyState !== 'idle' ? (
<span>{copyState === 'copied' ? '已复制' : '复制失败'}</span>
) : null}
</button>
) : null}
<PlatformDetailShareActions
workCode={publicWorkCode}
copyState={copyState}
onCopyWorkCode={copyPublicWorkCode}
shareState={shareState}
onShare={sharePublicWork}
shareAriaLabel={`分享作品 ${entry.worldName}`}
leading={null}
showShareAction={false}
variant="solid"
className="platform-work-detail__code"
/>
{shareState !== 'idle' ? (
<div className="platform-work-detail__toast">
<PlatformStatusMessage
tone={shareState === 'copied' ? 'success' : 'error'}
surface="platform"
size="sm"
className="platform-work-detail__toast"
>
{shareState === 'copied' ? '分享内容已复制' : '分享失败'}
</div>
</PlatformStatusMessage>
) : null}
</section>
</div>
<div className="platform-work-detail__bottom">
<button
type="button"
<PlatformActionButton
tone="secondary"
shape="pill"
size="lg"
fullWidth
className="platform-work-detail__remix"
onClick={onRemix}
disabled={isBusy}
>
<WorkActionIcon className="h-5 w-5" />
{workActionLabel}
</button>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
shape="pill"
size="lg"
fullWidth
className="platform-work-detail__start"
onClick={onStart}
disabled={isBusy}
>
<Play className="h-5 w-5 fill-current" />
</button>
</PlatformActionButton>
</div>
</div>
);

View File

@@ -0,0 +1,21 @@
import type {
ExternalGenerationJobStatusRecord,
ExternalGenerationQueueOverview,
} from '../../../packages/shared/src/contracts/externalGeneration';
import type { ExternalGenerationQueueStatus } from '../CustomWorldGenerationView';
export function buildExternalGenerationQueueStatus(
overview: ExternalGenerationQueueOverview | null,
job: ExternalGenerationJobStatusRecord | null,
): ExternalGenerationQueueStatus | null {
if (!overview && !job) {
return null;
}
return {
currentStatus: job?.status ?? null,
currentProgress: job?.progress ?? null,
pendingCount: overview?.pendingCount ?? null,
runningCount: overview?.runningCount ?? null,
};
}

View File

@@ -0,0 +1,30 @@
import type {
MiniGameDraftGenerationKind,
MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress';
import type { SelectionStage } from './platformEntryTypes';
type MiniGameGenerationProgressTickStateMap = Partial<
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
>;
export function resolveMiniGameGenerationProgressTickState(
selectionStage: SelectionStage,
states: MiniGameGenerationProgressTickStateMap,
) {
const stageKindMap: Partial<
Record<SelectionStage, MiniGameDraftGenerationKind>
> = {
'puzzle-generating': 'puzzle',
'big-fish-generating': 'big-fish',
'square-hole-generating': 'square-hole',
'match3d-generating': 'match3d',
'baby-object-match-generating': 'baby-object-match',
'jump-hop-generating': 'jump-hop',
'puzzle-clear-generating': 'puzzle-clear',
'wooden-fish-generating': 'wooden-fish',
};
const kind = stageKindMap[selectionStage];
return kind ? (states[kind] ?? null) : null;
}

File diff suppressed because it is too large Load Diff