「生成任务」侧栏读秒改为 100ms 粒度:与对话侧共用同一份时钟
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m57s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 4m2s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m15s
Project CI / Repository checks (pull_request) Failing after 20s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m16s
Project CI / Frontend tests (pull_request) Successful in 7m30s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m1s
Project CI / Native shell tests (pull_request) Successful in 11m1s

- ResourceCanvasAssetGenerationTasksPanelView:删掉面板自带的 1000ms interval 与 nowMillis 状态;两条任务行的「已耗时」下沉为叶子组件(AssetGenerationElapsedLabel / ResourceEditElapsedLabel),各自订阅共享 hook useLiveNow(LIVE_TIMER_TICK_MS = 100)。
- 为什么不是面板级时钟:面板上通常同时挂着多条任务,100ms 的 tick 落在面板上等于每 0.1 秒重建整个侧栏;useLiveNow 的注释明确要求调用方是叶子节点。终态行不订阅,已耗时定格在账本给的结束时间上。
- 行为:不足一分钟的耗时本来就显示到 0.1 秒(formatElapsedDuration),现在每 100ms 真的更新一次,不再是「带小数却 1 秒跳一格」。
- 用例:新增「读秒按 100ms 刷新(0.0 → 0.1 → 0.5 秒)」「终态行不再走表,定格在结束时间」「秒表只订在运行中的那一行上(setInterval 只被调用一次)」;把 LIVE_TIMER_TICK_MS 临时改回 1000 时第一条用例即红,粒度确实被钉住。
This commit is contained in:
2026-09-21 12:48:21 +08:00
parent b4ce6877c8
commit 562e1ba586
2 changed files with 157 additions and 42 deletions
@@ -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 (
<span className="game-resource-generation-task-card-elapsed">
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
resourceCanvasAssetGenerationTaskElapsedMillis(task, now),
)}`}
</span>
);
}
/**
* 一条派生/修改任务的「已耗时」。
*
* 同上:粒度与对话侧同一份 `useLiveNow`,只换资源编辑账本那一份文案入口。从原生账本恢复来的
* 收口记录没有结束时间(`elapsedMillis === null`)时整格不渲染——宁可不显示,也不拿创建时间
* 冒充耗时。
*/
function ResourceEditElapsedLabel({
task,
}: {
task: ResourceCanvasResourceEditTask;
}) {
const now = useLiveNow(!resourceCanvasResourceEditTaskIsTerminal(task));
const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis(task, now);
if (elapsedMillis === null) {
return null;
}
return (
<span className="game-resource-generation-task-card-elapsed">
{`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`}
</span>
);
}
function assetGenerationTaskRow(
task: ResourceCanvasAssetGenerationTask,
nowMillis: number,
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
) {
const focusable = task.status === 'completed' && Boolean(task.assetId);
@@ -134,11 +183,7 @@ function assetGenerationTaskRow(
<span className="game-resource-generation-task-card-phase">
{task.phaseDetail}
</span>
<span className="game-resource-generation-task-card-elapsed">
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis),
)}`}
</span>
<AssetGenerationElapsedLabel task={task} />
</div>
{task.error ? (
<p className="game-resource-generation-task-card-error" role="alert">
@@ -166,14 +211,7 @@ function assetGenerationTaskRow(
* 给的待办记录里没有产物 id,编一个指向不明的跳转不如不给;② 提示词单独一行,用户要能认出手上
* 这行是哪一次修改。
*/
function resourceEditTaskRow(
task: ResourceCanvasResourceEditTask,
nowMillis: number,
) {
const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis(
task,
nowMillis,
);
function resourceEditTaskRow(task: ResourceCanvasResourceEditTask) {
return (
<li
key={task.operationId}
@@ -199,11 +237,7 @@ function resourceEditTaskRow(
<span className="game-resource-generation-task-card-phase">
{task.phaseDetail}
</span>
{elapsedMillis === null ? null : (
<span className="game-resource-generation-task-card-elapsed">
{`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`}
</span>
)}
<ResourceEditElapsedLabel task={task} />
</div>
{task.error ? (
<p className="game-resource-generation-task-card-error" role="alert">
@@ -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({
) : (
<ul className="game-resource-generation-tasks-list">
{rendered.active.map((row) =>
resourceCanvasGenerationTaskRowNode(
row,
nowMillis,
onFocusTask,
),
resourceCanvasGenerationTaskRowNode(row, onFocusTask),
)}
</ul>
)}
@@ -463,11 +488,7 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
<>
<ul className="game-resource-generation-tasks-list">
{rendered.visibleDone.map((row) =>
resourceCanvasGenerationTaskRowNode(
row,
nowMillis,
onFocusTask,
),
resourceCanvasGenerationTaskRowNode(row, onFocusTask),
)}
</ul>
{rendered.done.length > rendered.visibleDone.length ? (
@@ -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' })]);