diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 070fd6ca..51c774ff 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -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`,任务领取完成后只刷新弹窗内任务中心,卡片本身不更新;页面底部还保留旧的“填邀请码”次级按钮,和当前五项常用功能宫格口径重复。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 600e0327..9899a199 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -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 或异常返回把创作入口初始化直接打崩。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index a74239f8..19c1fecd 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -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; 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, +) { + 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, +) { + 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({}); const [pendingDraftShelfItems, setPendingDraftShelfItems] = useState({}); + 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) => { + 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, 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(null); + const [ + dismissedPlatformTaskCompletionDialogKey, + setDismissedPlatformTaskCompletionDialogKey, + ] = useState(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]" /> + ({ 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( + {}} + />, + ); + + 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( + {}} />, + ); + + expect(screen.queryByRole('dialog', { name: '生成完成' })).toBeNull(); + }); +}); diff --git a/src/components/platform-entry/PlatformTaskCompletionDialog.tsx b/src/components/platform-entry/PlatformTaskCompletionDialog.tsx new file mode 100644 index 00000000..66513dd9 --- /dev/null +++ b/src/components/platform-entry/PlatformTaskCompletionDialog.tsx @@ -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(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 ( + + {copyState === 'copied' ? ( + + ) : ( + + )} + {copyState === 'copied' + ? '已复制' + : copyState === 'failed' + ? '复制失败' + : '复制内容'} + + } + > + {completion ? ( + <> +
+
+ 来源 +
+
+ {completion.source} +
+
+
+
+ 状态 +
+
+ {completion.message} +
+
+ + ) : null} +
+ ); +} diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index baf9807c..d6941800 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -3757,7 +3757,7 @@ test('running match3d form generation can return to draft tab and reopen progres render(); 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(); 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(); 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(); 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(); 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(); 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', {