diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx index 07ded6984..ed1a33d0b 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx @@ -1,7 +1,7 @@ import './resourceCanvasAssetGenerationTasksSidebar.css'; import { ListChecks, X } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS, @@ -16,6 +16,12 @@ import { /** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */ export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20; +/** + * 收起动画时长,必须与 `resourceCanvasAssetGenerationTasksSidebar.css` 里的 + * `game-resource-generation-tasks-leave` 一致:收起时侧栏要先播完这一段再卸载。 + */ +export const RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS = 160; + export type ResourceCanvasAssetGenerationTasksPanelViewProps = { tasks: readonly ResourceCanvasAssetGenerationTask[]; /** 侧栏是否展开;折叠时只留贴边把手。 */ @@ -102,6 +108,13 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ onFocusTask, }: ResourceCanvasAssetGenerationTasksPanelViewProps) { const [nowMillis, setNowMillis] = useState(() => Date.now()); + /** + * 收起动画期:`open` 已经变 false,但侧栏还要留在 DOM 里把 `…-leave` 播完 + * (`entering` 是刚打开时给根节点挂进场动画的那一档)。 + */ + const [phase, setPhase] = useState<'idle' | 'entering' | 'leaving'>( + open ? 'entering' : 'idle', + ); const inFlightCount = tasks.filter( (task) => !resourceCanvasAssetGenerationTaskIsTerminal(task), ).length; @@ -120,6 +133,29 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ 0, RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT, ); + /** + * 收起动画期间必须沿用最后一份列表:宿主会在同一帧里收起侧栏并把「在途」收口成 + * 已完成,直接吃新 props 会让退出动画里的内容跳一下。 + */ + const lastRenderedRef = useRef({ + inFlightCount, + ordered, + active, + visibleDone, + done, + }); + if (open) { + lastRenderedRef.current = { + inFlightCount, + ordered, + active, + visibleDone, + done, + }; + } + const rendered = open + ? { inFlightCount, ordered, active, visibleDone, done } + : lastRenderedRef.current; // 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。 useEffect(() => { @@ -130,18 +166,41 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ return () => clearInterval(timer); }, [hasLiveTask]); + /** + * open → 进场,close → 先留一帧播 `…-leave` 再卸载。 + * + * 减少动效偏好下这一段动画在 CSS 里被关掉,所以那次收起要**立即**卸载,不能白等 160ms。 + */ + useEffect(() => { + if (open) { + setPhase('entering'); + return undefined; + } + setPhase((current) => (current === 'idle' ? 'idle' : 'leaving')); + const reducedMotion = + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const timer = window.setTimeout( + () => setPhase('idle'), + reducedMotion ? 0 : RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS, + ); + return () => window.clearTimeout(timer); + }, [open]); + // 折叠态不再在画布左侧留贴边把手:开合口只剩工具条上那一枚「生成任务」按钮 - // (带在途计数),收起时侧栏完全让出画布。 - if (!open) { + // (带在途计数),收起时侧栏完全让出画布(播完收起动画之后)。 + if (!open && phase === 'idle') { return null; } return (