feat: surface platform errors in copyable dialogs
This commit is contained in:
@@ -51,7 +51,6 @@ test('dispatches wooden fish creation type selection', () => {
|
||||
<PlatformEntryCreationTypeModal
|
||||
isOpen
|
||||
isBusy={false}
|
||||
error={null}
|
||||
entryConfig={entryConfig}
|
||||
creationTypes={derivePlatformCreationTypes(entryConfig.creationTypes)}
|
||||
onClose={() => {}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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="删除作品"
|
||||
|
||||
60
src/components/platform-entry/PlatformErrorDialog.test.tsx
Normal file
60
src/components/platform-entry/PlatformErrorDialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
120
src/components/platform-entry/PlatformErrorDialog.tsx
Normal file
120
src/components/platform-entry/PlatformErrorDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user