修复画布任务列表服务端任务显示

任务列表只使用服务端返回的生成任务。

保留生成任务水合后的开始和结束时间。

同步任务列表与画布阶段视图测试。
This commit is contained in:
2026-06-29 17:22:42 +08:00
parent 73eb3f6dc8
commit 7417370ae9
7 changed files with 88 additions and 215 deletions
@@ -569,6 +569,8 @@ describe('ImageCanvasEditorModel', () => {
composerOpen: false,
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
generationStartedAt: 1_771_400_000_000,
generationFinishedAt: 1_771_400_004_000,
placeholder: {
x: 100,
y: 120,
@@ -624,6 +626,8 @@ describe('ImageCanvasEditorModel', () => {
status: 'generating',
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
generationStartedAt: 1_771_400_000_000,
generationFinishedAt: 1_771_400_004_000,
placeholder: {
x: 100,
y: 120,
@@ -592,6 +592,8 @@ export function hydrateCanvasGenerationDialog(
aspectRatio: stringOrUndefined(snapshot.aspectRatio),
imageSize: stringOrUndefined(snapshot.imageSize),
errorMessage: stringOrUndefined(snapshot.errorMessage),
generationStartedAt: numberOrUndefined(snapshot.generationStartedAt),
generationFinishedAt: numberOrUndefined(snapshot.generationFinishedAt),
placeholder: hydrateGenerationPlaceholder(snapshot.placeholder),
};
}
@@ -823,6 +825,12 @@ export function numberFromSnapshot(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function numberOrUndefined(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
function audioDurationOrNull(value: unknown) {
if (value && typeof value === 'object' && 'durationSeconds' in value) {
return audioDurationOrNull(
@@ -133,7 +133,6 @@ function createStageProps(): ImageCanvasStageViewProps {
hoveredLayerId: null,
canvasMarquee: null,
canvasGenerationDialogs: [],
backgroundRemovalTasks: [],
isTaskSidebarOpen: false,
generateDialog: null,
cropExpandPanel: null,
@@ -176,8 +175,6 @@ function createStageProps(): ImageCanvasStageViewProps {
onUpdateLayerAssetKind: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onFocusGenerationDialog: vi.fn(),
onFocusTaskLayer: vi.fn(),
onFocusExternalTask: vi.fn(),
onToggleTaskSidebar: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
@@ -1678,7 +1678,6 @@ export function ImageCanvasEditorView() {
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
backgroundRemovalTasks: generationSurface.backgroundRemovalTasks,
isTaskSidebarOpen: generationSurface.isTaskSidebarOpen,
generateDialog,
cropExpandPanel: generationSurface.cropExpandPanel,
@@ -1726,8 +1725,6 @@ export function ImageCanvasEditorView() {
onUpdateLayerAssetKind: updateLayerAssetKind,
onGenerationFramePointerDown: handleGenerationFramePointerDown,
onActivateGenerationDialog: activateCanvasGenerationDialog,
onFocusGenerationDialog: focusCanvasGenerationDialog,
onFocusTaskLayer: focusCanvasLayerById,
onFocusExternalTask: focusExternalGenerationTask,
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
@@ -17,7 +17,6 @@ import type {
CanvasClipboard,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasTaskListItem,
CanvasLayer,
CanvasMarqueeState,
CanvasTool,
@@ -58,7 +57,6 @@ export type ImageCanvasStageViewProps = {
hoveredLayerId: string | null;
canvasMarquee: CanvasMarqueeState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
backgroundRemovalTasks: CanvasTaskListItem[];
isTaskSidebarOpen: boolean;
generateDialog: GenerateDialogState | null;
cropExpandPanel: CropExpandPanelState | null;
@@ -117,8 +115,6 @@ export type ImageCanvasStageViewProps = {
dialog: CanvasGenerationDialogState,
) => void;
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onFocusGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onFocusTaskLayer: (layerId: string) => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onToggleTaskSidebar: () => void;
onCropExpandHandlePointerDown: (
@@ -199,7 +195,6 @@ export function ImageCanvasStageView({
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
backgroundRemovalTasks,
isTaskSidebarOpen,
generateDialog,
cropExpandPanel,
@@ -243,8 +238,6 @@ export function ImageCanvasStageView({
onUpdateLayerAssetKind,
onGenerationFramePointerDown,
onActivateGenerationDialog,
onFocusGenerationDialog,
onFocusTaskLayer,
onFocusExternalTask,
onToggleTaskSidebar,
onCropExpandHandlePointerDown,
@@ -445,12 +438,8 @@ export function ImageCanvasStageView({
<ImageCanvasTaskSidebarView
projectId={projectId}
generationDialogs={canvasGenerationDialogs}
backgroundRemovalTasks={backgroundRemovalTasks}
open={isTaskSidebarOpen}
onToggleOpen={onToggleTaskSidebar}
onFocusGenerationDialog={onFocusGenerationDialog}
onFocusLayer={onFocusTaskLayer}
onFocusExternalTask={onFocusExternalTask}
/>
@@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { listExternalGenerationTasks } from '../../services/external-generation';
import { ImageCanvasTaskSidebarView } from './ImageCanvasTaskSidebarView';
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
vi.mock('../../services/external-generation', () => ({
listExternalGenerationTasks: vi.fn().mockResolvedValue({
@@ -35,27 +34,6 @@ beforeEach(() => {
});
});
function createDialog(
overrides: Partial<CanvasGenerationDialogState>,
): CanvasGenerationDialogState {
return {
id: overrides.id ?? 'dialog-1',
mode: overrides.mode ?? 'generate',
prompt: overrides.prompt ?? '',
status: overrides.status ?? 'idle',
composerOpen: false,
placeholder: {
x: 0,
y: 0,
width: 320,
height: 320,
originalWidth: 1024,
originalHeight: 1024,
},
...overrides,
};
}
function createExternalTask(
overrides: Partial<ExternalGenerationTaskRecord> = {},
): ExternalGenerationTaskRecord {
@@ -85,40 +63,53 @@ function createExternalTask(
describe('ImageCanvasTaskSidebarView', () => {
it('groups tasks by tabs and keeps running tasks before queued tasks', async () => {
const focusGenerationDialog = vi.fn();
const focusExternalTask = vi.fn();
const parentWheel = vi.fn();
const completedAt = new Date(Date.now() - 1000).toISOString();
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) =>
Promise.resolve({
overview: {
pendingCount: 1,
runningCount: 1,
unacknowledgedTerminalCount: 1,
updatedAtMicros: 1,
},
tasks: options.statuses?.includes('running')
? [
createExternalTask({
jobId: 'queued-task',
requestLabel: '图标素材',
status: 'queued',
}),
createExternalTask({
jobId: 'running-task',
requestLabel: '角色图片',
status: 'running',
startedAt: new Date(Date.now() - 3000).toISOString(),
}),
]
: [
createExternalTask({
jobId: 'done-task',
requestLabel: '视频生成',
requestPrompt: '生成一段开场动画',
status: 'completed',
startedAt: new Date(Date.now() - 4000).toISOString(),
completedAt,
updatedAt: completedAt,
}),
],
}),
);
render(
<div onWheel={parentWheel}>
<ImageCanvasTaskSidebarView
projectId="project-1"
generationDialogs={[
createDialog({
id: 'queued-dialog',
mode: 'icon',
status: 'idle',
}),
createDialog({
id: 'running-dialog',
mode: 'character',
status: 'generating',
generationStartedAt: Date.now() - 3000,
}),
createDialog({
id: 'done-dialog',
mode: 'video',
prompt: '生成一段开场动画',
status: 'idle',
generatedLayerId: 'layer-video',
generationStartedAt: Date.now() - 4000,
generationFinishedAt: Date.now() - 1000,
}),
]}
backgroundRemovalTasks={[]}
open
onToggleOpen={vi.fn()}
onFocusGenerationDialog={focusGenerationDialog}
onFocusLayer={vi.fn()}
onFocusExternalTask={vi.fn()}
onFocusExternalTask={focusExternalTask}
/>
</div>,
);
@@ -149,11 +140,18 @@ describe('ImageCanvasTaskSidebarView', () => {
.filter((text) => text.includes('角色图片') || text.includes('图标素材'));
expect(activeTitles[0]).toContain('角色图片');
expect(activeTitles[1]).toContain('图标素材');
await waitFor(() =>
expect(
screen
.getByRole('tab', { name: /\//u })
.getAttribute('aria-selected'),
).toBe('true'),
);
const completedTab = screen.getByRole('tab', { name: //u });
fireEvent.click(completedTab);
expect(screen.queryByText('视频生成')).not.toBeNull();
expect(screen.queryByText(//u)).not.toBeNull();
expect(await screen.findByText('视频生成')).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.queryByText(//u)).not.toBeNull();
expect(screen.queryByText(//u)).toBeNull();
@@ -161,8 +159,8 @@ describe('ImageCanvasTaskSidebarView', () => {
expect(parentWheel).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('视频生成'));
expect(focusGenerationDialog).toHaveBeenCalledWith(
expect.objectContaining({ id: 'done-dialog' }),
expect(focusExternalTask).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'done-task' }),
);
expect(completedTab.getAttribute('aria-selected')).toBe('true');
});
@@ -216,12 +214,8 @@ describe('ImageCanvasTaskSidebarView', () => {
render(
<ImageCanvasTaskSidebarView
projectId="project-1"
generationDialogs={[]}
backgroundRemovalTasks={[]}
open
onToggleOpen={vi.fn()}
onFocusGenerationDialog={vi.fn()}
onFocusLayer={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
@@ -231,9 +225,16 @@ describe('ImageCanvasTaskSidebarView', () => {
).toBeTruthy();
expect(screen.getByText(/ 1/u)).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
await waitFor(() =>
expect(
screen
.getByRole('tab', { name: /\//u })
.getAttribute('aria-selected'),
).toBe('true'),
);
fireEvent.click(screen.getByRole('tab', { name: //u }));
expect(screen.getByText(//u)).toBeTruthy();
expect(await screen.findByText(//u)).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
});
@@ -266,12 +267,8 @@ describe('ImageCanvasTaskSidebarView', () => {
render(
<ImageCanvasTaskSidebarView
projectId="project-1"
generationDialogs={[]}
backgroundRemovalTasks={[]}
open
onToggleOpen={vi.fn()}
onFocusGenerationDialog={vi.fn()}
onFocusLayer={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
@@ -17,11 +17,7 @@ import {
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { listExternalGenerationTasks } from '../../services/external-generation';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type {
CanvasGenerationDialogState,
CanvasTaskListItem,
CanvasTaskStatus,
} from './ImageCanvasEditorTypes';
import type { CanvasTaskStatus } from './ImageCanvasEditorTypes';
const COMPLETED_TASK_LIST_LIMIT = 20;
const ACTIVE_TASK_LIST_LIMIT = 100;
@@ -44,12 +40,8 @@ type TaskSidebarItem = {
type ImageCanvasTaskSidebarViewProps = {
projectId?: string | null;
generationDialogs: CanvasGenerationDialogState[];
backgroundRemovalTasks: CanvasTaskListItem[];
open: boolean;
onToggleOpen: () => void;
onFocusGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onFocusLayer: (layerId: string) => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
};
@@ -85,58 +77,11 @@ function formatTaskTimeMs(timestampMs?: number) {
)}:${pad(date.getMinutes())}`;
}
function generationDialogTitle(dialog: CanvasGenerationDialogState) {
switch (dialog.mode) {
case 'quick-edit':
return '局部修改';
case 'character-animation':
return '角色动画';
case 'video':
return '视频生成';
case 'audio-sound-effect':
return '音效生成';
case 'audio-background-music':
return '背景音乐';
case 'icon':
return '图标素材';
case 'ui-design':
return 'UI素材提取';
case 'publication':
return '宣发素材';
case 'character':
return '角色图片';
case 'spec':
return '图片规范';
default:
return '图片生成';
}
}
function generationDialogDetail(dialog: CanvasGenerationDialogState) {
if (dialog.status === 'generating') {
return '生成中';
}
if (dialog.status === 'failed') {
return dialog.errorMessage || '生成失败';
}
return dialog.generatedLayerId ? '生成完成' : '待提交';
}
function normalizeTaskPrompt(value?: string | null) {
const prompt = value?.trim();
return prompt || null;
}
function generationDialogTaskDetail(
dialog: CanvasGenerationDialogState,
status: CanvasTaskStatus,
) {
if (status === 'done') {
return normalizeTaskPrompt(dialog.prompt) ?? generationDialogTitle(dialog);
}
return generationDialogDetail(dialog);
}
function externalTaskDetail(
task: ExternalGenerationTaskRecord,
status: CanvasTaskStatus,
@@ -183,7 +128,7 @@ function canvasTaskStatusFromExternalTask(
function statusLabel(status: CanvasTaskStatus) {
if (status === 'pending') {
return '待提交';
return '排队中';
}
if (status === 'running') {
return '进行中';
@@ -267,12 +212,8 @@ function mergeExternalTaskRefresh(
export function ImageCanvasTaskSidebarView({
projectId,
generationDialogs,
backgroundRemovalTasks,
open,
onToggleOpen,
onFocusGenerationDialog,
onFocusLayer,
onFocusExternalTask,
}: ImageCanvasTaskSidebarViewProps) {
const [activeTab, setActiveTab] = useState<TaskSidebarTab>('active');
@@ -280,7 +221,6 @@ export function ImageCanvasTaskSidebarView({
const [externalTasks, setExternalTasks] = useState<
ExternalGenerationTaskRecord[]
>([]);
const observedStartedAtRef = useRef(new Map<string, number>());
const previousActiveItemCountRef = useRef(0);
const filterVisibleExternalTasks = useCallback(
(tasks: ExternalGenerationTaskRecord[]) =>
@@ -395,13 +335,16 @@ export function ImageCanvasTaskSidebarView({
window.clearTimeout(timerId);
}
};
}, [activeExternalTaskIds, activeExternalTaskKey, filterVisibleExternalTasks]);
}, [
activeExternalTaskIds,
activeExternalTaskKey,
filterVisibleExternalTasks,
]);
useEffect(() => {
const hasRunningTask =
generationDialogs.some((dialog) => dialog.status === 'generating') ||
backgroundRemovalTasks.some((task) => task.status === 'running') ||
externalTasks.some((task) => task.status === 'running');
const hasRunningTask = externalTasks.some(
(task) => task.status === 'running',
);
if (!hasRunningTask) {
return undefined;
}
@@ -409,66 +352,9 @@ export function ImageCanvasTaskSidebarView({
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timerId);
}, [backgroundRemovalTasks, externalTasks, generationDialogs]);
}, [externalTasks]);
const items = useMemo<TaskSidebarItem[]>(() => {
const resolveStartedAt = (itemId: string, startedAt?: number) => {
if (typeof startedAt === 'number' && Number.isFinite(startedAt)) {
return startedAt;
}
const observedStartedAt = observedStartedAtRef.current.get(itemId);
if (observedStartedAt) {
return observedStartedAt;
}
observedStartedAtRef.current.set(itemId, now);
return now;
};
const generationItems: TaskSidebarItem[] = generationDialogs.map(
(dialog) => {
const status =
dialog.status === 'generating'
? 'running'
: dialog.status === 'failed'
? 'failed'
: dialog.generatedLayerId
? 'done'
: 'pending';
const id = `generation-${dialog.id}`;
const startedAt =
status === 'running'
? resolveStartedAt(id, dialog.generationStartedAt)
: dialog.generationStartedAt;
const finishedAt = dialog.generationFinishedAt;
return {
id,
title: generationDialogTitle(dialog),
detail: generationDialogTaskDetail(dialog, status),
status,
startedAt,
finishedAt,
elapsedMs:
status === 'running' && startedAt
? now - startedAt
: startedAt && finishedAt
? finishedAt - startedAt
: undefined,
errorMessage: dialog.errorMessage,
onClick: () => onFocusGenerationDialog(dialog),
sortTime: finishedAt ?? startedAt ?? 0,
};
},
);
const backgroundItems: TaskSidebarItem[] = backgroundRemovalTasks.map(
(task) => ({
...task,
elapsedMs:
task.status === 'running' ? now - task.startedAt : task.elapsedMs,
onClick: task.focusLayerId
? () => onFocusLayer(task.focusLayerId!)
: undefined,
sortTime: task.finishedAt ?? task.startedAt,
}),
);
const externalVisibleTasks = mergeExternalTasks(
externalTasks.filter(
(task) => task.status === 'queued' || task.status === 'running',
@@ -515,16 +401,8 @@ export function ImageCanvasTaskSidebarView({
0,
};
});
return [...backgroundItems, ...generationItems, ...externalItems];
}, [
backgroundRemovalTasks,
externalTasks,
generationDialogs,
now,
onFocusExternalTask,
onFocusGenerationDialog,
onFocusLayer,
]);
return externalItems;
}, [externalTasks, now, onFocusExternalTask]);
const activeItems = useMemo(
() =>
@@ -597,7 +475,10 @@ export function ImageCanvasTaskSidebarView({
<strong></strong>
<span>{items.length}</span>
</div>
<div className="image-canvas-editor__task-sidebar-tabs" role="tablist">
<div
className="image-canvas-editor__task-sidebar-tabs"
role="tablist"
>
<button
type="button"
role="tab"