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