feat: surface platform errors in copyable dialogs

This commit is contained in:
kdletters
2026-05-26 14:27:18 +08:00
parent 44c65df5c9
commit fbda614156
16 changed files with 715 additions and 191 deletions

View File

@@ -13,7 +13,7 @@ interface CustomWorldGenerationViewProps {
anchorEntries?: CustomWorldStructuredAnchorEntry[];
progress: CustomWorldGenerationProgress | null;
isGenerating: boolean;
error: string | null;
error?: string | null;
onBack: () => void;
onEditSetting: () => void;
onRetry: () => void;
@@ -110,7 +110,6 @@ export function CustomWorldGenerationView({
anchorEntries = [],
progress,
isGenerating,
error,
onBack,
onEditSetting,
onRetry,
@@ -123,7 +122,6 @@ export function CustomWorldGenerationView({
settingDescription = '这段文本会直接驱动本轮世界框架、角色与场景生成。',
progressTitle = '生成进度',
activeBadgeLabel = '世界建设中',
pausedBadgeLabel = '生成已暂停',
idleBadgeLabel = '等待操作',
structuredEmptyText = '正在整理当前设定结构,请稍后。',
hideBatchModule = false,
@@ -169,11 +167,7 @@ export function CustomWorldGenerationView({
<span className="break-keep">{backLabel}</span>
</button>
<div className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
{isGenerating
? activeBadgeLabel
: error
? pausedBadgeLabel
: idleBadgeLabel}
{isGenerating ? activeBadgeLabel : idleBadgeLabel}
</div>
</div>
@@ -195,12 +189,6 @@ export function CustomWorldGenerationView({
/>
</div>
{error ? (
<div className="mt-4 rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
{error}
</div>
) : null}
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
{!isGenerating ? (
<>

View File

@@ -1,12 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
@@ -43,7 +43,6 @@ type CustomWorldCreationHubProps = {
loading: boolean;
error: string | null;
onRetry: () => void;
createError?: string | null;
createBusy?: boolean;
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
@@ -154,7 +153,6 @@ export function CustomWorldCreationHub({
loading,
error,
onRetry,
createError = null,
createBusy = false,
entryConfig,
creationTypes,
@@ -360,7 +358,6 @@ export function CustomWorldCreationHub({
{showStartCard ? (
<CustomWorldCreationStartCard
busy={createBusy}
error={createError}
entryConfig={entryConfig}
creationTypes={creationTypes}
onCreateType={onCreateType}
@@ -377,12 +374,11 @@ export function CustomWorldCreationHub({
) : null}
{showWorkShelf && error ? (
<div className="platform-banner platform-banner--danger rounded-[1.4rem] px-4 py-4 text-sm leading-7">
<div>{error}</div>
<div className="flex justify-end">
<button
type="button"
onClick={onRetry}
className="platform-button platform-button--ghost mt-3 min-h-0 rounded-full px-4 py-2 text-sm"
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
>
</button>

View File

@@ -1,5 +1,5 @@
import { Coins, Trophy } from 'lucide-react';
import { useMemo, useState, type UIEvent } from 'react';
import { type UIEvent,useMemo, useState } from 'react';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import {
@@ -10,7 +10,6 @@ import {
type CustomWorldCreationStartCardProps = {
busy?: boolean;
error?: string | null;
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
onCreateType: (type: PlatformCreationTypeId) => void;
@@ -25,7 +24,6 @@ function shouldShowCreationBadge(badge: string) {
export function CustomWorldCreationStartCard({
busy = false,
error = null,
entryConfig,
creationTypes,
onCreateType,
@@ -233,11 +231,6 @@ export function CustomWorldCreationStartCard({
})}
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1rem] px-3 py-2 text-sm leading-5 sm:rounded-[1.25rem] sm:leading-6">
{error}
</div>
) : null}
</section>
</div>
);

View File

@@ -51,7 +51,6 @@ test('dispatches wooden fish creation type selection', () => {
<PlatformEntryCreationTypeModal
isOpen
isBusy={false}
error={null}
entryConfig={entryConfig}
creationTypes={derivePlatformCreationTypes(entryConfig.creationTypes)}
onClose={() => {}}

View File

@@ -10,7 +10,6 @@ import {
export interface PlatformEntryCreationTypeModalProps {
isOpen: boolean;
isBusy: boolean;
error: string | null;
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
onClose: () => void;
@@ -94,7 +93,6 @@ function CreationTypeCard(props: {
export function PlatformEntryCreationTypeModal({
isOpen,
isBusy,
error,
entryConfig,
creationTypes,
onClose,
@@ -172,11 +170,6 @@ export function PlatformEntryCreationTypeModal({
))}
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</UnifiedModal>
);
}

View File

@@ -38,6 +38,7 @@ import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type {
CreateMatch3DSessionRequest,
ExecuteMatch3DActionRequest,
@@ -154,13 +155,6 @@ import {
type CreationEntryConfig,
fetchCreationEntryConfig,
} from '../../services/creationEntryConfigService';
import {
cancelCreativeAgentSession,
confirmCreativePuzzleTemplate,
createCreativeAgentSession,
streamCreativeAgentMessage,
streamCreativeDraftEdit,
} from '../../services/creative-agent';
import {
clearCreationUrlState,
type CreationUrlState,
@@ -169,11 +163,12 @@ import {
writeCreationUrlState,
} from '../../services/creationUrlState';
import {
clearPuzzleRuntimeUrlState,
readPuzzleRuntimeUrlState,
writePuzzleRuntimeUrlState,
type PuzzleRuntimeUrlState,
} from '../../services/puzzleRuntimeUrlState';
cancelCreativeAgentSession,
confirmCreativePuzzleTemplate,
createCreativeAgentSession,
streamCreativeAgentMessage,
streamCreativeDraftEdit,
} from '../../services/creative-agent';
import {
readCustomWorldAgentUiState,
shouldRestoreCustomWorldAgentUiState,
@@ -196,7 +191,6 @@ import {
JumpHopWorkProfileResponse,
JumpHopWorkspaceCreateRequest,
} from '../../services/jump-hop/jumpHopClient';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { match3dCreationClient } from '../../services/match3d-creation';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import {
@@ -287,6 +281,12 @@ import {
listPuzzleWorks,
updatePuzzleWork,
} from '../../services/puzzle-works';
import {
clearPuzzleRuntimeUrlState,
type PuzzleRuntimeUrlState,
readPuzzleRuntimeUrlState,
writePuzzleRuntimeUrlState,
} from '../../services/puzzleRuntimeUrlState';
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import {
@@ -375,6 +375,7 @@ import {
mapVisualNovelWorkToPlatformGalleryCard,
mapWoodenFishWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
resolvePlatformPublicWorkCode,
} from '../rpg-entry/rpgEntryWorldPresentation';
import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling';
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
@@ -414,6 +415,7 @@ import {
PlatformEntryHomeView,
type PlatformHomeTab,
} from './PlatformEntryHomeView';
import { usePlatformDesktopLayout } from './platformEntryResponsive';
import {
buildCreationHubFallbackItems,
resolveRpgCreationErrorMessage,
@@ -423,11 +425,14 @@ import type {
SelectionStage,
} from './platformEntryTypes';
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
import {
PlatformErrorDialog,
type PlatformErrorDialogPayload,
} from './PlatformErrorDialog';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
import { usePlatformDesktopLayout } from './platformEntryResponsive';
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
import { usePlatformEntryNavigation } from './usePlatformEntryNavigation';
@@ -2012,6 +2017,22 @@ function createPendingDraftShelfState(
};
}
function normalizePlatformErrorMessage(message: string | null | undefined) {
const normalized = message?.trim();
return normalized ? normalized : null;
}
function formatPlatformErrorSource(label: string, id?: string | null) {
const normalizedId = id?.trim();
return normalizedId ? `${label} ${normalizedId}` : label;
}
function buildPlatformErrorDialogDismissKey(
error: (PlatformErrorDialogPayload & { key: string }) | null,
) {
return error ? `${error.key}:${error.source}:${error.message}` : null;
}
function createMiniGameDraftGenerationStateForRestoredDraft(
kind: MiniGameDraftGenerationKind,
metadata?: MiniGameDraftGenerationState['metadata'],
@@ -5767,6 +5788,336 @@ export function PlatformEntryFlowShellImpl({
isMiniGameDraftGenerating(
activePuzzleBackgroundCompileTask?.generationState ?? null,
);
const [dismissedPlatformErrorDialogKey, setDismissedPlatformErrorDialogKey] =
useState<string | null>(null);
const currentPlatformErrorDialog = useMemo<
(PlatformErrorDialogPayload & { key: string }) | null
>(() => {
const candidates: Array<{
key: string;
source: string;
message: string | null | undefined;
}> = [
{
key: 'creation-entry-config',
source: '创作入口配置',
message: creationEntryConfigError,
},
{
key: 'platform-bootstrap',
source: '平台首页',
message: platformBootstrap.platformError,
},
{
key: 'rpg-creation-type',
source: '创作入口',
message: sessionController.creationTypeError,
},
{
key: 'rpg-restore',
source: '创作作品架',
message: sessionController.agentWorkspaceRestoreError,
},
{
key: 'rpg-result',
source: formatPlatformErrorSource(
'RPG 草稿',
sessionController.agentSession?.sessionId ??
sessionController.generatedCustomWorldProfile?.id,
),
message: resultViewError,
},
{
key: 'public-work-detail',
source: formatPlatformErrorSource(
'作品详情',
selectedPublicWorkDetail
? resolvePlatformPublicWorkCode(selectedPublicWorkDetail)
: selectedDetailEntry?.profileId,
),
message: publicWorkDetailError ?? detailNavigation.detailError,
},
{
key: 'big-fish',
source: formatPlatformErrorSource(
selectionStage === 'big-fish-runtime' ? '大鱼吃小鱼游玩' : '大鱼草稿',
bigFishRun?.runId ?? bigFishSession?.sessionId,
),
message: bigFishError,
},
{
key: 'match3d',
source: formatPlatformErrorSource(
selectionStage === 'match3d-runtime' ? '抓大鹅游玩' : '抓大鹅草稿',
match3dRun?.runId ??
match3dGenerationViewSession?.sessionId ??
match3dSession?.sessionId,
),
message: match3dGenerationViewError ?? match3dError,
},
{
key: 'square-hole',
source: formatPlatformErrorSource(
selectionStage === 'square-hole-runtime'
? '方洞挑战游玩'
: '方洞挑战草稿',
squareHoleRun?.runId ?? squareHoleSession?.sessionId,
),
message: squareHoleError,
},
{
key: 'jump-hop',
source: formatPlatformErrorSource(
selectionStage === 'jump-hop-runtime' ? '跳一跳游玩' : '跳一跳草稿',
jumpHopRun?.runId ?? jumpHopSession?.sessionId,
),
message: jumpHopError,
},
{
key: 'wooden-fish',
source: formatPlatformErrorSource(
selectionStage === 'wooden-fish-runtime'
? '敲木鱼游玩'
: '敲木鱼草稿',
woodenFishRun?.runId ?? woodenFishSession?.sessionId,
),
message: woodenFishError,
},
{
key: 'puzzle',
source: formatPlatformErrorSource(
selectionStage === 'puzzle-runtime' ? '拼图游玩' : '拼图草稿',
puzzleRun?.runId ??
puzzleGenerationViewSession?.sessionId ??
puzzleSession?.sessionId,
),
message: puzzleGenerationViewError ?? puzzleCreationError ?? puzzleError,
},
{
key: 'puzzle-onboarding',
source: '拼图首次创作',
message: puzzleOnboardingError,
},
{
key: 'puzzle-shelf',
source: '拼图作品架',
message: puzzleShelfError,
},
{
key: 'visual-novel',
source: formatPlatformErrorSource(
selectionStage === 'visual-novel-runtime'
? '视觉小说游玩'
: '视觉小说草稿',
visualNovelRun?.runId ?? visualNovelSession?.sessionId,
),
message: visualNovelError,
},
{
key: 'baby-object-match',
source: formatPlatformErrorSource(
selectionStage === 'baby-object-match-runtime'
? '宝贝识物游玩'
: '宝贝识物草稿',
babyObjectMatchDraft?.profileId,
),
message: babyObjectMatchError,
},
{
key: 'bark-battle',
source: formatPlatformErrorSource(
selectionStage === 'bark-battle-runtime'
? '汪汪声浪游玩'
: '汪汪声浪草稿',
barkBattlePublishedConfig?.workId ?? barkBattleDraftConfig?.workId,
),
message: barkBattleError,
},
{
key: 'creative-agent',
source: formatPlatformErrorSource(
'智能创作 Agent',
creativeAgentSession?.sessionId,
),
message: creativeAgentError,
},
{
key: 'rpg-generation',
source: formatPlatformErrorSource(
'RPG 草稿生成',
sessionController.agentSession?.sessionId,
),
message: sessionController.activeGenerationError,
},
];
for (const candidate of candidates) {
const message = normalizePlatformErrorMessage(candidate.message);
if (message) {
return {
key: candidate.key,
source: candidate.source,
message,
};
}
}
return null;
}, [
babyObjectMatchDraft?.profileId,
babyObjectMatchError,
barkBattleDraftConfig?.workId,
barkBattleError,
barkBattlePublishedConfig?.workId,
bigFishError,
bigFishRun?.runId,
bigFishSession?.sessionId,
creationEntryConfigError,
creativeAgentError,
creativeAgentSession?.sessionId,
detailNavigation.detailError,
jumpHopError,
jumpHopRun?.runId,
jumpHopSession?.sessionId,
match3dError,
match3dGenerationViewError,
match3dGenerationViewSession?.sessionId,
match3dRun?.runId,
match3dSession?.sessionId,
platformBootstrap.platformError,
publicWorkDetailError,
puzzleCreationError,
puzzleError,
puzzleGenerationViewError,
puzzleGenerationViewSession?.sessionId,
puzzleOnboardingError,
puzzleRun?.runId,
puzzleSession?.sessionId,
puzzleShelfError,
resultViewError,
selectedDetailEntry?.profileId,
selectedPublicWorkDetail,
selectionStage,
sessionController.activeGenerationError,
sessionController.agentSession?.sessionId,
sessionController.agentWorkspaceRestoreError,
sessionController.creationTypeError,
sessionController.generatedCustomWorldProfile?.id,
squareHoleError,
squareHoleRun?.runId,
squareHoleSession?.sessionId,
visualNovelError,
visualNovelRun?.runId,
visualNovelSession?.sessionId,
woodenFishError,
woodenFishRun?.runId,
woodenFishSession?.sessionId,
]);
const activePlatformErrorDialogDismissKey =
buildPlatformErrorDialogDismissKey(currentPlatformErrorDialog);
const activePlatformErrorDialog =
activePlatformErrorDialogDismissKey &&
activePlatformErrorDialogDismissKey === dismissedPlatformErrorDialogKey
? null
: currentPlatformErrorDialog;
const closePlatformErrorDialog = useCallback(() => {
if (!currentPlatformErrorDialog) {
return;
}
const dismissKey = buildPlatformErrorDialogDismissKey(
currentPlatformErrorDialog,
);
if (dismissKey) {
setDismissedPlatformErrorDialogKey(dismissKey);
}
if (currentPlatformErrorDialog.key === 'creation-entry-config') {
setCreationEntryConfigError(null);
return;
}
if (currentPlatformErrorDialog.key === 'platform-bootstrap') {
platformBootstrap.setPlatformError(null);
return;
}
if (currentPlatformErrorDialog.key === 'rpg-creation-type') {
sessionController.setCreationTypeError(null);
return;
}
if (currentPlatformErrorDialog.key === 'rpg-restore') {
return;
}
if (
currentPlatformErrorDialog.key === 'rpg-result' ||
currentPlatformErrorDialog.key === 'rpg-generation'
) {
autosaveCoordinator.setCustomWorldAutoSaveError(null);
sessionController.setCustomWorldError(null);
return;
}
if (currentPlatformErrorDialog.key === 'public-work-detail') {
setPublicWorkDetailError(null);
detailNavigation.setDetailError(null);
return;
}
if (currentPlatformErrorDialog.key === 'big-fish') {
setBigFishError(null);
return;
}
if (currentPlatformErrorDialog.key === 'match3d') {
setMatch3DError(null);
return;
}
if (currentPlatformErrorDialog.key === 'square-hole') {
setSquareHoleError(null);
return;
}
if (currentPlatformErrorDialog.key === 'jump-hop') {
setJumpHopError(null);
return;
}
if (currentPlatformErrorDialog.key === 'wooden-fish') {
setWoodenFishError(null);
return;
}
if (
currentPlatformErrorDialog.key === 'puzzle' ||
currentPlatformErrorDialog.key === 'puzzle-onboarding' ||
currentPlatformErrorDialog.key === 'puzzle-shelf'
) {
setPuzzleCreationError(null);
setPuzzleOnboardingError(null);
setPuzzleShelfError(null);
setPuzzleError(null);
return;
}
if (currentPlatformErrorDialog.key === 'visual-novel') {
setVisualNovelError(null);
return;
}
if (currentPlatformErrorDialog.key === 'baby-object-match') {
setBabyObjectMatchError(null);
return;
}
if (currentPlatformErrorDialog.key === 'bark-battle') {
setBarkBattleError(null);
return;
}
if (currentPlatformErrorDialog.key === 'creative-agent') {
setCreativeAgentError(null);
}
}, [
autosaveCoordinator,
currentPlatformErrorDialog,
detailNavigation,
platformBootstrap,
sessionController,
setBigFishError,
setMatch3DError,
setPuzzleError,
setSquareHoleError,
setVisualNovelError,
]);
const shouldPollPuzzleGenerationSession =
selectionStage === 'puzzle-generating' &&
activePuzzleGenerationSessionId != null &&
@@ -14098,19 +14449,6 @@ export function PlatformEntryFlowShellImpl({
void refreshBabyObjectMatchShelf();
void refreshBarkBattleShelf();
}}
createError={
creationEntryConfigError ??
sessionController.creationTypeError ??
bigFishError ??
match3dError ??
(isSquareHoleCreationVisible ? squareHoleError : null) ??
woodenFishError ??
puzzleCreationError ??
puzzleError ??
(isVisualNovelCreationOpen ? visualNovelError : null) ??
babyObjectMatchError ??
barkBattleError
}
createBusy={
!creationEntryConfig ||
sessionController.isCreatingAgentSession ||
@@ -15762,7 +16100,6 @@ export function PlatformEntryFlowShellImpl({
settingDescription={null}
progressTitle="拼图草稿生成进度"
activeBadgeLabel="草稿生成中"
pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区"
hideBatchModule
/>
@@ -16420,7 +16757,7 @@ export function PlatformEntryFlowShellImpl({
</AnimatePresence>
{creationEntryConfig ? (
<PlatformEntryCreationTypeModal
<PlatformEntryCreationTypeModal
isOpen={showCreationTypeModal}
isBusy={
sessionController.isCreatingAgentSession ||
@@ -16436,20 +16773,6 @@ export function PlatformEntryFlowShellImpl({
(isVisualNovelCreationOpen && isVisualNovelStreamingReply) ||
isBabyObjectMatchBusy
}
error={
creationEntryConfigError ??
bigFishError ??
creativeAgentError ??
match3dError ??
squareHoleError ??
jumpHopError ??
woodenFishError ??
puzzleCreationError ??
(isVisualNovelCreationOpen ? visualNovelError : null) ??
babyObjectMatchError ??
puzzleError ??
sessionController.creationTypeError
}
entryConfig={creationEntryConfig}
creationTypes={creationEntryTypes}
onClose={() => {
@@ -16542,6 +16865,12 @@ export function PlatformEntryFlowShellImpl({
payload={publishSharePayload}
onClose={() => setPublishSharePayload(null)}
/>
<PlatformErrorDialog
error={activePlatformErrorDialog}
onClose={closePlatformErrorDialog}
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
panelClassName="platform-remap-surface rounded-[1.5rem]"
/>
<UnifiedModal
open={Boolean(pendingDeleteCreationWork)}
title="删除作品"

View File

@@ -0,0 +1,60 @@
/* @vitest-environment jsdom */
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as clipboardService from '../../services/clipboard';
import { PlatformErrorDialog } from './PlatformErrorDialog';
vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
describe('PlatformErrorDialog', () => {
test('shows source, message, and copies the full error report', async () => {
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
render(
<PlatformErrorDialog
error={{
source: '拼图草稿 puzzle-session-123',
message: '图片生成失败,请稍后再试。',
}}
onClose={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: '发生错误' });
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',
),
);
await waitFor(() => {
expect(
within(dialog).getByRole('button', { name: '已复制' }),
).toBeTruthy();
});
});
test('does not render when there is no active error', () => {
render(<PlatformErrorDialog error={null} onClose={() => {}} />);
expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull();
});
});

View File

@@ -0,0 +1,120 @@
import { Check, Copy } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { UnifiedModal } from '../common/UnifiedModal';
export type PlatformErrorDialogPayload = {
source: string;
message: string;
};
type PlatformErrorDialogProps = {
error: PlatformErrorDialogPayload | null;
onClose: () => void;
overlayClassName?: string;
panelClassName?: string;
};
function buildPlatformErrorReport(error: PlatformErrorDialogPayload) {
return [`来源:${error.source}`, `错误:${error.message}`].join('\n');
}
export function PlatformErrorDialog({
error,
onClose,
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 reportText = useMemo(
() => (error ? buildPlatformErrorReport(error) : ''),
[error],
);
useEffect(
() => () => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
},
[],
);
useEffect(() => {
setCopyState('idle');
}, [error?.source, error?.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
open={Boolean(error)}
title="发生错误"
onClose={onClose}
size="sm"
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>
}
>
{error ? (
<>
<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)]">
{error.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)]">
{error.message}
</div>
</div>
</>
) : null}
</UnifiedModal>
);
}

View File

@@ -24,7 +24,6 @@ import {
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isEdutainmentGalleryEntry,
isJumpHopGalleryEntry,
type PlatformPublicGalleryCard,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverSlides,
@@ -36,7 +35,7 @@ export interface PlatformWorkDetailViewProps {
authorAvatarUrl?: string | null;
authorDisplayName?: string | null;
isBusy: boolean;
error: string | null;
error?: string | null;
visibleCoverCount?: number;
onBack: () => void;
onLike: () => void;
@@ -89,7 +88,6 @@ export function PlatformWorkDetailView({
authorAvatarUrl,
authorDisplayName,
isBusy,
error,
visibleCoverCount = 1,
onBack,
onLike,
@@ -432,9 +430,6 @@ export function PlatformWorkDetailView({
{shareState === 'copied' ? '分享内容已复制' : '分享失败'}
</div>
) : null}
{error ? (
<div className="platform-work-detail__error">{error}</div>
) : null}
</section>
</div>

View File

@@ -1819,12 +1819,7 @@ export function PuzzleResultView({
) : null}
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
{!error && autoSaveError ? (
{autoSaveError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{autoSaveError}
</div>

View File

@@ -1826,11 +1826,18 @@ test('non-wechat profile opens reward code from recharge-shaped entry', async ()
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
});
test('profile daily task shortcut opens task center and claims reward', async () => {
test('profile daily task shortcut reflects task progress and claim updates', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
const dailyTask = screen.getByRole('button', { name: //u });
await waitFor(() => {
expect(within(dailyTask).getByText('1 / 1')).toBeTruthy();
});
expect(within(dailyTask).getByText('领取')).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
expect(await screen.findByText('每日登录')).toBeTruthy();
@@ -1847,6 +1854,7 @@ test('profile daily task shortcut opens task center and claims reward', async ()
expect(await screen.findByText('已领取 10 泥点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '已领取' })).toBeNull();
expect(screen.getByText('暂无任务')).toBeTruthy();
expect(within(dailyTask).getByText('已完成')).toBeTruthy();
});
test('profile task center keeps only the highest priority actionable task', async () => {
@@ -1909,7 +1917,7 @@ test('profile task center keeps only the highest priority actionable task', asyn
expect(screen.queryByText('低优先级已完成')).toBeNull();
});
test('profile total play time card always uses hours', () => {
test('profile total play time card always uses hours', async () => {
renderProfileView(vi.fn(), {
totalPlayTimeMs: 90 * 60 * 1000,
});
@@ -1920,9 +1928,10 @@ test('profile total play time card always uses hours', () => {
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
expect(within(playTimeCard).queryByText('90分')).toBeNull();
await screen.findByText('1 / 1');
});
test('profile played works card shows count unit', () => {
test('profile played works card shows count unit', async () => {
renderProfileView(vi.fn(), {
playedWorldCount: 1,
});
@@ -1932,9 +1941,10 @@ test('profile played works card shows count unit', () => {
});
expect(within(playedCard).getByText('1个')).toBeTruthy();
await screen.findByText('1 / 1');
});
test('profile stats cards are centered without update timestamp', () => {
test('profile stats cards are centered without update timestamp', async () => {
renderProfileView(vi.fn(), {
updatedAt: '2026-05-03T08:01:00Z',
});
@@ -1950,6 +1960,7 @@ test('profile stats cards are centered without update timestamp', () => {
expect(card.className).toContain('text-center');
}
expect(screen.queryByText(//u)).toBeNull();
await screen.findByText('1 / 1');
});
test('mobile profile page matches the reference layout sections', async () => {
@@ -2007,7 +2018,7 @@ test('mobile profile page matches the reference layout sections', async () => {
expect(dailyTask.querySelector('.platform-profile-daily-task-card__desc')).toBeTruthy();
expect(dailyTask.querySelector('.platform-profile-daily-task-card__progress')).toBeTruthy();
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
expect(within(dailyTask).getByText('0 / 1')).toBeTruthy();
expect(await within(dailyTask).findByText('1 / 1')).toBeTruthy();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
@@ -2101,7 +2112,7 @@ test('profile scan action opens camera scanner instead of recharge panel', async
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
});
test('desktop account entry uses saved avatar image when available', () => {
test('desktop account entry uses saved avatar image when available', async () => {
mockDesktopLayout();
const avatarUrl = 'data:image/png;base64,AAAA';
@@ -2111,6 +2122,7 @@ test('desktop account entry uses saved avatar image when available', () => {
const avatarImage = accountEntry.querySelector('img');
expect(avatarImage?.getAttribute('src')).toBe(avatarUrl);
expect(within(accountEntry).queryByText('测')).toBeNull();
await screen.findByText('1 / 1');
});
test('profile avatar upload uses the shared square crop tool', async () => {
@@ -2184,7 +2196,7 @@ test('profile invite shortcut shows reward subtitle and invited users', async ()
expect(screen.queryByText('今日')).toBeNull();
});
test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => {
test('profile page hides legacy redeem invite secondary shortcut for fresh accounts', async () => {
renderProfileView(
vi.fn(),
{},
@@ -2192,20 +2204,16 @@ test('profile redeem invite shortcut sits between invite and community for fresh
);
const inviteButton = screen.getByRole('button', { name: //u });
const redeemButton = await screen.findByRole('button', {
name: //u,
});
const communityButton = screen.getByRole('button', { name: //u });
const secondaryShortcuts = screen.getByRole('region', {
name: '次级入口',
await waitFor(() => {
expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
});
expect(inviteButton).toBeTruthy();
expect(communityButton).toBeTruthy();
expect(
within(secondaryShortcuts).getByRole('button', { name: //u }),
).toBeTruthy();
expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy();
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
});
test('profile redeem invite shortcut hides after redeemed or one day old', async () => {
@@ -2226,6 +2234,7 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async
expect(
within(firstShortcutRegion).queryByRole('button', { name: //u }),
).toBeNull();
await screen.findByText('1 / 1');
unmount();
renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' });
@@ -2237,6 +2246,7 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async
name: //u,
}),
).toBeNull();
await screen.findByText('1 / 1');
});
test('invite query opens login modal for logged out users', async () => {
@@ -2269,9 +2279,10 @@ test('profile redeem invite modal reads query invite code after login', async ()
expect((input as HTMLInputElement).value).toBe('SPRING2026');
});
test('profile redeem invite modal submits code and hides shortcut after success', async () => {
test('profile redeem invite query modal submits code after login', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
renderProfileView(
onRechargeSuccess,
@@ -2279,9 +2290,7 @@ test('profile redeem invite modal submits code and hides shortcut after success'
{ createdAt: buildFreshProfileCreatedAt() },
);
await user.click(await screen.findByRole('button', { name: //u }));
const input = await screen.findByLabelText('邀请码');
await user.type(input, 'spring-2026');
expect(await screen.findByLabelText('邀请码')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '提交' }));
await waitFor(() => {
@@ -2291,12 +2300,7 @@ test('profile redeem invite modal submits code and hides shortcut after success'
});
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
expect(await screen.findByText('已填写')).toBeTruthy();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
within(shortcutRegion).queryByRole('button', {
name: //u,
}),
).toBeNull();
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
});
test('opens reward code modal from profile action on mobile', async () => {

View File

@@ -255,18 +255,25 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<ProfileTaskItem['status'], number> = {
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<
ProfileTaskItem['status'],
number
> = {
claimable: 2,
incomplete: 1,
disabled: 0,
claimed: -1,
};
const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10;
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
return tasks
.map((task, index) => ({ task, index }))
.filter(({ task }) => task.status === 'claimable' || task.status === 'incomplete')
.filter(
({ task }) =>
task.status === 'claimable' || task.status === 'incomplete',
)
.sort(
(left, right) =>
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
@@ -277,6 +284,37 @@ function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
.map(({ task }) => task);
}
function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) {
return (
selectProfileTaskCenterTasks(tasks)[0] ??
tasks.find((task) => task.status === 'claimed') ??
tasks.find((task) => task.status !== 'disabled') ??
null
);
}
function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) {
const task = selectProfileTaskCardTask(center?.tasks ?? []);
const threshold = Math.max(1, task?.threshold ?? 1);
const progressCount = Math.min(task?.progressCount ?? 0, threshold);
const rewardPoints =
task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS;
const actionLabel =
task?.status === 'claimable'
? '领取'
: task?.status === 'claimed'
? '已完成'
: '去完成';
return {
actionLabel,
progressCount,
progressPercent: Math.round((progressCount / threshold) * 100),
rewardPoints,
threshold,
};
}
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
type BarcodeDetectorLike = {
@@ -2449,42 +2487,6 @@ function ProfileSettingsRow({
);
}
function ProfileSecondaryShortcutButton({
label,
subLabel,
icon,
onClick,
}: {
label: string;
subLabel?: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick}
className="platform-profile-secondary-shortcut inline-flex items-center gap-2 rounded-full px-3 py-2 text-left"
>
<span className="platform-profile-secondary-shortcut__icon">
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block truncate text-[13px] font-semibold text-[var(--platform-text-strong)]">
{label}
</span>
{subLabel ? (
<span className="mt-0.5 block truncate text-[11px] font-medium text-[var(--platform-text-soft)]">
{subLabel}
</span>
) : null}
</span>
</button>
);
}
function ProfileLegalSection({
onOpenDocument,
}: {
@@ -4218,12 +4220,10 @@ export function RpgEntryHomeView({
profileDashboard?.totalPlayTimeMs ?? 0,
);
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
const canShowReferralRedeemShortcut =
isAuthenticated &&
isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt) &&
isReferralCenterInitialized &&
Boolean(referralCenter) &&
referralCenter?.hasRedeemedCode !== true;
const profileTaskCardSummary = useMemo(
() => buildProfileTaskCardSummary(taskCenter),
[taskCenter],
);
const tabIcons: Record<
PlatformHomeTab,
ComponentType<{ className?: string }>
@@ -4776,7 +4776,7 @@ export function RpgEntryHomeView({
document.removeEventListener('visibilitychange', handleResume);
};
}, [handleWechatPayResult]);
const loadTaskCenter = () => {
const loadTaskCenter = useCallback(() => {
setTaskCenterError(null);
setIsLoadingTaskCenter(true);
void getRpgProfileTasks()
@@ -4788,11 +4788,24 @@ export function RpgEntryHomeView({
);
})
.finally(() => setIsLoadingTaskCenter(false));
};
}, []);
useEffect(() => {
if (activeTab !== 'profile' || !isAuthenticated) {
setTaskCenter(null);
setTaskCenterError(null);
return;
}
loadTaskCenter();
}, [activeTab, isAuthenticated, loadTaskCenter]);
const openTaskCenterPanel = () => {
setIsTaskCenterOpen(true);
setTaskClaimSuccess(null);
loadTaskCenter();
if (!taskCenter) {
loadTaskCenter();
}
};
const openQrScannerPanel = () => {
if (!authUi?.user) {
@@ -6185,14 +6198,24 @@ export function RpgEntryHomeView({
</span>
<span className="platform-profile-daily-task-card__desc mt-4 block text-[13px] font-medium text-[var(--platform-text-base)]">
<span className="text-[#c45b2a]">10</span>
{' '}
<span className="text-[#c45b2a]">
{profileTaskCardSummary.rewardPoints}
</span>{' '}
</span>
<span className="platform-profile-daily-task-card__progress mt-4 flex items-center gap-3">
<span className="platform-profile-daily-task-card__progress-value text-[14px] font-semibold text-[#dc3f0e]">
0 / 1
{profileTaskCardSummary.progressCount} /{' '}
{profileTaskCardSummary.threshold}
</span>
<span className="platform-profile-daily-task-card__track">
<span className="platform-profile-daily-task-card__bar" />
<span
className="platform-profile-daily-task-card__bar"
style={{
width: `${profileTaskCardSummary.progressPercent}%`,
}}
/>
</span>
</span>
</span>
@@ -6202,7 +6225,7 @@ export function RpgEntryHomeView({
className="platform-profile-daily-task-card__mascot"
/>
<span className="platform-profile-daily-task-card__action">
{profileTaskCardSummary.actionLabel}
</span>
</button>
@@ -6267,20 +6290,6 @@ export function RpgEntryHomeView({
/>
</section>
{canShowReferralRedeemShortcut ? (
<section
className="platform-profile-secondary-shortcuts"
aria-label="次级入口"
>
<ProfileSecondaryShortcutButton
label="填邀请码"
subLabel="新用户奖励"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
</section>
) : null}
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
</>
) : (