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

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