From 3485bad2d39bb063c66a0dafa04d04e7ed5d1e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 4 Aug 2026 20:04:07 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=B7=A5=E4=BD=9C=E6=B5=81=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=20"icon-spec"=20=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将所有相关工作流从 "ui-spec" 迁移至 "icon-spec"。 - 优化 `playSetting` 和 `artStyle` 的输入处理逻辑。 - 更新编辑器提示词、提交模型及测试用例以适配 "icon-spec"。 - 在 UI 及后端调用中集成 `EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH` 及相关校验。 - 新增 "icon-spec" 生成与微调的专用 API 路径。 - 移除旧版 "ui-spec" 相关定义及提示词构建器。 --- ...CanvasEditorGenerationIntegration.test.tsx | 77 +++-- .../image-editor/ImageCanvasEditorTypes.ts | 3 +- .../image-editor/ImageCanvasEditorView.tsx | 2 +- .../ImageCanvasGenerationComposerView.tsx | 2 +- .../ImageCanvasGenerationDialogModel.test.ts | 15 +- .../ImageCanvasGenerationDialogModel.ts | 4 +- .../ImageCanvasGenerationModel.test.ts | 42 ++- .../ImageCanvasGenerationModel.ts | 37 +-- ...ageCanvasGenerationSubmissionModel.test.ts | 51 +++- .../ImageCanvasGenerationSubmissionModel.ts | 46 ++- ...CanvasIconSpritesheetComposerView.test.tsx | 2 +- ...mageCanvasSpecGenerationPanelView.test.tsx | 271 +++++++++++++++++- .../ImageCanvasSpecGenerationPanelView.tsx | 216 +++++++++++++- ...anvasGenerationSubmissionWorkflow.test.tsx | 28 +- ...ImageCanvasGenerationSubmissionWorkflow.ts | 85 ++++++ .../useImageCanvasGenerationWorkflow.test.tsx | 32 ++- .../useImageCanvasUploadWorkflow.test.tsx | 2 +- .../image-editor/editorProjectClient.test.ts | 128 ++++++++- .../image-editor/editorProjectClient.ts | 124 ++++++-- 19 files changed, 980 insertions(+), 187 deletions(-) diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index ffe305bff..48637d846 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -22,6 +22,7 @@ import { } from './ImageCanvasEditorView.test-utils'; const generateEditorImageMock = vi.hoisted(() => vi.fn()); +const generateEditorIconSpecMock = vi.hoisted(() => vi.fn()); const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn()); const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn()); @@ -55,6 +56,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { deleteEditorAsset: deleteEditorAssetMock, deleteEditorAssetFolder: deleteEditorAssetFolderMock, generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, + generateEditorIconSpec: generateEditorIconSpecMock, generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, loadEditorAssetLibrary: loadEditorAssetLibraryMock, @@ -104,6 +106,7 @@ describe('ImageCanvasEditorView generation integration', () => { beforeEach(() => { window.localStorage.clear(); + generateEditorIconSpecMock.mockReset(); renderUiDesignAssetExtractionMarkedImageMock.mockReset(); renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue( 'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==', @@ -2146,9 +2149,16 @@ describe('ImageCanvasEditorView generation integration', () => { expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); }); - it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => { - generateEditorImageMock.mockImplementationOnce(async (input) => - createGeneratedImageResponse(input, { + it('submits icon spec business fields through the dedicated endpoint', async () => { + generateEditorIconSpecMock.mockImplementationOnce(async (input) => + createGeneratedImageResponse({ + ...input, + assetKind: 'icon-spec', + kind: 'spec', + model: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '2K', + }, { imageSrc: 'data:image/png;base64,c3BlYy11aQ==', width: 2048, height: 1152, @@ -2185,23 +2195,26 @@ describe('ImageCanvasEditorView generation integration', () => { fireEvent.submit(screen.getByRole('dialog', { name: '生成规范' })); await waitFor(() => { - expect(generateEditorImageMock).toHaveBeenCalledWith( + expect(generateEditorIconSpecMock).toHaveBeenCalledWith( expect.objectContaining({ - kind: 'spec', - model: 'gpt-image-2', - size: '2048x1152', - aspectRatio: '16:9', - imageSize: '2K', - prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'), + playSetting: '消除类派对玩法', + artStyle: '糖果玻璃拟物', projectId: 'editor-project-default', assetFolderId: 'project', - assetKind: 'icon-spec', + generationInputs: { + fields: [ + { title: '玩法设定', value: '消除类派对玩法' }, + { title: '美术风格', value: '糖果玻璃拟物' }, + ], + references: [], + }, }), ); }); - const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? ''; - expect(prompt).toContain('玩法设定:消除类派对玩法'); - expect(prompt).toContain('美术风格:糖果玻璃拟物'); + const payload = generateEditorIconSpecMock.mock.calls[0]?.[0] ?? {}; + expect(payload).not.toHaveProperty('prompt'); + expect(payload).not.toHaveProperty('kind'); + expect(payload).not.toHaveProperty('assetKind'); await waitFor(() => { expect(screen.getByAltText(/画布图片:图标规范/)).toBeTruthy(); @@ -3043,8 +3056,15 @@ describe('ImageCanvasEditorView generation integration', () => { }); it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => { - generateEditorImageMock.mockImplementationOnce(async (input) => - createGeneratedImageResponse(input, { + generateEditorIconSpecMock.mockImplementationOnce(async (input) => + createGeneratedImageResponse({ + ...input, + assetKind: 'icon-spec', + kind: 'spec', + model: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '2K', + }, { imageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=', width: 2048, height: 1152, @@ -3080,26 +3100,27 @@ describe('ImageCanvasEditorView generation integration', () => { fireEvent.change(within(specDialog).getByLabelText('玩法设定'), { target: { value: '背包整理玩法' }, }); + fireEvent.change(within(specDialog).getByLabelText('美术风格'), { + target: { value: '清爽卡通' }, + }); fireEvent.click( within(specDialog).getByRole('button', { name: '提交生成规范' }), ); const generatedSpec = await screen.findByAltText(/画布图片:图标规范/u); - expect(generateEditorImageMock).toHaveBeenCalledWith( + expect(generateEditorIconSpecMock).toHaveBeenCalledWith( expect.objectContaining({ - kind: 'spec', - prompt: expect.stringContaining('背包整理玩法'), + playSetting: '背包整理玩法', + artStyle: '清爽卡通', projectId: 'editor-project-default', assetFolderId: 'project', - assetKind: 'icon-spec', - generationInputs: expect.objectContaining({ - fields: expect.arrayContaining([ - expect.objectContaining({ - title: '玩法设定', - value: '背包整理玩法', - }), - ]), - }), + generationInputs: { + fields: [ + { title: '玩法设定', value: '背包整理玩法' }, + { title: '美术风格', value: '清爽卡通' }, + ], + references: [], + }, }), ); diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 64b479ef9..e446e1ddf 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -147,7 +147,8 @@ export type EditorAssetFolder = { persisted: boolean; }; -export type SpecGenerationType = 'character' | 'ui' | 'icon' | 'custom'; +// 这里指规范的类型 +export type SpecGenerationType = 'character' | 'icon' | 'custom'; export type SpecFormValues = { playSetting: string; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index f608fe9c1..242368852 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -165,7 +165,7 @@ function openCanvasStartupTool( return; } if (tool === 'icon-spec') { - generationSurface.openSpecDialog('ui'); + generationSurface.openSpecDialog('icon'); return; } if (tool === 'ui-design') { diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 1756f8174..53ffe6b2b 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -968,7 +968,7 @@ export function ImageCanvasGenerationComposerView({ onFocus={onSpecMenuPointerEnter} onBlur={onSpecMenuPointerLeave} > - {(['character', 'ui', 'custom'] as const).map((specType) => ( + {(['character', 'icon', 'custom'] as const).map((specType) => ( { bodyRatio: '3', }, }); - expect( - createSpecDialogDraft({ canvasSize, viewport, specType: 'ui' }), - ).toMatchObject({ - mode: 'spec', - specType: 'ui', - specValues: { - playSetting: '', - artStyle: '', - bodyRatio: '3', - }, - }); expect( createSpecDialogDraft({ canvasSize, viewport, specType: 'icon' }), ).toMatchObject({ mode: 'spec', specType: 'icon', specValues: { - playSetting: '休闲小游戏', - artStyle: '清爽卡通', + playSetting: '', + artStyle: '', }, placeholder: { x: -824, diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 6c8019b3b..309ac9d5c 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -199,7 +199,7 @@ export function createGenerateDialogDraft({ } function shouldUseSpecPlaceholderValues(specType: SpecGenerationType) { - return specType === 'character' || specType === 'ui'; + return specType === 'character' || specType === 'icon'; } export function createSpecDialogDraft({ @@ -811,7 +811,7 @@ function resolveSpecTypeFromSourceLayer( if (fields.has('头身比') || fields.has('角色视角')) { return 'character'; } - return 'ui'; + return 'icon'; } function restoreSpecValuesFromLayer( diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index c2e21ecbf..32ba7003e 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -257,6 +257,21 @@ describe('ImageCanvasGenerationModel', () => { ], references: [], }); + expect( + buildSpecGenerationInputs('icon', { + playSetting: '休闲消除', + artStyle: '清爽卡通', + bodyRatio: '3', + characterView: '', + customPrompt: '', + }), + ).toEqual({ + fields: [ + { title: '玩法设定', value: '休闲消除' }, + { title: '美术风格', value: '清爽卡通' }, + ], + references: [], + }); }); it('builds character, icon and edit reference snapshots', () => { @@ -395,17 +410,7 @@ describe('ImageCanvasGenerationModel', () => { }); }); - it('keeps generated prompts and quick edit options stable', () => { - const prompt = buildSpecPrompt('ui', { - playSetting: '消除玩法', - artStyle: '清爽卡通', - bodyRatio: '3', - characterView: '', - customPrompt: '', - }); - - expect(prompt).toContain('生成一张完整游戏UI规范汇总设定展板'); - expect(prompt).toContain('玩法设定:消除玩法'); + it('keeps custom prompts and quick edit options stable', () => { expect( buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }), ).toBe('自定义'); @@ -438,21 +443,6 @@ describe('ImageCanvasGenerationModel', () => { }); it('adds reference image semantics and snapshots for spec generation references', () => { - const prompt = buildSpecPrompt( - 'ui', - { - playSetting: '消除玩法', - artStyle: '清爽卡通', - bodyRatio: '3', - characterView: '', - customPrompt: '', - }, - true, - ); - - expect(prompt).toContain('参考图生成规范'); - expect(prompt).toContain('参考图1'); - expect(prompt).toContain('生成一张完整游戏UI规范汇总设定展板'); expect( buildSpecPrompt( 'custom', diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 396d10029..60d1a21aa 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -484,17 +484,9 @@ export const DEFAULT_SPEC_FORM_VALUES: Record< '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', customPrompt: '', }, - ui: { - playSetting: '抓娃娃题材的抓大鹅玩法', - artStyle: '毛茸茸', - bodyRatio: '3', - characterView: - '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', - customPrompt: '', - }, icon: { - playSetting: '休闲小游戏', - artStyle: '清爽卡通', + playSetting: '', + artStyle: '', bodyRatio: '3', characterView: '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', @@ -512,7 +504,6 @@ export const DEFAULT_SPEC_FORM_VALUES: Record< export const SPEC_TYPE_LABEL: Record = { character: '角色规范', - ui: '图标规范', icon: '图标规范', custom: '自定义规范', }; @@ -609,24 +600,6 @@ export function buildCharacterSpecPrompt(values: SpecFormValues) { ].join('\n'); } -export function buildUiSpecPrompt(values: SpecFormValues) { - return [ - '生成一张完整游戏UI规范汇总设定展板,纯白色干净背景,Figma专业设计稿质感,矢量锐利线条,页面划分九大区域:色彩规范、字体规范、图标规范、按钮规范、组件规范、布局规范、特效规范、IP规范、主视觉。主视觉居中较大显示,其他八个区域环绕主视觉', - '', - `玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.ui.playSetting}`, - `美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.ui.artStyle}`, - ].join('\n'); -} - -export function buildIconSpecPrompt(values: SpecFormValues) { - return [ - '生成一张游戏图标素材视觉规范展板,纯白色干净背景,展示按钮图标的统一视角、线条粗细、填充风格、描边、阴影、圆角、材质、状态层级和色彩规范,图标样例需要成组排列且风格高度统一。', - '', - `玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.icon.playSetting}`, - `美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.icon.artStyle}`, - ].join('\n'); -} - export function buildSpecPrompt( type: SpecGenerationType, values: SpecFormValues, @@ -635,11 +608,7 @@ export function buildSpecPrompt( const prompt = type === 'character' ? buildCharacterSpecPrompt(values) - : type === 'ui' - ? buildUiSpecPrompt(values) - : type === 'icon' - ? buildIconSpecPrompt(values) - : values.customPrompt.trim(); + : values.customPrompt.trim(); if (!hasReferenceImage) { return prompt; } diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 1784b7056..40f83a78f 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -212,7 +212,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { ); }); - it('builds spec generation plans with reference prompt semantics', () => { + it('builds icon spec generation plans without a client prompt or backend kind', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'spec', @@ -238,23 +238,33 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); expect(plan).toMatchObject({ - kind: 'image', + kind: 'icon-spec', normalizedPrompt: 'AI 生成图片', input: { - size: '2048x1152', - model: 'gpt-image-2', - aspectRatio: '16:9', - imageSize: '2K', - kind: 'spec', + playSetting: '休闲消除', + artStyle: '清爽卡通', referenceImageSrcs: ['data:image/png;base64,ref'], - prompt: expect.stringContaining('参考图生成规范'), + generationInputs: { + fields: [ + { title: '玩法设定', value: '休闲消除' }, + { title: '美术风格', value: '清爽卡通' }, + ], + references: [ + { + title: '参考图', + label: '参考.png', + refType: 'project-resource', + refId: 'resource-spec-ref', + }, + ], + }, }, result: { assetKind: 'icon-spec', title: '图标规范 8', }, }); - expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( + expect(plan.kind === 'icon-spec' ? plan.result.generationInputs : null).toEqual( { fields: [ { title: '玩法设定', value: '休闲消除' }, @@ -272,13 +282,13 @@ describe('ImageCanvasGenerationSubmissionModel', () => { ); }); - it('treats the renamed UI spec entry as an icon spec asset', () => { + it('does not add optional reference fields to an icon spec without a reference', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { mode: 'spec', prompt: '', status: 'idle', - specType: 'ui', + specType: 'icon', specValues: { playSetting: '消除类派对玩法', artStyle: '糖果玻璃拟物', @@ -292,17 +302,28 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); expect(plan).toMatchObject({ - kind: 'image', + kind: 'icon-spec', + input: { + playSetting: '消除类派对玩法', + artStyle: '糖果玻璃拟物', + generationInputs: { + fields: [ + { title: '玩法设定', value: '消除类派对玩法' }, + { title: '美术风格', value: '糖果玻璃拟物' }, + ], + references: [], + }, + }, result: { assetKind: 'icon-spec', title: '图标规范 2', }, }); - expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty( + expect(plan.kind === 'icon-spec' ? plan.input : null).not.toHaveProperty( 'referenceImageSrcs', ); - expect(plan.kind === 'image' ? plan.input.prompt : '').not.toContain( - '参考图生成规范', + expect(plan.kind === 'icon-spec' ? plan.input : null).not.toHaveProperty( + 'prompt', ); }); diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index f33d4f55f..b9548a630 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -1,6 +1,7 @@ import type { EditorBackgroundMusicGenerationInput, EditorCharacterAnimationGenerationInput, + EditorIconSpecGenerationInput, EditorIconSpritesheetGenerationInput, EditorImageEditInput, EditorImageGenerationInput, @@ -162,6 +163,16 @@ export type ImageGenerationSubmissionPlan = }; rememberImageModel?: string; } + | { + kind: 'icon-spec'; + normalizedPrompt: string; + input: EditorIconSpecGenerationInput; + result: { + assetKind: 'icon-spec'; + title: string; + generationInputs: CanvasGenerationInputs; + }; + } | { kind: 'quick-edit'; normalizedPrompt: string; @@ -293,6 +304,37 @@ export function buildImageGenerationSubmissionPlan({ if (dialog.mode === 'spec') { const specType = dialog.specType ?? 'custom'; const specValues = dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType]; + if (specType === 'icon') { + const generationInputs = buildSpecGenerationInputs( + specType, + specValues, + dialog.specReference, + ); + return { + kind: 'icon-spec', + normalizedPrompt, + input: { + playSetting: specValues.playSetting.trim(), + artStyle: specValues.artStyle.trim(), + ...(dialog.specReference?.src + ? { + referenceImageSrcs: [ + resolveImageReferenceSubmissionSource(dialog.specReference), + ], + } + : {}), + generationInputs, + }, + result: { + assetKind: 'icon-spec', + title: resolveGenerationAssetLabel( + dialog.assetLabel, + `${SPEC_TYPE_LABEL[specType]} ${nextGeneratedIndex}`, + ), + generationInputs, + }, + }; + } return { kind: 'image', normalizedPrompt, @@ -316,9 +358,7 @@ export function buildImageGenerationSubmissionPlan({ : {}), }, result: { - // 中文注释:生成规范菜单里的“图标规范”沿用历史 ui specType,但产物语义应作为图标规范供图标素材引用。 - assetKind: - specType === 'ui' || specType === 'icon' ? 'icon-spec' : 'spec', + assetKind: 'spec', title: resolveGenerationAssetLabel( dialog.assetLabel, `${SPEC_TYPE_LABEL[specType]} ${nextGeneratedIndex}`, diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx index 864b7bf2e..3693086be 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx @@ -36,7 +36,7 @@ function IconComposerHarness({ }: { initialDialog: GenerateDialogState; initialMenuOpen?: boolean; - onOpenSpecDialog?: (specType: 'character' | 'ui' | 'icon' | 'custom') => void; + onOpenSpecDialog?: (specType: 'character' | 'icon' | 'custom') => void; onRequestUpload?: (target: UploadTarget) => void; onUpdateIconDescriptionText?: (value: string) => void; onRememberImageModel?: (model: string) => void; diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx index e10eb5428..c287ab190 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx @@ -1,8 +1,15 @@ /* @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { createRef, useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GenerateDialogState, @@ -11,6 +18,22 @@ import type { } from './ImageCanvasEditorTypes'; import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView'; +const iconSpecClientMocks = vi.hoisted(() => ({ + refineGamePlay: vi.fn(), + refineArtStyle: vi.fn(), +})); + +vi.mock('../../services/image-editor/editorProjectClient', () => ({ + EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH: 200, + refineEditorIconSpecPlaySetting: iconSpecClientMocks.refineGamePlay, + refineEditorIconSpecArtStyle: iconSpecClientMocks.refineArtStyle, +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + function createSpecDialog( patch: Partial = {}, ): GenerateDialogState { @@ -79,6 +102,57 @@ function UiDesignHarness({ ) : null; } +function IconSpecHarness({ + playSetting = '回合制占点', + artStyle = '低多边形', + onSubmit = vi.fn(), +}: { + playSetting?: string; + artStyle?: string; + onSubmit?: (dialog: GenerateDialogState) => void; +}) { + const [dialog, setDialog] = useState( + createSpecDialog({ + specType: 'icon', + specValues: { + playSetting, + artStyle, + bodyRatio: '3', + characterView: '右向斜侧身站姿', + customPrompt: '', + }, + }), + ); + + return ( + + setDialog((current) => ({ + ...current, + specValues: { + ...current.specValues!, + [key]: value, + }, + })) + } + onRequestUpload={vi.fn()} + onSubmit={onSubmit} + /> + ); +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + describe('ImageCanvasSpecGenerationPanelView', () => { it('keeps the reference row above fields for spec panels and shows mud point text', () => { renderPanel({ dialog: createSpecDialog() }); @@ -138,7 +212,9 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ).toBe(true); expect(panel.textContent).not.toContain('UI设计要求'); expect(prompt.getAttribute('placeholder')).toBe('你希望这个 UI 长什么样?'); - expect(prompt.className).toContain('image-canvas-editor__generation-prompt'); + expect(prompt.className).toContain( + 'image-canvas-editor__generation-prompt', + ); expect(prompt.className).not.toContain( 'image-canvas-editor__generation-prompt--borderless', ); @@ -229,7 +305,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { it('uses gameplay and art style hints for icon specs', () => { renderPanel({ dialog: createSpecDialog({ - specType: 'ui', + specType: 'icon', specValues: { playSetting: '', artStyle: '', @@ -245,13 +321,198 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ); expect( (screen.getByLabelText('玩法设定') as HTMLInputElement).placeholder, - ).toBe('这是什么类型的游戏?'); + ).toBe('游戏的核心玩法是什么?'); expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe( '', ); expect( (screen.getByLabelText('美术风格') as HTMLInputElement).placeholder, ).toBe('游戏的画风是怎样的?'); + expect( + ( + screen.getByRole('button', { + name: '一键优化玩法设定', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect( + ( + screen.getByRole('button', { + name: '提交生成规范', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + + it('enforces the icon spec prompt limit in the fields and submit state', () => { + const overLimit = '玩'.repeat(201); + const onSubmit = vi.fn(); + render(); + + const playSetting = screen.getByLabelText( + '玩法设定', + ) as HTMLTextAreaElement; + const artStyle = screen.getByLabelText('美术风格') as HTMLTextAreaElement; + expect(playSetting.maxLength).toBe(200); + expect(artStyle.maxLength).toBe(200); + + fireEvent.change(playSetting, { target: { value: overLimit } }); + expect(Array.from(playSetting.value)).toHaveLength(200); + fireEvent.click(screen.getByRole('button', { name: '提交生成规范' })); + expect(onSubmit).toHaveBeenCalledTimes(1); + + cleanup(); + renderPanel({ + dialog: createSpecDialog({ + specType: 'icon', + specValues: { + playSetting: overLimit, + artStyle: '低多边形', + bodyRatio: '3', + characterView: '右向斜侧身站姿', + customPrompt: '', + }, + }), + onSubmit, + }); + expect( + ( + screen.getByRole('button', { + name: '提交生成规范', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect( + ( + screen.getByRole('button', { + name: '一键优化玩法设定', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + + it('optimizes each icon spec field independently and disables generation until both finish', async () => { + const playSettingDeferred = createDeferred(); + const artStyleDeferred = createDeferred(); + iconSpecClientMocks.refineGamePlay.mockReturnValueOnce( + playSettingDeferred.promise, + ); + iconSpecClientMocks.refineArtStyle.mockReturnValueOnce( + artStyleDeferred.promise, + ); + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); + expect( + (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).disabled, + ).toBe(true); + expect( + (screen.getByLabelText('美术风格') as HTMLTextAreaElement).disabled, + ).toBe(false); + expect( + screen.getByRole('button', { name: '一键优化玩法设定' }).textContent, + ).toBe('正在优化'); + + fireEvent.click(screen.getByRole('button', { name: '一键优化美术风格' })); + expect( + ( + screen.getByRole('button', { + name: '提交生成规范', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + + await act(async () => playSettingDeferred.resolve('优化后的玩法')); + await waitFor(() => + expect( + (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, + ).toBe('优化后的玩法'), + ); + expect( + ( + screen.getByRole('button', { + name: '提交生成规范', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + + await act(async () => artStyleDeferred.resolve('优化后的画风')); + await waitFor(() => + expect( + (screen.getByLabelText('美术风格') as HTMLTextAreaElement).value, + ).toBe('优化后的画风'), + ); + expect( + ( + screen.getByRole('button', { + name: '提交生成规范', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + }); + + it('keeps one undo snapshot per icon spec field and replaces it after a later successful optimization', async () => { + iconSpecClientMocks.refineGamePlay + .mockResolvedValueOnce('第一次优化') + .mockResolvedValueOnce('第二次优化'); + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); + await waitFor(() => + expect( + (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, + ).toBe('第一次优化'), + ); + fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' })); + expect( + (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, + ).toBe('回合制占点'); + expect( + screen.queryByRole('button', { name: '撤销玩法设定优化' }), + ).toBeNull(); + + fireEvent.change(screen.getByLabelText('玩法设定'), { + target: { value: '手动修改玩法' }, + }); + fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); + await waitFor(() => + expect( + (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, + ).toBe('第二次优化'), + ); + fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' })); + expect( + (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, + ).toBe('手动修改玩法'); + }); + + it('leaves text and the existing undo snapshot unchanged when optimization fails', async () => { + iconSpecClientMocks.refineGamePlay + .mockResolvedValueOnce('成功优化') + .mockRejectedValueOnce(new Error('服务暂不可用')); + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); + await waitFor(() => + expect( + (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, + ).toBe('成功优化'), + ); + fireEvent.change(screen.getByLabelText('玩法设定'), { + target: { value: '失败前文本' }, + }); + fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); + + await waitFor(() => + expect(screen.getByRole('alert').textContent).toContain('服务暂不可用'), + ); + expect( + (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, + ).toBe('失败前文本'); + fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' })); + expect( + (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, + ).toBe('回合制占点'); }); it('renders custom prompt and hides reference upload for icon specs', () => { diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index 4bba9f068..54172748a 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -5,8 +5,15 @@ import { type ReactNode, type RefObject, type SetStateAction, + useEffect, + useState, } from 'react'; +import { + EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH, + refineEditorIconSpecArtStyle, + refineEditorIconSpecPlaySetting, +} from '../../services/image-editor/editorProjectClient'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; import { @@ -61,7 +68,35 @@ type ImageCanvasSpecGenerationPanelViewProps = { }; function shouldShowSpecInputPlaceholders(dialog: GenerateDialogState) { - return dialog.specType === 'character' || dialog.specType === 'ui'; + return dialog.specType === 'character' || dialog.specType === 'icon'; +} + +type IconSpecOptimizationState = { + optimizing: boolean; + undoValue: string | null; +}; + +const INITIAL_ICON_SPEC_OPTIMIZATION_STATE: IconSpecOptimizationState = { + optimizing: false, + undoValue: null, +}; + +function getIconSpecPromptLength(value: string) { + return Array.from(value).length; +} + +function limitIconSpecPrompt(value: string) { + return Array.from(value) + .slice(0, EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH) + .join(''); +} + +function isValidIconSpecPrompt(value: string | undefined) { + const normalized = value?.trim() ?? ''; + return ( + normalized.length > 0 && + getIconSpecPromptLength(normalized) <= EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH + ); } export function ImageCanvasSpecGenerationPanelView({ @@ -100,6 +135,27 @@ export function ImageCanvasSpecGenerationPanelView({ ? dialog.uiDesignSpecReference : dialog.specReference; const isGenerating = dialog.status === 'generating'; + const isIconSpec = dialog.mode === 'spec' && dialog.specType === 'icon'; + const [playSettingOptimization, setPlaySettingOptimization] = + useState(INITIAL_ICON_SPEC_OPTIMIZATION_STATE); + const [artStyleOptimization, setArtStyleOptimization] = + useState(INITIAL_ICON_SPEC_OPTIMIZATION_STATE); + const [optimizationError, setOptimizationError] = useState( + null, + ); + useEffect(() => { + setPlaySettingOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE); + setArtStyleOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE); + setOptimizationError(null); + }, [dialog.id, dialog.mode, dialog.specType]); + const isOptimizingIconSpec = + playSettingOptimization.optimizing || artStyleOptimization.optimizing; + const hasRequiredIconSpecValues = + isValidIconSpecPrompt(dialog.specValues?.playSetting) && + isValidIconSpecPrompt(dialog.specValues?.artStyle); + const canSubmit = + !isGenerating && + (!isIconSpec || (hasRequiredIconSpecValues && !isOptimizingIconSpec)); const specGenerationCost = calculateEditorSpecGenerationPrice( SPEC_GENERATION_MODEL, ); @@ -117,6 +173,56 @@ export function ImageCanvasSpecGenerationPanelView({ onRequestUpload(uploadTarget); }; + const optimizeIconSpecField = async (field: 'playSetting' | 'artStyle') => { + const isPlaySetting = field === 'playSetting'; + const key: keyof SpecFormValues = isPlaySetting + ? 'playSetting' + : 'artStyle'; + const value = dialog.specValues?.[key]?.trim() ?? ''; + if (!value) { + return; + } + const setOptimization = isPlaySetting + ? setPlaySettingOptimization + : setArtStyleOptimization; + setOptimization((current) => ({ ...current, optimizing: true })); + setOptimizationError(null); + try { + const refined = isPlaySetting + ? await refineEditorIconSpecPlaySetting(value) + : await refineEditorIconSpecArtStyle(value); + onUpdateSpecFormValue(key, refined); + setOptimization({ optimizing: false, undoValue: value }); + } catch (error) { + setOptimization((current) => ({ ...current, optimizing: false })); + setOptimizationError( + error instanceof Error && error.message.trim() + ? error.message + : isPlaySetting + ? '优化玩法设定失败' + : '优化美术风格失败', + ); + } + }; + + const undoIconSpecOptimization = (field: 'playSetting' | 'artStyle') => { + const isPlaySetting = field === 'playSetting'; + const key: keyof SpecFormValues = isPlaySetting + ? 'playSetting' + : 'artStyle'; + const state = isPlaySetting + ? playSettingOptimization + : artStyleOptimization; + if (state.undoValue === null) { + return; + } + onUpdateSpecFormValue(key, state.undoValue); + (isPlaySetting ? setPlaySettingOptimization : setArtStyleOptimization)( + INITIAL_ICON_SPEC_OPTIMIZATION_STATE, + ); + setOptimizationError(null); + }; + return ( <>
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); - if (dialog.status !== 'generating') { + if (canSubmit) { onSubmit(dialog); } }} @@ -305,6 +411,99 @@ export function ImageCanvasSpecGenerationPanelView({ } /> + ) : isIconSpec ? ( + <> + {( + [ + { + field: 'playSetting' as const, + key: 'playSetting' as const, + title: '玩法设定', + placeholder: '游戏的核心玩法是什么?', + optimization: playSettingOptimization, + }, + { + field: 'artStyle' as const, + key: 'artStyle' as const, + title: '美术风格', + placeholder: '游戏的画风是怎样的?', + optimization: artStyleOptimization, + }, + ] as const + ).map((item) => { + const value = dialog.specValues?.[item.key] ?? ''; + return ( + + ); + })} + ) : ( <>