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 216f8800d..f526ea984 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 @@ -3,6 +3,7 @@ import './resourceCanvasAssetGenerationTasksSidebar.css'; import { ListChecks, X } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { useLiveNow } from '../project-workspace/useLiveNow'; import { RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS, resourceCanvasAssetGenerationElapsedLabel, @@ -104,9 +105,57 @@ function sortResourceCanvasGenerationTaskRows( ); } +/** + * 一条图片类生成任务的「已耗时」。 + * + * 刷新粒度走对话侧同一份 `useLiveNow`(`LIVE_TIMER_TICK_MS = 100`):不足一分钟的耗时显示到 + * 0.1 秒,若按 1 秒一跳,用户看到的是一块「带小数却一格一格跳」的表,像卡住不动。 + * + * 时钟只订在**这一行**上(叶子节点):面板上通常同时挂着多条任务,把 100ms 的 tick 放在整块 + * 面板上等于每 0.1 秒重建整个列表——`useLiveNow` 的注释写明「调用方必须是叶子节点」。任务收口 + * 后(终态)不再订阅,已耗时就定格在账本给的结束时间上。 + */ +function AssetGenerationElapsedLabel({ + task, +}: { + task: ResourceCanvasAssetGenerationTask; +}) { + const now = useLiveNow(!resourceCanvasAssetGenerationTaskIsTerminal(task)); + return ( + + {`已耗时 ${resourceCanvasAssetGenerationElapsedLabel( + resourceCanvasAssetGenerationTaskElapsedMillis(task, now), + )}`} + + ); +} + +/** + * 一条派生/修改任务的「已耗时」。 + * + * 同上:粒度与对话侧同一份 `useLiveNow`,只换资源编辑账本那一份文案入口。从原生账本恢复来的 + * 收口记录没有结束时间(`elapsedMillis === null`)时整格不渲染——宁可不显示,也不拿创建时间 + * 冒充耗时。 + */ +function ResourceEditElapsedLabel({ + task, +}: { + task: ResourceCanvasResourceEditTask; +}) { + const now = useLiveNow(!resourceCanvasResourceEditTaskIsTerminal(task)); + const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis(task, now); + if (elapsedMillis === null) { + return null; + } + return ( + + {`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`} + + ); +} + function assetGenerationTaskRow( task: ResourceCanvasAssetGenerationTask, - nowMillis: number, onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void, ) { const focusable = task.status === 'completed' && Boolean(task.assetId); @@ -134,11 +183,7 @@ function assetGenerationTaskRow( {task.phaseDetail} - - {`已耗时 ${resourceCanvasAssetGenerationElapsedLabel( - resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis), - )}`} - + {task.error ? (

@@ -166,14 +211,7 @@ function assetGenerationTaskRow( * 给的待办记录里没有产物 id,编一个指向不明的跳转不如不给;② 提示词单独一行,用户要能认出手上 * 这行是哪一次修改。 */ -function resourceEditTaskRow( - task: ResourceCanvasResourceEditTask, - nowMillis: number, -) { - const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis( - task, - nowMillis, - ); +function resourceEditTaskRow(task: ResourceCanvasResourceEditTask) { return (

  • {task.phaseDetail} - {elapsedMillis === null ? null : ( - - {`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`} - - )} + {task.error ? (

    @@ -224,12 +258,11 @@ function resourceEditTaskRow( function resourceCanvasGenerationTaskRowNode( row: ResourceCanvasGenerationTaskRow, - nowMillis: number, onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void, ) { return row.source === 'asset-generation' - ? assetGenerationTaskRow(row.task, nowMillis, onFocusTask) - : resourceEditTaskRow(row.task, nowMillis); + ? assetGenerationTaskRow(row.task, onFocusTask) + : resourceEditTaskRow(row.task); } /** @@ -257,7 +290,6 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ onFocusTask, placement = 'canvas', }: ResourceCanvasAssetGenerationTasksPanelViewProps) { - const [nowMillis, setNowMillis] = useState(() => Date.now()); /** * 收起动画期:`open` 已经变 false,但侧栏还要留在 DOM 里把 `…-leave` 播完 * (`entering` 是刚打开时给根节点挂进场动画的那一档)。 @@ -291,7 +323,6 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ ); const done = ordered.filter(resourceCanvasGenerationTaskRowIsTerminal); const inFlightCount = active.length; - const hasLiveTask = inFlightCount > 0; const visibleDone = done.slice( 0, RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT, @@ -320,14 +351,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ ? { inFlightCount, ordered, active, visibleDone, done } : lastRenderedRef.current; - // 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。 - useEffect(() => { - if (!hasLiveTask) { - return undefined; - } - const timer = setInterval(() => setNowMillis(Date.now()), 1_000); - return () => clearInterval(timer); - }, [hasLiveTask]); + /* + * 已耗时是前端计时(后端只给时间戳):秒表**不在这一层**——面板上通常同时挂着多条任务, + * 在这里订阅 100ms 的 tick 等于每 0.1 秒重建整个侧栏。计时下沉到每一行的 + * `AssetGenerationElapsedLabel` / `ResourceEditElapsedLabel`,只在**还有未终态任务**时 + * 订阅(终态行定格在账本的结束时间上)。 + */ /** * open → 进场,close → 先留一帧播 `…-leave` 再卸载。 @@ -436,11 +465,7 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ ) : (

      {rendered.active.map((row) => - resourceCanvasGenerationTaskRowNode( - row, - nowMillis, - onFocusTask, - ), + resourceCanvasGenerationTaskRowNode(row, onFocusTask), )}
    )} @@ -463,11 +488,7 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ <>
      {rendered.visibleDone.map((row) => - resourceCanvasGenerationTaskRowNode( - row, - nowMillis, - onFocusTask, - ), + resourceCanvasGenerationTaskRowNode(row, onFocusTask), )}
    {rendered.done.length > rendered.visibleDone.length ? ( diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx index 3e911ea8f..5d01d2ac8 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx @@ -133,6 +133,100 @@ describe('「生成任务」侧栏', () => { ).toBeGreaterThan(0); }); + /** + * 读秒粒度:与对话侧同一份 `useLiveNow`(`LIVE_TIMER_TICK_MS = 100`)。 + * + * 不足一分钟的耗时显示到 0.1 秒,刷新就必须是 100ms——按 1 秒一跳时,用户看到的是一块 + * 「带小数却一格一格跳」的表,像卡住不动(验收现场「生成任务里的读秒更新频率应为 0.1s」)。 + */ + test('读秒按 100ms 刷新:不足一分钟的耗时以 0.1 秒递增', () => { + vi.useFakeTimers(); + try { + // 假时钟与任务的创建时间对齐(夹具的 `nowMillis` 是 1000)。 + vi.setSystemTime(1_000); + renderSidebar([ + task({ + taskId: 't1', + dispatched: true, + status: 'running', + phaseDetail: '正在生成。', + }), + ]); + const elapsed = () => screen.getByText(/^已耗时 /u).textContent; + expect(elapsed()).toBe('已耗时 0.0秒'); + + act(() => { + vi.advanceTimersByTime(100); + }); + expect(elapsed()).toBe('已耗时 0.1秒'); + + // 1 秒一跳的实现会停在上面的 0.1 秒:再走 400ms 必须继续往上加。 + act(() => { + vi.advanceTimersByTime(400); + }); + expect(elapsed()).toBe('已耗时 0.5秒'); + } finally { + vi.useRealTimers(); + } + }); + + test('任务收口后不再走表:已耗时定格在账本给的结束时间上', () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(1_000); + renderSidebar([ + task({ + taskId: 't1', + dispatched: true, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: 'asset-1', + finishedAtMillis: 2_500, + }), + ]); + const elapsed = () => screen.getByText(/^已耗时 /u).textContent; + expect(elapsed()).toBe('已耗时 1.5秒'); + + act(() => { + vi.advanceTimersByTime(5_000); + }); + expect(elapsed()).toBe('已耗时 1.5秒'); + } finally { + vi.useRealTimers(); + } + }); + + /** + * 秒表只订在**运行中的那一行**上:终态行不订阅,面板本身也不订阅。 + * + * 这条是性能约束的可执行版本——100ms 的 tick 若落在整块面板或终态行上,每 0.1 秒都会重建 + * 整个侧栏(`useLiveNow` 的注释写明「调用方必须是叶子节点」)。 + */ + test('秒表只订在运行中的那一行上,终态行与面板本身都不订阅时钟', () => { + const spy = vi.spyOn(globalThis, 'setInterval'); + try { + renderSidebar([ + task({ + taskId: 'live', + dispatched: true, + status: 'running', + phaseDetail: '正在生成。', + }), + task({ + taskId: 'done', + dispatched: true, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: 'asset-done', + finishedAtMillis: 2_000, + }), + ]); + expect(spy).toHaveBeenCalledTimes(1); + } finally { + spy.mockRestore(); + } + }); + test('收起只有一个入口:头部那枚关闭按钮,收起即整块让出画布', async () => { const user = userEvent.setup(); const { onToggleOpen } = renderSidebar([task({ taskId: 't1' })]);