From 754d36422793703c86e783faa8eeca61c2b9f070 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 7 Jul 2026 19:45:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=88=B7=E6=96=B0=E5=90=8E=E5=AE=8C=E6=88=90=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务列表识别当前画布项目的未确认完成任务并发出完成通知。 编辑器收到任务完成通知后重新加载项目快照并刷新素材库。 补充刷新后任务已完成和轮询完成的回归测试。 --- ...CanvasEditorGenerationIntegration.test.tsx | 219 +++++++++++++++++- .../image-editor/ImageCanvasEditorView.tsx | 13 ++ .../image-editor/ImageCanvasStageView.tsx | 3 + .../ImageCanvasTaskSidebarView.test.tsx | 87 ++++++- .../ImageCanvasTaskSidebarView.tsx | 77 +++++- 5 files changed, 382 insertions(+), 17 deletions(-) diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index d2f40c3c0..b26be96aa 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -11,6 +11,7 @@ import { import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; import { ApiClientError, defaultEditorProjectLayers, @@ -244,6 +245,34 @@ describe('ImageCanvasEditorView generation integration', () => { updatedAt: '2026-06-19T00:00:00.000Z', }); + const createExternalGenerationTask = ( + overrides: Partial = {}, + ): ExternalGenerationTaskRecord => { + const now = new Date().toISOString(); + return { + jobId: overrides.jobId ?? 'task-editor-character-refresh', + jobKind: overrides.jobKind ?? 'editor_image_generation', + sourceModule: overrides.sourceModule ?? 'editor-canvas', + sourceEntityId: + overrides.sourceEntityId ?? 'editor-project-refresh-completion', + requestLabel: overrides.requestLabel ?? '图片画布生成角色形象', + requestPrompt: overrides.requestPrompt ?? '刷新后角色', + status: overrides.status ?? 'running', + phaseLabel: overrides.phaseLabel ?? '生成图片', + phaseDetail: overrides.phaseDetail ?? '正在生成角色形象。', + progress: overrides.progress ?? 40, + error: overrides.error ?? null, + priceMudPoints: overrides.priceMudPoints ?? 3, + refundLedgerId: overrides.refundLedgerId ?? null, + notificationAcknowledgedAt: overrides.notificationAcknowledgedAt ?? null, + createdAt: overrides.createdAt ?? now, + startedAt: overrides.startedAt ?? now, + completedAt: overrides.completedAt ?? null, + updatedAt: overrides.updatedAt ?? now, + updatedAtMicros: overrides.updatedAtMicros ?? 1, + }; + }; + const createGeneratedImageResponse = ( input: { projectId?: string | null; @@ -552,7 +581,9 @@ describe('ImageCanvasEditorView generation integration', () => { fireEvent.click(generatedImage.closest('button')!); - expect(await screen.findByRole('dialog', { name: '生成图片' })).toBeTruthy(); + expect( + await screen.findByRole('dialog', { name: '生成图片' }), + ).toBeTruthy(); expect( (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, ).toBe('元数据恢复生成器'); @@ -2719,6 +2750,184 @@ describe('ImageCanvasEditorView generation integration', () => { ).toBeTruthy(); }); + it('reloads project and asset snapshots when a restored queued generation completed after refresh', async () => { + window.history.replaceState(null, '', '/editor/canvas'); + const projectId = 'editor-project-refresh-completion'; + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId, + title: '刷新恢复画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:generation-dialog-refresh-character', + resourceId: 'generation-dialog:generation-dialog-refresh-character', + dialog: { + id: 'generation-dialog-refresh-character', + mode: 'character', + prompt: '刷新后角色', + status: 'generating', + composerOpen: false, + imageModel: 'gpt-image-2', + placeholder: { + x: 240, + y: 120, + width: 420, + height: 560, + originalWidth: 1024, + originalHeight: 1536, + }, + }, + }, + ], + resources: [], + updatedAt: '2026-06-21T00:00:00.000Z', + }); + const generatedResource = createGeneratedResource({ + resourceId: 'resource-refresh-character-result', + projectId, + imageSrc: 'data:image/png;base64,cmVmcmVzaC1yZXN1bHQ=', + width: 1024, + height: 1536, + assetKind: 'character', + generationInputs: { + fields: [{ title: '角色设定', value: '刷新后角色' }], + references: [], + }, + }); + loadEditorProjectMock.mockResolvedValueOnce({ + projectId, + title: '刷新恢复画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-refresh-character-result', + resourceId: 'resource-refresh-character-result', + title: '刷新后生成结果', + x: 240, + y: 120, + width: 1024, + height: 1536, + originalWidth: 1024, + originalHeight: 1536, + zIndex: 12, + sourceType: 'generated', + assetKind: 'character', + }, + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:generation-dialog-refresh-character', + resourceId: 'generation-dialog:generation-dialog-refresh-character', + dialog: { + id: 'generation-dialog-refresh-character', + mode: 'character', + prompt: '刷新后角色', + status: 'idle', + composerOpen: false, + generatedLayerId: 'layer-refresh-character-result', + imageModel: 'gpt-image-2', + placeholder: { + x: 240, + y: 120, + width: 420, + height: 560, + originalWidth: 1024, + originalHeight: 1536, + }, + }, + }, + ], + resources: [generatedResource], + updatedAt: '2026-06-21T00:01:00.000Z', + }); + loadEditorAssetLibraryMock + .mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [], + }) + .mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createGeneratedAsset({ + assetId: 'asset-refresh-character-result', + folderId: 'project', + label: '刷新后生成结果素材', + imageSrc: 'data:image/png;base64,cmVmcmVzaC1yZXN1bHQ=', + width: 1024, + height: 1536, + assetKind: 'character', + }), + ], + }); + const completedTask = createExternalGenerationTask({ + jobId: 'task-refresh-character-result', + sourceEntityId: projectId, + requestPrompt: '刷新后角色', + status: 'completed', + progress: 100, + phaseDetail: '生成已完成。', + completedAt: '2026-06-21T00:01:00.000Z', + updatedAt: '2026-06-21T00:01:00.000Z', + updatedAtMicros: 2, + }); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const requestUrl = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url; + const url = new URL(requestUrl, 'http://localhost'); + if (url.pathname === '/api/runtime/external-generation/jobs') { + const statuses = url.searchParams.get('statuses') ?? ''; + return new Response( + JSON.stringify({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: 1, + updatedAtMicros: 2, + }, + tasks: statuses.includes('completed') ? [completedTask] : [], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + return new Response(null, { status: 404 }); + }); + + render(); + + await waitFor(() => { + expect(loadEditorProjectMock).toHaveBeenCalledWith(projectId); + }); + expect(await screen.findByAltText('画布图片:刷新后生成结果')).toBeTruthy(); + await waitFor(() => { + expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2); + }); + expect(createEditorAssetMock).not.toHaveBeenCalled(); + expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); + }); + it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { @@ -3174,7 +3383,9 @@ describe('ImageCanvasEditorView generation integration', () => { }); expect( - within(screen.getByRole('dialog', { name: 'UI素材提取' })).getByRole('button', { + within( + screen.getByRole('dialog', { name: 'UI素材提取' }), + ).getByRole('button', { name: '提取', }).disabled, ).toBe(false); @@ -3265,9 +3476,7 @@ describe('ImageCanvasEditorView generation integration', () => { }); render(); - const uiDesignImage = await screen.findByAltText( - '画布图片:背包UI设计图', - ); + const uiDesignImage = await screen.findByAltText('画布图片:背包UI设计图'); fireEvent.pointerDown(uiDesignImage.closest('button')!, { button: 0, pointerId: 256, diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index d74f18dbb..561bd2f15 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1052,6 +1052,18 @@ export function ImageCanvasEditorView({ }, [applyProjectSnapshot, refreshAssetLibrary], ); + const handleExternalGenerationTasksCompleted = useCallback( + (tasks: ExternalGenerationTaskRecord[]) => { + if (!projectId || tasks.length === 0) { + return; + } + refreshEditorWalletBalance(); + void loadEditorProject(projectId) + .then(applyGeneratedProjectSnapshot) + .catch(() => undefined); + }, + [applyGeneratedProjectSnapshot, projectId, refreshEditorWalletBalance], + ); const handleEditorAgentGenerationResult = useCallback( (event: EditorAgentGenerationResultEvent) => { const hasGeneratedResource = event.images.some((image) => @@ -2033,6 +2045,7 @@ export function ImageCanvasEditorView({ onGenerationFrameContextMenu: handleGenerationFrameContextMenu, onActivateGenerationDialog: activateCanvasGenerationDialog, onFocusExternalTask: focusExternalGenerationTask, + onExternalTasksCompleted: handleExternalGenerationTasksCompleted, onEditorAgentGenerationResult: handleEditorAgentGenerationResult, onToggleTaskSidebar: toggleTaskSidebar, onToggleAgentConversation: toggleAgentConversation, diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 3867fb717..fb9a8630a 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -125,6 +125,7 @@ export type ImageCanvasStageViewProps = { ) => void; onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void; onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; + onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void; onEditorAgentGenerationResult?: ( event: EditorAgentGenerationResultEvent, ) => void; @@ -257,6 +258,7 @@ export function ImageCanvasStageView({ onGenerationFrameContextMenu, onActivateGenerationDialog, onFocusExternalTask, + onExternalTasksCompleted, onEditorAgentGenerationResult, onToggleTaskSidebar, onToggleAgentConversation, @@ -465,6 +467,7 @@ export function ImageCanvasStageView({ open={isTaskSidebarOpen} onToggleOpen={onToggleTaskSidebar} onFocusExternalTask={onFocusExternalTask} + onExternalTasksCompleted={onExternalTasksCompleted} /> {isAgentConversationEnabled ? ( diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx index 41fed163c..32915556e 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.test.tsx @@ -1,6 +1,12 @@ // @vitest-environment jsdom -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; @@ -239,9 +245,7 @@ describe('ImageCanvasTaskSidebarView', () => { expect(await screen.findByText('图片画布生成图片')).toBeTruthy(); expect(screen.getByText(/发光猫咪主视觉/u)).toBeTruthy(); - expect( - screen.getByText(/正在生成第 2\/4 段。 · 已用时 1分/u), - ).toBeTruthy(); + expect(screen.getByText(/正在生成第 2\/4 段。 · 已用时 1分/u)).toBeTruthy(); expect(screen.queryByText(/35%/u)).toBeNull(); expect(screen.getByText(/已用时 1分/u)).toBeTruthy(); expect(screen.queryByText(/总进度/u)).toBeNull(); @@ -356,6 +360,81 @@ describe('ImageCanvasTaskSidebarView', () => { expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(4); }); + it('notifies when a restored active external task completes during polling', async () => { + vi.useFakeTimers(); + try { + const onExternalTasksCompleted = vi.fn(); + let activeRequestCount = 0; + const runningTask = createExternalTask({ + jobId: 'restored-character-task', + requestLabel: '角色图片', + status: 'running', + }); + const completedTask = createExternalTask({ + ...runningTask, + status: 'completed', + progress: 100, + phaseDetail: '生成已完成。', + completedAt: new Date().toISOString(), + }); + listExternalGenerationTasksMock.mockImplementation( + (options: Parameters[0] = {}) => { + if (options.statuses?.includes('running')) { + activeRequestCount += 1; + return Promise.resolve({ + overview: { + pendingCount: activeRequestCount === 1 ? 1 : 0, + runningCount: activeRequestCount === 1 ? 1 : 0, + unacknowledgedTerminalCount: activeRequestCount === 1 ? 0 : 1, + updatedAtMicros: activeRequestCount, + }, + tasks: activeRequestCount === 1 ? [runningTask] : [], + }); + } + return Promise.resolve({ + overview: { + pendingCount: 0, + runningCount: 0, + unacknowledgedTerminalCount: activeRequestCount > 1 ? 1 : 0, + updatedAtMicros: activeRequestCount, + }, + tasks: activeRequestCount > 1 ? [completedTask] : [], + }); + }, + ); + + render( + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText('角色图片')).toBeTruthy(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + await Promise.resolve(); + }); + + expect(onExternalTasksCompleted).toHaveBeenCalledWith([ + expect.objectContaining({ + jobId: 'restored-character-task', + status: 'completed', + }), + ]); + expect(onExternalTasksCompleted).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + it('loads more completed tasks while scrolling and keeps the request capped', async () => { const completedTasks = Array.from({ length: 120 }, (_, index) => { const completedAt = new Date(Date.now() - index * 1000).toISOString(); diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index a63b12682..1457153fa 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -47,6 +47,7 @@ type ImageCanvasTaskSidebarViewProps = { open: boolean; onToggleOpen: () => void; onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void; + onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void; }; function parseTaskTimeMs(value?: string | null) { @@ -117,9 +118,9 @@ function externalTaskProgressDetail( if (!isActiveTaskStatus(status)) { return null; } - const values = [ - task.phaseDetail.trim(), - ].filter((value): value is string => Boolean(value)); + const values = [task.phaseDetail.trim()].filter((value): value is string => + Boolean(value), + ); return values.length > 0 ? values.join(' · ') : null; } @@ -205,6 +206,10 @@ function isTerminalExternalTask(task: ExternalGenerationTaskRecord) { return task.status === 'completed' || task.status === 'failed'; } +function isCompletedExternalTask(task: ExternalGenerationTaskRecord) { + return task.status === 'completed'; +} + function isEditorCanvasTaskForProject( task: ExternalGenerationTaskRecord, projectId?: string | null, @@ -256,6 +261,7 @@ export function ImageCanvasTaskSidebarView({ open, onToggleOpen, onFocusExternalTask, + onExternalTasksCompleted, }: ImageCanvasTaskSidebarViewProps) { const projectId = useImageCanvasContextStore((state) => state.projectId); const normalizedProjectId = projectId?.trim() ?? ''; @@ -273,6 +279,8 @@ export function ImageCanvasTaskSidebarView({ ? completedListState.limit : COMPLETED_TASK_LIST_LIMIT; const previousActiveItemCountRef = useRef(0); + const activeExternalTaskIdsRef = useRef>(new Set()); + const completedRefreshNotifiedTaskIdsRef = useRef>(new Set()); const filterVisibleExternalTasks = useCallback( (tasks: ExternalGenerationTaskRecord[]) => tasks.filter((task) => @@ -280,6 +288,39 @@ export function ImageCanvasTaskSidebarView({ ), [normalizedProjectId], ); + const notifyCompletedExternalTasks = useCallback( + ( + tasks: ExternalGenerationTaskRecord[], + options: { + activeTaskIds?: ReadonlySet; + unacknowledgedOnly?: boolean; + } = {}, + ) => { + const newlyCompletedTasks = tasks.filter((task) => { + if ( + !isCompletedExternalTask(task) || + completedRefreshNotifiedTaskIdsRef.current.has(task.jobId) + ) { + return false; + } + if (options.activeTaskIds && !options.activeTaskIds.has(task.jobId)) { + return false; + } + if (options.unacknowledgedOnly && task.notificationAcknowledgedAt) { + return false; + } + return true; + }); + if (!newlyCompletedTasks.length) { + return; + } + for (const task of newlyCompletedTasks) { + completedRefreshNotifiedTaskIdsRef.current.add(task.jobId); + } + onExternalTasksCompleted?.(newlyCompletedTasks); + }, + [onExternalTasksCompleted], + ); useEffect(() => { setCompletedListState((state) => @@ -287,6 +328,7 @@ export function ImageCanvasTaskSidebarView({ ? state : { limit: COMPLETED_TASK_LIST_LIMIT, projectId: normalizedProjectId }, ); + completedRefreshNotifiedTaskIdsRef.current = new Set(); }, [normalizedProjectId]); useEffect(() => { @@ -310,12 +352,18 @@ export function ImageCanvasTaskSidebarView({ if (disposed) { return; } + const visibleActiveTasks = filterVisibleExternalTasks( + activeResponse.tasks, + ); + const visibleCompletedTasks = filterVisibleExternalTasks( + completedResponse.tasks, + ); + notifyCompletedExternalTasks(visibleCompletedTasks, { + unacknowledgedOnly: true, + }); setExternalTasks( trimStoredExternalTasks( - mergeExternalTasks( - filterVisibleExternalTasks(activeResponse.tasks), - filterVisibleExternalTasks(completedResponse.tasks), - ), + mergeExternalTasks(visibleActiveTasks, visibleCompletedTasks), completedListLimit, ), ); @@ -329,7 +377,12 @@ export function ImageCanvasTaskSidebarView({ disposed = true; controller.abort(); }; - }, [completedListLimit, filterVisibleExternalTasks, refreshKey]); + }, [ + completedListLimit, + filterVisibleExternalTasks, + notifyCompletedExternalTasks, + refreshKey, + ]); const activeExternalTaskIds = useMemo( () => @@ -340,6 +393,10 @@ export function ImageCanvasTaskSidebarView({ ); const activeExternalTaskKey = activeExternalTaskIds.join('|'); + useEffect(() => { + activeExternalTaskIdsRef.current = new Set(activeExternalTaskIds); + }, [activeExternalTaskKey, activeExternalTaskIds]); + useEffect(() => { if (!activeExternalTaskIds.length) { return undefined; @@ -371,6 +428,9 @@ export function ImageCanvasTaskSidebarView({ filterVisibleExternalTasks(activeResponse.tasks), filterVisibleExternalTasks(completedResponse.tasks), ); + notifyCompletedExternalTasks(refreshedTasks, { + activeTaskIds: activeExternalTaskIdsRef.current, + }); setExternalTasks((currentTasks) => trimStoredExternalTasks( mergeExternalTaskRefresh(currentTasks, refreshedTasks), @@ -407,6 +467,7 @@ export function ImageCanvasTaskSidebarView({ activeExternalTaskKey, completedListLimit, filterVisibleExternalTasks, + notifyCompletedExternalTasks, ]); const handleTaskListScroll = useCallback(