「生成任务」侧栏读秒改为 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
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:
+63
-42
@@ -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' })]);
|
||||
|
||||
Reference in New Issue
Block a user