合并泥点弹窗透明修复
# Conflicts: # src/components/common/PublishShareModal.test.tsx # src/components/common/PublishShareModal.tsx # src/index.test.ts
This commit is contained in:
@@ -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');
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
type Dispatch,
|
||||
@@ -365,6 +364,12 @@ import {
|
||||
} from '../../services/wooden-fish/woodenFishClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PublishShareModal } from '../common/PublishShareModal';
|
||||
import type { PublishShareModalPayload } from '../common/publishShareModalModel';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
@@ -386,7 +391,6 @@ import {
|
||||
isPuzzleClearGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
mapPuzzleClearWorkToPlatformGalleryCard,
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverImage,
|
||||
@@ -501,6 +505,10 @@ import {
|
||||
canExposePublicWork,
|
||||
EDUTAINMENT_HIDDEN_MESSAGE,
|
||||
} from './platformEdutainmentVisibility';
|
||||
import {
|
||||
PlatformDraftGenerationPointNoticeDialog,
|
||||
type DraftGenerationPointNotice,
|
||||
} from './PlatformDraftGenerationPointNoticeDialog';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import {
|
||||
@@ -513,6 +521,7 @@ import {
|
||||
type PuzzleOnboardingPhase,
|
||||
PuzzleOnboardingView,
|
||||
} from './PlatformEntryFlowShellImpl/PuzzleOnboardingView';
|
||||
import { PuzzleRuntimeBlockingOverlay } from './PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay';
|
||||
import {
|
||||
PlatformEntryHomeView,
|
||||
type PlatformHomeTab,
|
||||
@@ -640,6 +649,7 @@ import {
|
||||
buildPuzzleResultWorkId,
|
||||
} from './platformPuzzleIdentityModel';
|
||||
import { mergePuzzleServiceRuntimeState } from './platformPuzzleRuntimeStateModel';
|
||||
import { buildPlatformRecommendedEntries } from './platformRecommendation';
|
||||
import {
|
||||
type PlatformPuzzleRuntimeAuthMode,
|
||||
resolvePlatformRecommendRuntimeAuthPlan,
|
||||
@@ -768,6 +778,11 @@ type DeleteCreationWorkConfirmation = {
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
type WorkNotFoundRecoveryDialogState = {
|
||||
message: string;
|
||||
nextPath: '/';
|
||||
};
|
||||
|
||||
async function resumePuzzleProfileSaveArchiveRaw(worldKey: string) {
|
||||
return requestRpgRuntimeJson<
|
||||
ProfileSaveArchiveResumeResponse<PuzzleSaveArchiveState>
|
||||
@@ -1015,22 +1030,22 @@ function isMissingPuzzleWorkError(error: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function maybeAlertWorkNotFoundAndReturnHome() {
|
||||
function resolveWorkNotFoundRecoveryDialogState(): WorkNotFoundRecoveryDialogState | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const recoveryAction = resolveWorkNotFoundRecoveryAction(
|
||||
window.location.pathname,
|
||||
);
|
||||
if (!recoveryAction) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// 中文注释:直接打开公开详情或运行态深链失效时,确认提示后必须离开空详情页。
|
||||
window.alert('作品不存在或已下架,将返回首页。');
|
||||
pushAppHistoryPath(recoveryAction.nextPath);
|
||||
return true;
|
||||
return {
|
||||
message: '作品不存在或已下架,将返回首页。',
|
||||
nextPath: recoveryAction.nextPath,
|
||||
};
|
||||
}
|
||||
|
||||
function hasSeenPuzzleOnboarding() {
|
||||
@@ -1370,9 +1385,13 @@ const PuzzleRuntimeShell = lazy(async () => {
|
||||
function LazyPanelFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
<PlatformSubpanel
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-5 py-4 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1390,21 +1409,26 @@ function CreationResultRecoveryPanel({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center px-3 py-6">
|
||||
<div className="platform-subpanel w-full max-w-sm rounded-[1.5rem] p-5 text-center">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
radius="xl"
|
||||
padding="none"
|
||||
className="w-full max-w-sm p-5 text-center"
|
||||
>
|
||||
<div className="text-base font-black text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{message}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
onClick={onAction}
|
||||
className="platform-button platform-button--primary mt-4 min-h-11 justify-center px-4 py-3 text-sm"
|
||||
size="md"
|
||||
className="mt-4 min-h-11"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
</PlatformActionButton>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1426,10 +1450,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
: 'platform-theme--light';
|
||||
const isDesktopLayout = usePlatformDesktopLayout();
|
||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||
const [draftGenerationPointNotice, setDraftGenerationPointNotice] = useState<{
|
||||
title: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [draftGenerationPointNotice, setDraftGenerationPointNotice] =
|
||||
useState<DraftGenerationPointNotice | null>(null);
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||
const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] =
|
||||
@@ -1745,6 +1767,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
>(null);
|
||||
const [pendingDeleteCreationWork, setPendingDeleteCreationWork] =
|
||||
useState<DeleteCreationWorkConfirmation | null>(null);
|
||||
const [workNotFoundRecoveryDialog, setWorkNotFoundRecoveryDialog] =
|
||||
useState<WorkNotFoundRecoveryDialogState | null>(null);
|
||||
const [
|
||||
claimingPuzzlePointIncentiveProfileId,
|
||||
setClaimingPuzzlePointIncentiveProfileId,
|
||||
@@ -2190,10 +2214,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
const returnPlatformHomeAfterMissingWork = useCallback(() => {
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
if (!maybeAlertWorkNotFoundAndReturnHome()) {
|
||||
pushAppHistoryPath('/');
|
||||
const recoveryDialog = resolveWorkNotFoundRecoveryDialogState();
|
||||
if (recoveryDialog) {
|
||||
setWorkNotFoundRecoveryDialog(recoveryDialog);
|
||||
return;
|
||||
}
|
||||
pushAppHistoryPath('/');
|
||||
}, [setPlatformTab, setSelectionStage]);
|
||||
const confirmWorkNotFoundRecovery = useCallback(() => {
|
||||
const nextPath = workNotFoundRecoveryDialog?.nextPath ?? '/';
|
||||
setWorkNotFoundRecoveryDialog(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath(nextPath);
|
||||
}, [setPlatformTab, setSelectionStage, workNotFoundRecoveryDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage === 'profile-feedback') {
|
||||
@@ -2275,14 +2309,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
setDraftGenerationPointNotice({
|
||||
title: '泥点不足',
|
||||
message: `本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`,
|
||||
kind: 'insufficient-points',
|
||||
requiredPoints: pointsCost,
|
||||
currentPoints: walletBalance,
|
||||
});
|
||||
return false;
|
||||
} catch {
|
||||
setDraftGenerationPointNotice({
|
||||
title: '读取泥点余额失败',
|
||||
message: '请稍后重试。',
|
||||
kind: 'balance-load-failed',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -4397,11 +4431,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
barkBattleDraftGenerationPointCost,
|
||||
ensureEnoughDraftGenerationPointsFromServer,
|
||||
]);
|
||||
const draftGenerationPointNoticeDescription = draftGenerationPointNotice
|
||||
? draftGenerationPointNotice.title === '读取泥点余额失败'
|
||||
? '当前表单不会丢失,关闭后可继续编辑,稍后再试。'
|
||||
: '当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。'
|
||||
: undefined;
|
||||
const recoverCompletedPuzzleDraftGeneration = useCallback(
|
||||
async ({
|
||||
sessionId,
|
||||
@@ -10999,6 +11028,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
openPublicWorkDetail,
|
||||
platformBootstrap.platformTab,
|
||||
returnPlatformHomeAfterMissingWork,
|
||||
resolvePuzzleErrorMessage,
|
||||
setIsPuzzleBusy,
|
||||
setPuzzleError,
|
||||
@@ -11508,11 +11538,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleError(null);
|
||||
setPublicWorkDetailError(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
if (!maybeAlertWorkNotFoundAndReturnHome()) {
|
||||
pushAppHistoryPath('/');
|
||||
}
|
||||
returnPlatformHomeAfterMissingWork();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14073,6 +14099,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const closePublicSearchResult = useCallback(() => {
|
||||
setSearchedPublicUser(null);
|
||||
setPublicSearchError(null);
|
||||
}, []);
|
||||
|
||||
const openProfilePlayedWorks = useCallback(() => {
|
||||
setIsProfilePlayStatsOpen(true);
|
||||
setIsProfilePlayStatsLoading(true);
|
||||
@@ -14765,9 +14796,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 items-center justify-center"
|
||||
>
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-5 py-4 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
{publicWorkDetailError || '正在读取作品详情...'}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -14821,9 +14857,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
>
|
||||
{detailNavigation.isDetailLoading || !selectedDetailEntry ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-5 py-4 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
{detailNavigation.detailError || '正在读取作品详情...'}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
) : selectedDetailEntry.visibility !== 'draft' ? (
|
||||
<PlatformWorkDetailView
|
||||
@@ -14899,8 +14940,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
onDelete={
|
||||
detailNavigation.isSelectedWorldOwned
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.handleDeleteSelectedWorld();
|
||||
const deleteModel =
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'rpg-library',
|
||||
entry: selectedDetailEntry,
|
||||
});
|
||||
requestDeleteCreationWork({
|
||||
id: deleteModel.id,
|
||||
title: deleteModel.title,
|
||||
detail: deleteModel.detail,
|
||||
run: () => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.handleDeleteSelectedWorld();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
: null
|
||||
@@ -14950,12 +15003,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-5 py-4 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
{sessionController.isLoadingAgentSession
|
||||
? '正在准备 Agent 共创工作区...'
|
||||
: sessionController.agentWorkspaceRestoreError ||
|
||||
'正在恢复创作工作区...'}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
)}
|
||||
</UnifiedCreationPage>
|
||||
@@ -16660,15 +16718,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
/>
|
||||
</Suspense>
|
||||
{isPuzzleNextLevelGenerating ? (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-950/62 px-5 backdrop-blur-sm">
|
||||
<div className="flex max-w-[18rem] flex-col items-center gap-3 rounded-[1.5rem] border border-white/12 bg-slate-950/92 px-6 py-5 text-center text-white shadow-[0_28px_80px_rgba(0,0,0,0.35)]">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-amber-200" />
|
||||
<div className="text-sm font-bold">正在准备下一关</div>
|
||||
<div className="text-xs leading-5 text-white/68">
|
||||
广场暂无可接续作品,正在生成新的候选图。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PuzzleRuntimeBlockingOverlay
|
||||
title="正在准备下一关"
|
||||
description="广场暂无可接续作品,正在生成新的候选图。"
|
||||
/>
|
||||
) : null}
|
||||
{puzzleOnboardingDraft &&
|
||||
puzzleRun?.currentLevel?.status === 'cleared' ? (
|
||||
@@ -17096,29 +17149,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<UnifiedModal
|
||||
open={Boolean(draftGenerationPointNotice)}
|
||||
title={draftGenerationPointNotice?.title ?? '泥点提示'}
|
||||
description={draftGenerationPointNoticeDescription}
|
||||
<PlatformDraftGenerationPointNoticeDialog
|
||||
notice={draftGenerationPointNotice}
|
||||
onClose={() => setDraftGenerationPointNotice(null)}
|
||||
closeOnBackdrop
|
||||
size="sm"
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftGenerationPointNotice(null)}
|
||||
className="platform-button platform-button--primary min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{draftGenerationPointNotice?.message}
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
/>
|
||||
<PublishShareModal
|
||||
open={Boolean(publishSharePayload)}
|
||||
payload={publishSharePayload}
|
||||
@@ -17136,7 +17172,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.5rem]"
|
||||
/>
|
||||
<UnifiedModal
|
||||
<PlatformAcknowledgeStatusDialog
|
||||
open={Boolean(workNotFoundRecoveryDialog)}
|
||||
status="error"
|
||||
title="作品不可用"
|
||||
onClose={confirmWorkNotFoundRecovery}
|
||||
showHeader
|
||||
showCloseButton
|
||||
closeOnBackdrop
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
>
|
||||
{workNotFoundRecoveryDialog?.message}
|
||||
</PlatformAcknowledgeStatusDialog>
|
||||
<PlatformDangerConfirmDialog
|
||||
open={Boolean(pendingDeleteCreationWork)}
|
||||
title="删除作品"
|
||||
description={
|
||||
@@ -17145,83 +17194,73 @@ export function PlatformEntryFlowShellImpl({
|
||||
: undefined
|
||||
}
|
||||
onClose={closeDeleteCreationWorkConfirmation}
|
||||
closeDisabled={Boolean(deletingCreationWorkId)}
|
||||
busy={Boolean(deletingCreationWorkId)}
|
||||
closeOnBackdrop={!deletingCreationWorkId}
|
||||
size="sm"
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDeleteCreationWorkConfirmation}
|
||||
disabled={Boolean(deletingCreationWorkId)}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmDeleteCreationWork}
|
||||
disabled={Boolean(deletingCreationWorkId)}
|
||||
className="platform-button platform-button--danger min-h-0 rounded-full px-4 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{deletingCreationWorkId ? '删除中' : '确认删除'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
confirmLabel="确认删除"
|
||||
busyConfirmLabel="删除中"
|
||||
onConfirm={confirmDeleteCreationWork}
|
||||
>
|
||||
<div className="text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{pendingDeleteCreationWork?.detail}
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
{pendingDeleteCreationWork?.detail}
|
||||
</PlatformDangerConfirmDialog>
|
||||
<AnimatePresence>
|
||||
{(searchedPublicUser || publicSearchError) && (
|
||||
{publicSearchError ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4"
|
||||
>
|
||||
<div className="platform-surface w-full max-w-md rounded-[1.6rem] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
公开编号搜索
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-black text-[var(--platform-text-strong)]">
|
||||
{publicSearchError ? '未找到结果' : '命中用户'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchedPublicUser(null);
|
||||
setPublicSearchError(null);
|
||||
}}
|
||||
className="platform-icon-button"
|
||||
aria-label="关闭搜索结果"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{publicSearchError ? (
|
||||
<div className="mt-4 text-sm leading-6 text-[var(--platform-text-soft)]">
|
||||
{publicSearchError}
|
||||
</div>
|
||||
) : searchedPublicUser ? (
|
||||
<div className="mt-4 rounded-[1.2rem] border border-[var(--platform-line-soft)] p-4">
|
||||
<div className="text-lg font-bold text-[var(--platform-text-strong)]">
|
||||
{searchedPublicUser.displayName}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-[var(--platform-text-soft)]">
|
||||
陶泥号 {searchedPublicUser.publicUserCode}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<PlatformAcknowledgeStatusDialog
|
||||
status="error"
|
||||
title="未找到结果"
|
||||
description="公开编号搜索"
|
||||
onClose={closePublicSearchResult}
|
||||
showHeader
|
||||
showCloseButton
|
||||
closeLabel="关闭搜索结果"
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center bg-black/45 !p-4`}
|
||||
panelClassName="platform-remap-surface rounded-[1.6rem]"
|
||||
>
|
||||
{publicSearchError}
|
||||
</PlatformAcknowledgeStatusDialog>
|
||||
</motion.div>
|
||||
)}
|
||||
) : searchedPublicUser ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<UnifiedModal
|
||||
open
|
||||
title="命中用户"
|
||||
description="公开编号搜索"
|
||||
onClose={closePublicSearchResult}
|
||||
closeOnBackdrop={false}
|
||||
closeLabel="关闭搜索结果"
|
||||
portal={false}
|
||||
size="sm"
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center bg-black/45 !p-4`}
|
||||
panelClassName="platform-remap-surface rounded-[1.6rem]"
|
||||
>
|
||||
<PlatformSubpanel
|
||||
as="section"
|
||||
surface="flat"
|
||||
radius="sm"
|
||||
padding="md"
|
||||
title={searchedPublicUser.displayName}
|
||||
titleVariant="strong"
|
||||
bodyClassName="mt-3"
|
||||
>
|
||||
<PlatformFieldLabel>陶泥号</PlatformFieldLabel>
|
||||
<div className="mt-1 text-sm font-semibold text-[var(--platform-text-base)]">
|
||||
{searchedPublicUser.publicUserCode}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</UnifiedModal>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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_disabled(requestId: req-big-fish-gallery)',
|
||||
message: 'creation_entry_disabled(requestId: req-big-fish-gallery)',
|
||||
}}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
|
||||
@@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
152
src/components/platform-entry/PlatformProfileModalShell.tsx
Normal file
152
src/components/platform-entry/PlatformProfileModalShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
180
src/components/platform-entry/PlatformProfilePrimitives.tsx
Normal file
180
src/components/platform-entry/PlatformProfilePrimitives.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
196
src/components/platform-entry/PlatformProfileQrScannerModal.tsx
Normal file
196
src/components/platform-entry/PlatformProfileQrScannerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
270
src/components/platform-entry/PlatformProfileRechargeModal.tsx
Normal file
270
src/components/platform-entry/PlatformProfileRechargeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
317
src/components/platform-entry/PlatformProfileReferralModal.tsx
Normal file
317
src/components/platform-entry/PlatformProfileReferralModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
148
src/components/platform-entry/PlatformProfileTaskCenterModal.tsx
Normal file
148
src/components/platform-entry/PlatformProfileTaskCenterModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: '下一张关卡图' }));
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
1060
src/components/platform-entry/usePlatformProfileCenterController.ts
Normal file
1060
src/components/platform-entry/usePlatformProfileCenterController.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user