diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 81893939c..5df358f76 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -441,6 +441,8 @@ function buildDashboardQuery(query: AdminDashboardQuery) { const params = new URLSearchParams(); appendQueryParam(params, 'granularity', query.granularity); appendQueryParam(params, 'anchor', query.anchor); + appendQueryParam(params, 'startDate', query.startDate); + appendQueryParam(params, 'endDate', query.endDate); const queryString = params.toString(); return queryString ? `?${queryString}` : ''; } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 7f550de16..7e4617954 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -54,11 +54,13 @@ export interface AdminOverviewResponse { database: AdminDatabaseOverviewPayload; } -export type AdminDashboardGranularity = 'day' | 'week' | 'month'; +export type AdminDashboardGranularity = 'day' | 'week' | 'month' | 'period'; export interface AdminDashboardQuery { granularity?: AdminDashboardGranularity; anchor?: string; + startDate?: string; + endDate?: string; } export interface AdminDashboardResponse { @@ -82,6 +84,7 @@ export interface AdminDashboardMetricsPayload { generatedAssets: number; consumedMudPoints: number; totalRegisteredUsers: number; + newRegisteredUsers: number; visitUsers: number; totalVisitUsers: number; visitCount: number; diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index 64f5412ac..46c47f20e 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -1,12 +1,12 @@ /* @vitest-environment jsdom */ -import {fireEvent, render, screen, waitFor} from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import {beforeEach, expect, test, vi} from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; -import {getAdminDashboard} from '../api/adminApiClient'; -import type {AdminDashboardResponse} from '../api/adminApiTypes'; -import {AdminDashboardPage} from './AdminDashboardPage'; +import { getAdminDashboard } from '../api/adminApiClient'; +import type { AdminDashboardResponse } from '../api/adminApiTypes'; +import { AdminDashboardPage } from './AdminDashboardPage'; vi.mock('../api/adminApiClient', () => ({ formatAdminApiError: vi.fn((error: unknown) => @@ -28,6 +28,7 @@ const dashboardResponse: AdminDashboardResponse = { generatedAssets: 12, consumedMudPoints: 88, totalRegisteredUsers: 1200, + newRegisteredUsers: 16, visitUsers: 34, totalVisitUsers: 456, visitCount: 98, @@ -40,7 +41,7 @@ const dashboardResponse: AdminDashboardResponse = { title: '生产素材', unit: '个', total: 12, - buckets: [{key: '2026-06-23', label: '2026-06-23', value: 12}], + buckets: [{ key: '2026-06-23', label: '2026-06-23', value: 12 }], }, ], operations: { @@ -53,9 +54,9 @@ const dashboardResponse: AdminDashboardResponse = { }, ], assetKindBreakdown: [ - {key: 'editor_generated_image', label: '画板图片', value: 9}, + { key: 'editor_generated_image', label: '画板图片', value: 9 }, ], - moduleVisitBreakdown: [{key: 'auth', label: '认证', value: 30}], + moduleVisitBreakdown: [{ key: 'auth', label: '认证', value: 30 }], }, warnings: [], generatedAt: '2026-06-23T12:00:00Z', @@ -70,8 +71,9 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = const user = userEvent.setup(); render(); - expect(await screen.findByText('今日总生产素材数')).toBeTruthy(); + expect(await screen.findByText('本日总生产素材数')).toBeTruthy(); expect(screen.getByText('总注册用户')).toBeTruthy(); + expect(screen.getByText('本日新增用户数')).toBeTruthy(); expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy(); expect(screen.getByText('生产素材')).toBeTruthy(); @@ -82,38 +84,64 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = expect(screen.getByText('访问模块分布')).toBeTruthy(); }); -test('Dashboard 选择周时带上 week granularity 和周一 anchor', async () => { +test('Dashboard 选择本周时按当前终止日期填充整周范围', async () => { const user = userEvent.setup(); render(); - await screen.findByText('今日总生产素材数'); - await user.click(screen.getByRole('button', { name: '周' })); - fireEvent.change(screen.getByLabelText('周'), { - target: {value: '2026-W27'}, + await screen.findByText('本日总生产素材数'); + fireEvent.change(screen.getByLabelText('终止日期'), { + target: { value: '2026-07-02' }, }); + await user.click(screen.getByRole('button', { name: '本周' })); await waitFor(() => { expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', { granularity: 'week', - anchor: '2026-06-29', + anchor: '2026-07-05', + startDate: '2026-06-29', + endDate: '2026-07-05', }); }); }); -test('Dashboard 选择月份时带上 month granularity 和月初 anchor', async () => { +test('Dashboard 选择本月时按当前终止日期填充整月范围', async () => { const user = userEvent.setup(); render(); - await screen.findByText('今日总生产素材数'); - await user.click(screen.getByRole('button', { name: '月' })); - fireEvent.change(screen.getByLabelText('月份'), { - target: {value: '2026-07'}, + await screen.findByText('本日总生产素材数'); + fireEvent.change(screen.getByLabelText('终止日期'), { + target: { value: '2026-07-12' }, }); + await user.click(screen.getByRole('button', { name: '本月' })); await waitFor(() => { expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', { granularity: 'month', - anchor: '2026-07-01', + anchor: '2026-07-31', + startDate: '2026-07-01', + endDate: '2026-07-31', }); }); }); + +test('Dashboard 手动选择起止日期时使用本时段查询', async () => { + render(); + + await screen.findByText('本日总生产素材数'); + fireEvent.change(screen.getByLabelText('起始日期'), { + target: { value: '2026-06-01' }, + }); + fireEvent.change(screen.getByLabelText('终止日期'), { + target: { value: '2026-06-12' }, + }); + + await waitFor(() => { + expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', { + granularity: 'period', + anchor: undefined, + startDate: '2026-06-01', + endDate: '2026-06-12', + }); + }); + expect(screen.getByText('本时段新增用户数')).toBeTruthy(); +}); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index ce76add07..e3277a236 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -1,14 +1,14 @@ -import {RefreshCw} from 'lucide-react'; -import {useCallback, useEffect, useMemo, useState} from 'react'; +import { RefreshCw } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; -import {getAdminDashboard} from '../api/adminApiClient'; +import { getAdminDashboard } from '../api/adminApiClient'; import type { AdminDashboardBreakdownRowPayload, AdminDashboardChartPayload, AdminDashboardGranularity, AdminDashboardResponse, } from '../api/adminApiTypes'; -import {handlePageError} from './pageUtils'; +import { handlePageError } from './pageUtils'; interface AdminDashboardPageProps { token: string; @@ -21,9 +21,10 @@ const granularityOptions: Array<{ id: AdminDashboardGranularity; label: string; }> = [ - {id: 'day', label: '日'}, - {id: 'week', label: '周'}, - {id: 'month', label: '月'}, + { id: 'day', label: '本日' }, + { id: 'week', label: '本周' }, + { id: 'month', label: '本月' }, + { id: 'period', label: '本时段' }, ]; export function AdminDashboardPage({ @@ -35,11 +36,13 @@ export function AdminDashboardPage({ ); const [granularity, setGranularity] = useState('day'); - const [anchor, setAnchor] = useState(() => formatDateInput(new Date())); + const [dateRange, setDateRange] = useState(() => { + const today = formatDateInput(new Date()); + return { startDate: today, endDate: today }; + }); const [activeTab, setActiveTab] = useState('metrics'); const [errorMessage, setErrorMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); - const anchorInput = buildAnchorInput(granularity, anchor); const loadDashboard = useCallback(async () => { setIsLoading(true); @@ -47,7 +50,9 @@ export function AdminDashboardPage({ try { const response = await getAdminDashboard(token, { granularity, - anchor, + anchor: granularity === 'period' ? undefined : dateRange.endDate, + startDate: dateRange.startDate, + endDate: dateRange.endDate, }); setDashboard(response); } catch (error: unknown) { @@ -55,7 +60,13 @@ export function AdminDashboardPage({ } finally { setIsLoading(false); } - }, [anchor, granularity, onUnauthorized, token]); + }, [ + dateRange.endDate, + dateRange.startDate, + granularity, + onUnauthorized, + token, + ]); useEffect(() => { void loadDashboard(); @@ -92,6 +103,12 @@ export function AdminDashboardPage({ value: metrics?.totalRegisteredUsers ?? 0, unit: '人', }, + { + id: 'new-registered-users', + label: `${rangePrefix(granularity)}新增用户数`, + value: metrics?.newRegisteredUsers ?? 0, + unit: '人', + }, { id: 'visit-users', label: `${rangePrefix(granularity)}访问人数`, @@ -134,6 +151,28 @@ export function AdminDashboardPage({

{dashboard?.range.periodLabel ?? '运营指标'}

+
+ + +
setGranularity(option.id)} + onClick={() => handleGranularityChange(option.id)} > {option.label} ))}
- {asset.sourceKind === 'uploaded' ? (
{ expect(props.onToggleContextLayerLock).toHaveBeenCalledTimes(1); expect(props.onFlipContextLayers).toHaveBeenCalledWith('x'); expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer); + expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1); + expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1); expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1); }); + it('closes the standalone image menu after opening character animation', () => { + const layer = createLayer({ assetKind: 'character' }); + const props = renderContextMenus({ + imageContextMenu: { layerId: layer.id, x: 20, y: 22 }, + imageContextMenuLayer: layer, + }); + + fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' })); + + expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer); + expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1); + }); + it('renders the image context menu when no canvas context menu is open', () => { const layer = createLayer({ assetKind: 'character' }); const props = renderContextMenus({ diff --git a/src/components/image-editor/ImageCanvasContextMenusView.tsx b/src/components/image-editor/ImageCanvasContextMenusView.tsx index e3b9019c0..46cd1ad49 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.tsx @@ -249,9 +249,11 @@ export function ImageCanvasContextMenusView({ @@ -300,7 +302,10 @@ export function ImageCanvasContextMenusView({ {imageContextMenuLayer.assetKind === 'character' ? ( onOpenCharacterAnimationPanel(imageContextMenuLayer)} + onClick={() => { + onOpenCharacterAnimationPanel(imageContextMenuLayer); + onCloseImageContextMenu(); + }} > 生成动画 diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index b8bcdcc38..833aea8e0 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -985,8 +985,8 @@ describe('ImageCanvasEditorView generation integration', () => { clientY: 120, }); fireEvent.click(screen.getByRole('button', { name: '改造' })); - expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy(); - expect(screen.queryByRole('button', { name: '关闭重绘图片' })).toBeNull(); + expect(screen.getByRole('dialog', { name: '改造图片' })).toBeTruthy(); + expect(screen.getByLabelText('快速编辑生成占位图')).toBeTruthy(); fireEvent.pointerDown(viewport, { button: 0, @@ -995,7 +995,7 @@ describe('ImageCanvasEditorView generation integration', () => { clientY: 190, }); - expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull(); + expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); }); it('does not render a standalone close button on the generation composer', () => { @@ -2413,6 +2413,148 @@ describe('ImageCanvasEditorView generation integration', () => { expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy(); }); + it('refreshes the asset library after an icon generation project snapshot is applied', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-icon-library-refresh', + title: '图标素材刷新画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-icon-spec-refresh', + resourceId: 'resource-icon-spec-refresh', + title: '清爽按钮图标规范', + src: 'data:image/png;base64,icon-spec-refresh', + x: 240, + y: 80, + width: 160, + height: 120, + originalWidth: 2048, + originalHeight: 1152, + zIndex: 11, + sourceType: 'generated', + assetKind: 'icon-spec', + }, + ], + resources: [], + updatedAt: '2026-06-15T00:00: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-icon-spritesheet-refresh', + folderId: 'project', + label: '清爽按钮素材图集', + imageSrc: 'data:image/png;base64,icon-sheet-refresh', + width: 512, + height: 512, + assetKind: 'icon-spritesheet', + }), + ], + }); + const spritesheetResource = createGeneratedResource({ + resourceId: 'resource-icon-spritesheet-refresh', + projectId: 'editor-project-icon-library-refresh', + imageSrc: 'data:image/png;base64,icon-sheet-refresh', + width: 512, + height: 512, + assetKind: 'icon-spritesheet', + }); + generateEditorIconSpritesheetMock.mockResolvedValueOnce({ + spritesheetImageSrc: 'data:image/png;base64,icon-sheet-refresh', + spritesheetWidth: 512, + spritesheetHeight: 512, + iconImageSrcs: [], + prompt: '图标 prompt', + actualPrompt: '图标 prompt', + model: 'gemini-3.1-flash-image-preview', + provider: 'VectorEngine', + taskId: 'icon-library-refresh-task', + priceMudPoints: 3, + spritesheetResource, + spritesheetAsset: createGeneratedAsset({ + assetId: 'asset-icon-spritesheet-refresh', + folderId: 'project', + label: '清爽按钮素材图集', + imageSrc: 'data:image/png;base64,icon-sheet-refresh', + width: 512, + height: 512, + assetKind: 'icon-spritesheet', + }), + project: { + projectId: 'editor-project-icon-library-refresh', + title: '图标素材刷新画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [spritesheetResource], + updatedAt: '2026-06-15T00:01:00.000Z', + }, + }); + render(); + + await screen.findByAltText('画布图片:清爽按钮图标规范'); + await waitFor(() => { + expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(1); + }); + expect( + screen.queryByRole('button', { name: '添加清爽按钮素材图集' }), + ).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); + const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); + fireEvent.click( + within(iconPanel).getByRole('button', { name: '图标规范' }), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); + fireEvent.pointerDown( + screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!, + { + button: 0, + pointerId: 182, + clientX: 260, + clientY: 100, + }, + ); + fireEvent.change( + within(iconPanel).getByRole('textbox', { name: '素材描述' }), + { + target: { value: '返回按钮' }, + }, + ); + fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' })); + + await waitFor(() => { + expect(generateEditorIconSpritesheetMock).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2); + }); + expect( + await screen.findByRole('button', { name: '添加清爽按钮素材图集' }), + ).toBeTruthy(); + }); + it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { @@ -3457,12 +3599,12 @@ describe('ImageCanvasEditorView generation integration', () => { expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '改造' })); - const editDialog = screen.getByRole('dialog', { name: '重绘图片' }); + const editDialog = screen.getByRole('dialog', { name: '改造图片' }); expect(editDialog).toBeTruthy(); - const editPrompt = within(editDialog).getByLabelText('重绘提示词'); + const editPrompt = within(editDialog).getByLabelText('改造提示词'); expect(editPrompt.className).toContain('platform-text-field'); expect(editPrompt.className).toContain( - 'image-canvas-editor__quick-edit-prompt', + 'image-canvas-editor__generation-prompt', ); fireEvent.change(editPrompt, { target: { value: '把画面改成黄昏光线' }, @@ -3484,32 +3626,28 @@ describe('ImageCanvasEditorView generation integration', () => { }); await waitFor(() => { - expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); }); - expect(screen.getByAltText(/画布图片:生成图片 .* 重绘/)).toBeTruthy(); + expect(screen.getByAltText(/画布图片:生成图片 .* 快速编辑/)).toBeTruthy(); fireEvent.click( screen.getAllByRole('button', { - name: /查看生成图片 .* 重绘图片信息/u, + name: /查看生成图片 .* 快速编辑图片信息/u, })[0]!, ); const editedMetadataDialog = screen.getByRole('dialog', { name: '图片信息', }); expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull(); - expect(within(editedMetadataDialog).getByText('重绘提示词')).toBeTruthy(); + expect(within(editedMetadataDialog).getByText('快速编辑提示词')).toBeTruthy(); expect( within(editedMetadataDialog).getByText('把画面改成黄昏光线'), ).toBeTruthy(); - expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy(); - expect( - within(editedMetadataDialog).getByText(/^生成图片 \d+$/u), - ).toBeTruthy(); expect( screen.getByRole('button', { name: /当前缩放比例 \d+%/u }), ).toBeTruthy(); }); - it('keeps the redraw panel open after generation starts while keeping the source preview visible', async () => { + it('shows the quick edit generator after redraw starts while keeping the source preview visible', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-edit-generating', title: '重绘生成中画布', @@ -3555,20 +3693,18 @@ describe('ImageCanvasEditorView generation integration', () => { clientY: 180, }); fireEvent.click(screen.getByRole('button', { name: '改造' })); - const editDialog = screen.getByRole('dialog', { name: '重绘图片' }); - fireEvent.change(within(editDialog).getByLabelText('重绘提示词'), { + const editDialog = screen.getByRole('dialog', { name: '改造图片' }); + fireEvent.change(within(editDialog).getByLabelText('改造提示词'), { target: { value: '改成雨夜灯光' }, }); fireEvent.click(within(editDialog).getByRole('button', { name: '生成' })); - expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); + expect(screen.getByLabelText('快速编辑生成占位图')).toBeTruthy(); expect(screen.getByAltText('画布图片:待重绘')).toBeTruthy(); expect(sourceLayer.className).not.toContain( 'image-canvas-editor__layer--generating', ); - expect( - within(editDialog).getByRole('button', { name: '生成中' }), - ).toBeTruthy(); }); it('hides the quick edit panel after generation starts while keeping the source preview visible', async () => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index e9be6c4f6..c2150ec9e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -13,6 +13,7 @@ import { createEditorAsset, createEditorProjectResource, type EditorAssetSnapshot, + type EditorProjectSnapshot, loadEditorGenerationPricing, } from '../../services/image-editor/editorProjectClient'; import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'; @@ -394,6 +395,7 @@ export function ImageCanvasEditorView() { handleAssetMarqueePointerDown, handleAssetMarqueePointerMove, handleAssetMarqueePointerUp, + refreshAssetLibrary, } = useImageCanvasAssetLibrary({ assetListRef, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, @@ -670,6 +672,13 @@ export function ImageCanvasEditorView() { canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, openEditorLoginModal, }); + const applyGeneratedProjectSnapshot = useCallback( + (project: EditorProjectSnapshot) => { + applyProjectSnapshot(project); + void refreshAssetLibrary(); + }, + [applyProjectSnapshot, refreshAssetLibrary], + ); const persistUpdatedLayerResource = useCallback( (layer: CanvasLayer) => { if (!projectId) { @@ -788,7 +797,7 @@ export function ImageCanvasEditorView() { projectId, assetFolderId: activeUploadFolderId, upsertGeneratedAsset, - applyProjectSnapshot, + applyProjectSnapshot: applyGeneratedProjectSnapshot, onWalletBalanceMayHaveChanged: refreshEditorWalletBalance, }); const { diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index 6479edb40..ba822dc14 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; +import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { buildLayerExportMetadata, + buildSpineImageSequenceJson, + buildSpineImageSequenceReadme, dataUrlToBlob, formatExportDate, getImageExtensionFromTypeOrSrc, @@ -11,7 +14,6 @@ import { readLayerImageSequenceFrameBlob, sanitizeExportFilePart, } from './ImageCanvasExportModel'; -import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { DEFAULT_ICON_DESCRIPTIONS, UI_DESIGN_ASSET_EXTRACTION_PROMPT, @@ -169,6 +171,56 @@ describe('ImageCanvasExportModel', () => { }); }); + it('builds a Spine JSON attachment timeline from image sequence frames', () => { + const layer = buildLayer({ + mediaType: 'image-sequence', + originalWidth: 192, + originalHeight: 256, + durationSeconds: 0.5, + }); + const spineJson = buildSpineImageSequenceJson({ + layer, + frames: [ + { frameIndex: 1, fileName: 'frame-01.png', width: 192, height: 256 }, + { frameIndex: 2, fileName: 'frame-02.png', width: 192, height: 256 }, + { frameIndex: 3, fileName: 'frame-03.png', width: 192, height: 256 }, + { frameIndex: 4, fileName: 'frame-04.png', width: 192, height: 256 }, + ], + }); + + expect(spineJson.skeleton).toMatchObject({ + images: './frames/', + fps: 8, + width: 192, + height: 256, + }); + expect(spineJson.bones).toEqual([{ name: 'root' }]); + expect(spineJson.slots[0]).toEqual({ + name: 'character', + bone: 'root', + attachment: 'animation_0001', + }); + expect( + spineJson.skins[0]!.attachments.character.animation_0001, + ).toMatchObject({ + type: 'region', + path: 'frame-01', + y: 128, + width: 192, + height: 256, + }); + expect( + spineJson.animations.animation.slots.character.attachment, + ).toEqual([ + { time: 0, name: 'animation_0001' }, + { time: 0.125, name: 'animation_0002' }, + { time: 0.25, name: 'animation_0003' }, + { time: 0.375, name: 'animation_0004' }, + { time: 0.5, name: 'animation_0001' }, + ]); + expect(buildSpineImageSequenceReadme(layer)).toContain('skeleton.json'); + }); + it('reads private object-key assets through same-origin bytes before exporting', async () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url: string) => { diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index 320878a48..d1619260d 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -2,8 +2,8 @@ import { readAssetBytes } from '../../services/assetReadUrlService'; import type { CanvasAssetExportMetadata, CanvasGenerationInputField, - CanvasMediaType, CanvasLayer, + CanvasMediaType, } from './ImageCanvasEditorTypes'; import { DEFAULT_ICON_DESCRIPTIONS, @@ -210,6 +210,119 @@ export function getImageSequenceFrameFileName( return `frame-${frameNumber}.${extension}`; } +export type ExportedImageSequenceFrame = { + frameIndex: number; + fileName: string; + width: number; + height: number; +}; + +const DEFAULT_SPINE_VERSION = '4.2.00'; +const SPINE_SEQUENCE_BONE_NAME = 'root'; +const SPINE_SEQUENCE_SLOT_NAME = 'character'; +const SPINE_SEQUENCE_ANIMATION_NAME = 'animation'; + +function stripFileExtension(fileName: string) { + return fileName.replace(/\.[^.]+$/u, ''); +} + +function roundSpineTime(value: number) { + return Number(value.toFixed(6)); +} + +export function buildSpineImageSequenceJson({ + layer, + frames, +}: { + layer: CanvasLayer; + frames: ExportedImageSequenceFrame[]; +}) { + if (!frames.length) { + throw new Error('序列帧为空'); + } + + const durationSeconds = + typeof layer.durationSeconds === 'number' && layer.durationSeconds > 0 + ? layer.durationSeconds + : frames.length; + const frameDuration = durationSeconds / frames.length; + const skeletonWidth = frames[0]?.width || layer.originalWidth; + const skeletonHeight = frames[0]?.height || layer.originalHeight; + const attachments = Object.fromEntries( + frames.map((frame, index) => { + const attachmentName = `${SPINE_SEQUENCE_ANIMATION_NAME}_${String( + index + 1, + ).padStart(4, '0')}`; + return [ + attachmentName, + { + type: 'region', + path: stripFileExtension(frame.fileName), + x: 0, + y: roundSpineTime(frame.height / 2), + width: frame.width, + height: frame.height, + }, + ]; + }), + ); + const timeline = frames.map((_frame, index) => ({ + time: roundSpineTime(index * frameDuration), + name: `${SPINE_SEQUENCE_ANIMATION_NAME}_${String(index + 1).padStart( + 4, + '0', + )}`, + })); + timeline.push({ + time: roundSpineTime(durationSeconds), + name: `${SPINE_SEQUENCE_ANIMATION_NAME}_0001`, + }); + + return { + skeleton: { + spine: DEFAULT_SPINE_VERSION, + images: './frames/', + fps: Math.max(1, Math.round(frames.length / durationSeconds)), + width: skeletonWidth, + height: skeletonHeight, + }, + bones: [{ name: SPINE_SEQUENCE_BONE_NAME }], + slots: [ + { + name: SPINE_SEQUENCE_SLOT_NAME, + bone: SPINE_SEQUENCE_BONE_NAME, + attachment: `${SPINE_SEQUENCE_ANIMATION_NAME}_0001`, + }, + ], + skins: [ + { + name: 'default', + attachments: { + [SPINE_SEQUENCE_SLOT_NAME]: attachments, + }, + }, + ], + animations: { + [SPINE_SEQUENCE_ANIMATION_NAME]: { + slots: { + [SPINE_SEQUENCE_SLOT_NAME]: { + attachment: timeline, + }, + }, + }, + }, + }; +} + +export function buildSpineImageSequenceReadme(layer: CanvasLayer) { + return [ + `素材:${layer.title}`, + '内容:Spine JSON 序列帧导入包', + '导入:在 Spine Editor 中导入 skeleton.json,并保持 frames/ 目录与 skeleton.json 同级。', + '说明:该 JSON 使用单 slot attachment timeline 播放序列帧,不包含拆件、骨骼绑定或 mesh 权重。', + ].join('\n'); +} + export async function blobToUint8Array(blob: Blob) { if (typeof blob.arrayBuffer === 'function') { return new Uint8Array(await blob.arrayBuffer()); diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index c0753550c..325509132 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -985,7 +985,8 @@ export function ImageCanvasGenerationComposerView({ ) : null} - {generateDialog?.mode === 'generate' && + {(generateDialog?.mode === 'generate' || + generateDialog?.mode === 'quick-edit') && generateDialog.composerOpen !== false && generationComposerStyle ? ( { }), ), ).toBe('请先登录后再生成图片'); + expect( + resolveImageGenerationErrorMessage( + new ApiClientError({ + message: '请求冲突(requestId: wallet-one)', + status: 409, + code: 'CONFLICT', + details: { provider: 'profile-wallet' }, + }), + ), + ).toBe('泥点余额不足,请充值后再生成'); + expect( + resolveImageGenerationErrorMessage( + new ApiClientError({ + message: '请求冲突(requestId: conflict-one)', + status: 409, + code: 'CONFLICT', + }), + ), + ).toBe('生成任务未完成,请稍后重试'); }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 791920d3a..501c3ec17 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -1304,7 +1304,10 @@ export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) { return 'Image Generator'; } -export function resolveImageGenerationErrorMessage(error: unknown) { +export function resolveImageGenerationErrorMessage( + error: unknown, + fallbackMessage = '生成图片失败', +) { if ( error instanceof ApiClientError && (error.status === 401 || error.status === 403) @@ -1312,7 +1315,22 @@ export function resolveImageGenerationErrorMessage(error: unknown) { return '请先登录后再生成图片'; } + if (error instanceof ApiClientError && error.status === 409) { + const message = error.message.trim(); + if (message && !message.startsWith('请求冲突')) { + return message; + } + const provider = + typeof error.details?.provider === 'string' + ? error.details.provider.trim() + : ''; + if (provider === 'profile-wallet') { + return '泥点余额不足,请充值后再生成'; + } + return '生成任务未完成,请稍后重试'; + } + return error instanceof Error && error.message.trim() ? error.message - : '生成图片失败'; + : fallbackMessage; } diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 3237de2a7..3ba2a483b 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -118,6 +118,61 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); + it('builds quick-edit canvas generator plans with the source layer as the final reference', () => { + const sourceLayer = createLayer({ + objectKey: 'generated-character-drafts/editor/source.png', + assetKind: 'character', + }); + + const plan = buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'quick-edit', + prompt: '把当前图改成雨天', + status: 'idle', + sourceLayerId: sourceLayer.id, + imageModel: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '4:3', + imageSize: '1K', + generationReferences: [ + { + id: 'style-reference', + label: '风格参考', + src: 'data:image/png;base64,style', + }, + ], + }, + layers: [sourceLayer], + nextGeneratedIndex: 4, + }); + + expect(plan).toMatchObject({ + kind: 'quick-edit', + normalizedPrompt: '把图2改成雨天', + sourceLayer, + input: { + prompt: '把图2改成雨天', + size: '1024x768', + kind: 'character', + model: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '4:3', + imageSize: '1K', + referenceImageSrcs: [ + 'data:image/png;base64,style', + 'generated-character-drafts/editor/source.png', + ], + }, + result: { + title: '源图 快速编辑', + assetKind: 'character', + generationInputs: { + fields: [{ title: '快速编辑提示词', value: '把图2改成雨天' }], + references: [], + }, + }, + rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, + }); + }); + it('builds spec generation plans with reference prompt semantics', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index c2f3d60ef..b9dcc7df0 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -21,6 +21,7 @@ import { buildPublicationMaterialsGenerationInputs, buildPublicationMaterialsGenerationPrompt, buildPublicationMaterialsPrompt, + buildQuickEditGenerationInputs, buildSoundEffectGenerationInputs, buildSpecGenerationInputs, buildSpecPrompt, @@ -38,8 +39,12 @@ import { DEFAULT_VIDEO_WEB_SEARCH_ENABLED, ICON_DESCRIPTION_LIMIT, IMAGE_MODEL_GPT_IMAGE_2, + inferEditorImageAspectRatio, + inferEditorImageSizeLabel, normalizeEditorImageModel, + normalizeQuickEditPromptSourceReference, resolveCharacterAnimationSourceImageSrc, + resolveEditorImageGenerationPixelSize, SEEDANCE_VIDEO_REFERENCE_LIMITS, SPEC_GENERATION_ASPECT_RATIO, SPEC_GENERATION_IMAGE_SIZE, @@ -157,6 +162,18 @@ export type ImageGenerationSubmissionPlan = }; rememberImageModel?: string; } + | { + kind: 'quick-edit'; + normalizedPrompt: string; + sourceLayer: CanvasLayer; + input: EditorImageGenerationInput; + result: { + title: string; + assetKind?: CanvasLayer['assetKind']; + generationInputs: CanvasGenerationInputs; + }; + rememberImageModel?: string; + } | { kind: 'video'; normalizedPrompt: string; @@ -232,6 +249,70 @@ export function buildImageGenerationSubmissionPlan({ }; } + if (dialog.mode === 'quick-edit' && dialog.sourceLayerId) { + const sourceLayer = layers.find( + (layer) => layer.id === dialog.sourceLayerId, + ); + if (!sourceLayer) { + throw new Error('未找到要改造的图片'); + } + const quickEditReferences = dialog.generationReferences ?? []; + const sourceReferenceIndex = quickEditReferences.length + 1; + const basePrompt = dialog.prompt.trim() || '快速编辑图片'; + const normalizedQuickEditPrompt = normalizeQuickEditPromptSourceReference( + basePrompt, + sourceReferenceIndex, + ); + const imageModel = normalizeEditorImageModel(dialog.imageModel); + const aspectRatio = + dialog.aspectRatio ?? + inferEditorImageAspectRatio( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ); + const imageSize = + dialog.imageSize ?? + inferEditorImageSizeLabel( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ); + const outputSize = resolveEditorImageGenerationPixelSize({ + model: imageModel, + aspectRatio, + imageSize, + }); + return { + kind: 'quick-edit', + normalizedPrompt: normalizedQuickEditPrompt, + sourceLayer, + input: { + prompt: normalizedQuickEditPrompt, + size: `${outputSize.width}x${outputSize.height}`, + kind: sourceLayer.assetKind === 'character' ? 'character' : 'quick-edit', + model: imageModel, + aspectRatio, + imageSize, + referenceImageSrcs: [ + ...quickEditReferences.map((reference) => + resolveImageReferenceSubmissionSource(reference), + ), + sourceLayer.objectKey?.trim() || sourceLayer.src, + ], + }, + result: { + title: `${sourceLayer.title} 快速编辑`, + assetKind: sourceLayer.assetKind, + generationInputs: buildQuickEditGenerationInputs( + '快速编辑提示词', + normalizedQuickEditPrompt, + sourceLayer, + quickEditReferences, + ), + }, + rememberImageModel: imageModel, + }; + } + if (dialog.mode === 'spec') { const specType = dialog.specType ?? 'custom'; const specValues = dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType]; diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 1f08c69cf..74c681bfd 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -1,6 +1,13 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { @@ -1091,6 +1098,81 @@ describe('ImageCanvasWorldView', () => { ).toBeTruthy(); }); + it('keeps the last loaded sequence frame visible while the next frame is resolving', () => { + vi.useFakeTimers(); + try { + useResolvedAssetReadUrlMock.mockImplementation( + (source: string) => ({ + resolvedUrl: + source === '/generated-character-drafts/editor/frame01.png' + ? 'https://oss.example.com/frame01.png?signature=1' + : '', + isResolving: + source === '/generated-character-drafts/editor/frame02.png', + shouldResolve: true, + }), + ); + const layer = createLayer({ + title: '角色动作', + src: '/generated-character-drafts/editor/frame01.png', + mediaType: 'image-sequence', + assetKind: 'character-animation', + thumbnailSrc: '/generated-character-drafts/editor/frame01.png', + imageSequenceFrames: [ + { + frameIndex: 1, + imageSrc: '/generated-character-drafts/editor/frame01.png', + objectKey: 'generated-character-drafts/editor/frame01.png', + width: 1024, + height: 1024, + }, + { + frameIndex: 2, + imageSrc: '/generated-character-drafts/editor/frame02.png', + objectKey: 'generated-character-drafts/editor/frame02.png', + width: 1024, + height: 1024, + }, + ], + durationSeconds: 0.1, + }); + + renderWorldView({ layers: [layer] }); + + const layerButton = screen.getByRole('button', { name: '选择角色动作' }); + const firstFrame = within(layerButton).getByAltText( + '画布序列帧:角色动作', + ); + + fireEvent.load(firstFrame); + expect( + layerButton.querySelector('.image-canvas-editor__layer-image-loading'), + ).toBeNull(); + + act(() => { + vi.advanceTimersByTime(60); + }); + + expect(within(layerButton).getByText('2/2')).toBeTruthy(); + expect( + within(layerButton) + .getByAltText('画布序列帧:角色动作') + .getAttribute('src'), + ).toBe('https://oss.example.com/frame01.png?signature=1'); + expect( + layerButton.querySelector('.image-canvas-editor__layer-image-loading'), + ).toBeNull(); + expect(useResolvedAssetReadUrlMock).toHaveBeenCalledWith( + '/generated-character-drafts/editor/frame02.png', + expect.objectContaining({ + objectKey: 'generated-character-drafts/editor/frame02.png', + }), + ); + } finally { + vi.useRealTimers(); + } + }); + it('keeps saved generator placeholders hidden after a result layer exists', () => { renderWorldView({ layers: [createLayer({ id: 'layer-generated', title: '生成图片' })], diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index a58457986..c21917eb4 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -15,7 +15,7 @@ import { Volume2, VolumeX, } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, @@ -700,6 +700,78 @@ function getLayerImageSequenceFrames(layer: CanvasLayer) { : []; } +function getImageSequenceFrameKey( + layer: CanvasLayer, + frame: ReturnType[number], + index: number, +) { + return [ + layer.id, + layer.taskId ?? layer.resourceId ?? '', + frame.frameIndex, + frame.objectKey ?? '', + frame.imageSrc, + index, + ].join(':'); +} + +function ImageCanvasImageSequenceFrame({ + layer, + frame, + frameKey, + isVisible, + isLoaded, + mediaTransform, + onFrameReady, +}: { + layer: CanvasLayer; + frame: ReturnType[number]; + frameKey: string; + isVisible: boolean; + isLoaded: boolean; + mediaTransform?: string; + onFrameReady: (frameKey: string) => void; +}) { + const { resolvedUrl } = useResolvedAssetReadUrl(frame.imageSrc, { + objectKey: frame.objectKey, + refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`, + }); + const frameImageRef = useRef(null); + + useEffect(() => { + if (resolvedUrl && isImageAlreadyDecoded(frameImageRef.current)) { + onFrameReady(frameKey); + } + }, [frameKey, onFrameReady, resolvedUrl]); + + if (!resolvedUrl) { + return null; + } + + return ( + {isVisible onFrameReady(frameKey)} + onError={() => onFrameReady(frameKey)} + /> + ); +} + function ImageCanvasImageSequenceLayer({ layer, mediaTransform, @@ -708,26 +780,57 @@ function ImageCanvasImageSequenceLayer({ mediaTransform?: string; }) { const frames = getLayerImageSequenceFrames(layer); + const frameItems = frames.map((frame, index) => ({ + frame, + index, + key: getImageSequenceFrameKey(layer, frame, index), + })); + const frameSequenceKey = frameItems.map((item) => item.key).join('|'); + const firstFrameKey = frameItems[0]?.key ?? ''; const [frameIndex, setFrameIndex] = useState(0); const [isPlaying, setIsPlaying] = useState(frames.length > 1); - const currentFrame = frames[Math.min(frameIndex, frames.length - 1)]; - const hasFrameSource = Boolean(currentFrame?.imageSrc?.trim()); - const { resolvedUrl } = useResolvedAssetReadUrl(currentFrame?.imageSrc, { - refreshKey: `${layer.taskId ?? layer.resourceId}:${ - currentFrame?.frameIndex ?? frameIndex + 1 - }`, - }); - const frameImageRef = useRef(null); - const [isFrameLoaded, setIsFrameLoaded] = useState(false); + const currentFrameItem = + frameItems[Math.min(frameIndex, frameItems.length - 1)]; + const currentFrameKey = currentFrameItem?.key ?? ''; + const [loadedFrameKeys, setLoadedFrameKeys] = useState>( + () => new Set(), + ); + const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey); + const visibleFrameLoaded = Boolean( + visibleFrameKey && loadedFrameKeys.has(visibleFrameKey), + ); + const hasFrameSource = frameItems.some((item) => item.frame.imageSrc.trim()); + + const handleFrameReady = useCallback((readyFrameKey: string) => { + setLoadedFrameKeys((currentKeys) => { + if (currentKeys.has(readyFrameKey)) { + return currentKeys; + } + const nextKeys = new Set(currentKeys); + nextKeys.add(readyFrameKey); + return nextKeys; + }); + }, []); useEffect(() => { setFrameIndex(0); setIsPlaying(frames.length > 1); - }, [layer.taskId, layer.resourceId, frames.length]); + setLoadedFrameKeys(new Set()); + setVisibleFrameKey(firstFrameKey); + }, [firstFrameKey, frameSequenceKey, frames.length]); useEffect(() => { - setIsFrameLoaded(isImageAlreadyDecoded(frameImageRef.current)); - }, [resolvedUrl]); + if (!currentFrameKey) { + setVisibleFrameKey(''); + return; + } + if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) { + return; + } + setVisibleFrameKey((currentKey) => + currentKey === currentFrameKey ? currentKey : currentFrameKey, + ); + }, [currentFrameKey, loadedFrameKeys, visibleFrameKey]); useEffect(() => { if (!isPlaying || frames.length <= 1) { @@ -749,28 +852,24 @@ function ImageCanvasImageSequenceLayer({ return ( <> - {hasFrameSource && (!resolvedUrl || !isFrameLoaded) ? ( + {hasFrameSource && !visibleFrameLoaded ? ( {activeDialog?.prompt || '-'} + + {activeDialog + ? `${activeDialog.mode}:${activeDialog.imageModel ?? '-'}:${activeDialog.aspectRatio ?? '-'}:${activeDialog.imageSize ?? '-'}` + : '-'} + {dialogs.canvasGenerationDialogs .map( @@ -400,6 +405,22 @@ function GenerationWorkflowHarness({ > 填写生成提示词 +