feat: 平台错误与完成弹窗收口

This commit is contained in:
kdletters
2026-05-26 15:57:39 +08:00
parent fbda614156
commit abea7cec1d
6 changed files with 347 additions and 11 deletions

View File

@@ -24,6 +24,14 @@
- 验证方式:`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx``npm run typecheck``npm run check:encoding` 通过;手测时异步失败应弹出包含“错误来源”和“错误内容”的弹窗,复制按钮应复制完整诊断文本。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-26 生成任务完成在离开生成页后弹独立完成弹窗
- 背景:抓大鹅、拼图等生成任务完成时,用户如果已经离开生成页,草稿页的未读红点不足以表达“这次生成已完成”;但如果用户仍停留在生成页,结果页或试玩页本身就是完成反馈,不需要再叠一个成功提示。
- 决策:平台壳层在 `markDraftReady(..., viewedImmediately=false)` 时额外弹出 `PlatformTaskCompletionDialog`,完成弹窗必须带来源和复制按钮;如果 `viewedImmediately=true`,只保留结果页 / 试玩页本身的完成反馈和草稿未读态,不重复弹窗。
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/platform-entry/PlatformTaskCompletionDialog.tsx``src/components/platform-entry/PlatformErrorDialog.test.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
- 验证方式:`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "completed match3d draft"` 通过后,离开生成页再完成的草稿应出现“生成完成”弹窗,且复制内容包含来源与状态。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-26 “我的”页任务卡读后端任务摘要并移除常驻填邀请码入口
- 背景:移动端“我的”页每日任务卡曾硬编码 `0 / 1`,任务领取完成后只刷新弹窗内任务中心,卡片本身不更新;页面底部还保留旧的“填邀请码”次级按钮,和当前五项常用功能宫格口径重复。

View File

@@ -14,6 +14,8 @@
平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。
生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`
`platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recent` / `最近创作`,避免旧数据、局部 mock 或异常返回把创作入口初始化直接打崩。

View File

@@ -429,6 +429,10 @@ import {
PlatformErrorDialog,
type PlatformErrorDialogPayload,
} from './PlatformErrorDialog';
import {
PlatformTaskCompletionDialog,
type PlatformTaskCompletionDialogPayload,
} from './PlatformTaskCompletionDialog';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
@@ -444,6 +448,7 @@ type DraftGenerationNoticeStatus = 'generating' | 'ready';
type DraftGenerationNotice = {
status: DraftGenerationNoticeStatus;
seen: boolean;
completedAtMs?: number;
};
type DraftGenerationNoticeMap = Record<string, DraftGenerationNotice>;
type CreationWorkShelfKind = CreationWorkShelfItem['kind'];
@@ -2027,12 +2032,74 @@ function formatPlatformErrorSource(label: string, id?: string | null) {
return normalizedId ? `${label} ${normalizedId}` : label;
}
function formatPlatformTaskCompletionSource(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 buildPlatformTaskCompletionDialogDismissKey(
completion:
| (PlatformTaskCompletionDialogPayload & {
key: string;
completedAtMs: number | null;
})
| null,
) {
return completion
? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}`
: null;
}
function pickDraftCompletionDialogSourceId(
ids: Array<string | null | undefined>,
) {
const normalizedIds = ids
.map((id) => id?.trim() ?? '')
.filter((id) => Boolean(id));
return (
normalizedIds.find((id) => /session/i.test(id)) ??
normalizedIds.find((id) => /work/i.test(id)) ??
normalizedIds.find((id) => /draft/i.test(id)) ??
normalizedIds.find((id) => /run/i.test(id)) ??
normalizedIds.find((id) => /profile/i.test(id)) ??
normalizedIds[0] ??
null
);
}
function buildDraftCompletionDialogSource(
kind: CreationWorkShelfKind,
ids: Array<string | null | undefined>,
) {
const sourceId = pickDraftCompletionDialogSourceId(ids);
switch (kind) {
case 'rpg':
return formatPlatformTaskCompletionSource('RPG 草稿', sourceId);
case 'big-fish':
return formatPlatformTaskCompletionSource('大鱼吃小鱼草稿', sourceId);
case 'match3d':
return formatPlatformTaskCompletionSource('抓大鹅草稿', sourceId);
case 'square-hole':
return formatPlatformTaskCompletionSource('方洞挑战草稿', sourceId);
case 'jump-hop':
return formatPlatformTaskCompletionSource('跳一跳草稿', sourceId);
case 'puzzle':
return formatPlatformTaskCompletionSource('拼图草稿', sourceId);
case 'visual-novel':
return formatPlatformTaskCompletionSource('视觉小说草稿', sourceId);
case 'bark-battle':
return formatPlatformTaskCompletionSource('汪汪声浪草稿', sourceId);
case 'baby-object-match':
return formatPlatformTaskCompletionSource('宝贝识物草稿', sourceId);
}
}
function createMiniGameDraftGenerationStateForRestoredDraft(
kind: MiniGameDraftGenerationKind,
metadata?: MiniGameDraftGenerationState['metadata'],
@@ -3327,6 +3394,16 @@ export function PlatformEntryFlowShellImpl({
useState<DraftGenerationNoticeMap>({});
const [pendingDraftShelfItems, setPendingDraftShelfItems] =
useState<PendingDraftShelfMap>({});
const [
pendingPlatformTaskCompletionDialog,
setPendingPlatformTaskCompletionDialog,
] = useState<
| (PlatformTaskCompletionDialogPayload & {
key: string;
completedAtMs: number | null;
})
| null
>(null);
const [initialCreationUrlState] = useState(() => readCreationUrlState());
const handledInitialCreationUrlStateRef = useRef(false);
const [initialPuzzleRuntimeUrlState] = useState(() =>
@@ -3404,10 +3481,14 @@ export function PlatformEntryFlowShellImpl({
return;
}
const completedAtMs = status === 'ready' ? Date.now() : undefined;
setDraftGenerationNotices((current) => {
const next = { ...current };
for (const key of uniqueKeys) {
next[key] = { status, seen };
next[key] =
completedAtMs === undefined
? { status, seen }
: { status, seen, completedAtMs };
}
return next;
});
@@ -3449,12 +3530,13 @@ export function PlatformEntryFlowShellImpl({
);
const markDraftGenerating = useCallback(
(kind: CreationWorkShelfKind, ids: Array<string | null | undefined>) => {
setPendingPlatformTaskCompletionDialog(null);
updateDraftGenerationNotices(
collectDraftNoticeKeys(kind, ids),
'generating',
);
},
[updateDraftGenerationNotices],
[setPendingPlatformTaskCompletionDialog, updateDraftGenerationNotices],
);
const markDraftReady = useCallback(
(
@@ -3467,17 +3549,27 @@ export function PlatformEntryFlowShellImpl({
'ready',
viewedImmediately,
);
if (!viewedImmediately) {
const completedAtMs = Date.now();
setPendingPlatformTaskCompletionDialog({
key: `${kind}:${collectDraftNoticeKeys(kind, ids).join('|')}:${completedAtMs}`,
source: buildDraftCompletionDialogSource(kind, ids),
message: '生成任务已完成,可以继续查看草稿。',
completedAtMs,
});
}
},
[updateDraftGenerationNotices],
[setPendingPlatformTaskCompletionDialog, updateDraftGenerationNotices],
);
const markPendingDraftGenerating = useCallback(
(
kind: Exclude<CreationWorkShelfKind, 'rpg'>,
id: string | null | undefined,
) => {
setPendingPlatformTaskCompletionDialog(null);
updatePendingDraftShelfItem(kind, id, 'generating');
},
[updatePendingDraftShelfItem],
[setPendingPlatformTaskCompletionDialog, updatePendingDraftShelfItem],
);
const markPendingDraftReady = useCallback(
(
@@ -5790,6 +5882,10 @@ export function PlatformEntryFlowShellImpl({
);
const [dismissedPlatformErrorDialogKey, setDismissedPlatformErrorDialogKey] =
useState<string | null>(null);
const [
dismissedPlatformTaskCompletionDialogKey,
setDismissedPlatformTaskCompletionDialogKey,
] = useState<string | null>(null);
const currentPlatformErrorDialog = useMemo<
(PlatformErrorDialogPayload & { key: string }) | null
>(() => {
@@ -6013,6 +6109,25 @@ export function PlatformEntryFlowShellImpl({
woodenFishRun?.runId,
woodenFishSession?.sessionId,
]);
const currentPlatformTaskCompletionDialog = useMemo<
| (PlatformTaskCompletionDialogPayload & {
key: string;
completedAtMs: number | null;
})
| null
>(() => pendingPlatformTaskCompletionDialog, [
pendingPlatformTaskCompletionDialog,
]);
const activePlatformTaskCompletionDialogDismissKey =
buildPlatformTaskCompletionDialogDismissKey(
currentPlatformTaskCompletionDialog,
);
const activePlatformTaskCompletionDialog =
activePlatformTaskCompletionDialogDismissKey &&
activePlatformTaskCompletionDialogDismissKey ===
dismissedPlatformTaskCompletionDialogKey
? null
: currentPlatformTaskCompletionDialog;
const activePlatformErrorDialogDismissKey =
buildPlatformErrorDialogDismissKey(currentPlatformErrorDialog);
const activePlatformErrorDialog =
@@ -6118,6 +6233,19 @@ export function PlatformEntryFlowShellImpl({
setSquareHoleError,
setVisualNovelError,
]);
const closePlatformTaskCompletionDialog = useCallback(() => {
if (!currentPlatformTaskCompletionDialog) {
return;
}
const dismissKey = buildPlatformTaskCompletionDialogDismissKey(
currentPlatformTaskCompletionDialog,
);
if (dismissKey) {
setDismissedPlatformTaskCompletionDialogKey(dismissKey);
}
setPendingPlatformTaskCompletionDialog(null);
}, [currentPlatformTaskCompletionDialog]);
const shouldPollPuzzleGenerationSession =
selectionStage === 'puzzle-generating' &&
activePuzzleGenerationSessionId != null &&
@@ -7116,6 +7244,7 @@ export function PlatformEntryFlowShellImpl({
setIsProfilePlayStatsOpen(false);
setDraftGenerationNotices({});
setPendingDraftShelfItems({});
setPendingPlatformTaskCompletionDialog(null);
resetRpgSessionViewState();
setRpgGeneratedCustomWorldProfile(null);
setRpgCustomWorldError(null);
@@ -16871,6 +17000,12 @@ export function PlatformEntryFlowShellImpl({
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
panelClassName="platform-remap-surface rounded-[1.5rem]"
/>
<PlatformTaskCompletionDialog
completion={activePlatformTaskCompletionDialog}
onClose={closePlatformTaskCompletionDialog}
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
panelClassName="platform-remap-surface rounded-[1.5rem]"
/>
<UnifiedModal
open={Boolean(pendingDeleteCreationWork)}
title="删除作品"

View File

@@ -11,6 +11,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import * as clipboardService from '../../services/clipboard';
import { PlatformErrorDialog } from './PlatformErrorDialog';
import { PlatformTaskCompletionDialog } from './PlatformTaskCompletionDialog';
vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: vi.fn(),
@@ -58,3 +59,49 @@ describe('PlatformErrorDialog', () => {
expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull();
});
});
describe('PlatformTaskCompletionDialog', () => {
test('shows source, message, and copies the full completion report', async () => {
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
render(
<PlatformTaskCompletionDialog
completion={{
source: '抓大鹅草稿 match3d-notice-session-1',
message: '生成任务已完成,可以继续查看草稿。',
}}
onClose={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: '生成完成' });
expect(
within(dialog).getByText('抓大鹅草稿 match3d-notice-session-1'),
).toBeTruthy();
expect(
within(dialog).getByText('生成任务已完成,可以继续查看草稿。'),
).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '复制内容' }));
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
[
'来源:抓大鹅草稿 match3d-notice-session-1',
'状态:生成任务已完成,可以继续查看草稿。',
].join('\n'),
);
await waitFor(() => {
expect(
within(dialog).getByRole('button', { name: '已复制' }),
).toBeTruthy();
});
});
test('does not render when there is no active completion', () => {
render(
<PlatformTaskCompletionDialog completion={null} onClose={() => {}} />,
);
expect(screen.queryByRole('dialog', { name: '生成完成' })).toBeNull();
});
});

View File

@@ -0,0 +1,124 @@
import { CheckCircle2, Copy } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { UnifiedModal } from '../common/UnifiedModal';
export type PlatformTaskCompletionDialogPayload = {
source: string;
message: string;
};
type PlatformTaskCompletionDialogProps = {
completion: PlatformTaskCompletionDialogPayload | null;
onClose: () => void;
overlayClassName?: string;
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
open={Boolean(completion)}
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={copyCompletion}
disabled={!reportText}
className="platform-button platform-button--primary w-full justify-center gap-2 sm:w-auto"
>
{copyState === 'copied' ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
{copyState === 'copied'
? '已复制'
: copyState === 'failed'
? '复制失败'
: '复制内容'}
</button>
}
>
{completion ? (
<>
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
</div>
<div className="mt-1 break-words text-sm font-semibold leading-5 text-[var(--platform-text-strong)]">
{completion.source}
</div>
</div>
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
</div>
<div className="mt-1 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
{completion.message}
</div>
</div>
</>
) : null}
</UnifiedModal>
);
}

View File

@@ -3757,7 +3757,7 @@ test('running match3d form generation can return to draft tab and reopen progres
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
@@ -3841,7 +3841,7 @@ test('running match3d persisted draft reopens progress instead of unfinished res
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
@@ -4038,7 +4038,7 @@ test('running match3d form generation keeps other creation templates available',
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
@@ -4107,7 +4107,7 @@ test('running match3d form generation keeps same template generation available',
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
@@ -4721,7 +4721,7 @@ test('match3d draft generation auto starts trial and runtime back opens draft re
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
@@ -4953,11 +4953,15 @@ test('completed match3d draft notice first opens trial then reopens result', asy
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
@@ -4966,6 +4970,22 @@ test('completed match3d draft notice first opens trial then reopens result', asy
resolveCompile({ session: generatedSession });
});
const completionDialog = await screen.findByRole('dialog', {
name: '生成完成',
});
expect(
within(completionDialog).getByText(
/抓大鹅草稿 match3d-notice-session-1/u,
),
).toBeTruthy();
expect(
within(completionDialog).getByText(/生成任务已完成/u),
).toBeTruthy();
expect(
within(completionDialog).getByRole('button', { name: '复制内容' }),
).toBeTruthy();
await user.click(within(completionDialog).getByLabelText('关闭'));
expect(await screen.findByLabelText('新生成完成')).toBeTruthy();
await user.click(
await screen.findByRole('button', {