From 6caf3499927d13ddfbc4b2853043745e99c49bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Mon, 22 Jun 2026 21:51:59 +0800 Subject: [PATCH] =?UTF-8?q?=E9=94=81=E5=AE=9A=E7=94=BB=E5=B8=83UI=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E5=9B=BE=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固定生成UI设计图模型展示为gpt-image-2 对齐角色规范面板的只读模型展示样式 提交层兜底强制UI设计图使用gpt-image-2 补充模型锁定与旧草稿兜底测试 --- ...辑器】画板UI设计图生成入口设计-2026-06-17.md | 2 +- ...eCanvasGenerationImageOptionsView.test.tsx | 60 ++- .../ImageCanvasGenerationImageOptionsView.tsx | 51 +- ...ageCanvasGenerationSubmissionModel.test.ts | 112 ++-- .../ImageCanvasGenerationSubmissionModel.ts | 35 +- ...mageCanvasSpecGenerationPanelView.test.tsx | 30 +- .../ImageCanvasSpecGenerationPanelView.tsx | 510 +++++++++--------- 7 files changed, 467 insertions(+), 333 deletions(-) diff --git a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md index f95bbb146..e73ec443c 100644 --- a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md +++ b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md @@ -25,7 +25,7 @@ ## 生成参数 - 支持自定义画面比例和大小尺寸。 -- 模型固定为 `gpt-image-2`,面板不提供切换到其他模型的能力。 +- 模型固定为 `gpt-image-2`,模型展示对齐角色规范面板底部固定模型样式,不响应点击、不弹出模型切换菜单;历史草稿如果残留其他模型,提交时也必须强制改为 `gpt-image-2`。 - 默认画面比例为 `16:9`,默认大小为 `1K`。 ## 提示词契约 diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx index 2f562aabe..f8f6937ca 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx @@ -16,10 +16,12 @@ function ImageOptionsHarness({ initialDialog, includeDimensions = true, onRememberImageModel = vi.fn(), + lockedModel, }: { initialDialog: GenerateDialogState; includeDimensions?: boolean; onRememberImageModel?: (model: string) => void; + lockedModel?: string; }) { const [dialog, setDialog] = useState( initialDialog, @@ -32,8 +34,9 @@ function ImageOptionsHarness({ setGenerateDialog={setDialog} includeDimensions={includeDimensions} onRememberImageModel={onRememberImageModel} + lockedModel={lockedModel} cost={calculateEditorImageGenerationPrice({ - model: dialog.imageModel, + model: lockedModel ?? dialog.imageModel, imageSize: dialog.imageSize, })} submitLabel="生成" @@ -121,15 +124,11 @@ describe('ImageCanvasGenerationImageOptionsView', () => { fireEvent.click( screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }), ); - expect( - screen.getByRole('menu', { name: '生成图片尺寸选项' }), - ).toBeTruthy(); + expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy(); fireEvent.click(screen.getByLabelText('当前模型')); - expect( - screen.queryByRole('menu', { name: '生成图片尺寸选项' }), - ).toBeNull(); + expect(screen.queryByRole('menu', { name: '生成图片尺寸选项' })).toBeNull(); }); it('renders model choices as one model per row and uses a check mark for selection', () => { @@ -153,7 +152,9 @@ describe('ImageCanvasGenerationImageOptionsView', () => { ); const panel = screen.getByRole('menu', { name: '生成图片模型选项' }); - expect(within(panel).getByRole('button', { name: 'nanobanana2' })).toBeTruthy(); + expect( + within(panel).getByRole('button', { name: 'nanobanana2' }), + ).toBeTruthy(); fireEvent.click(within(panel).getByRole('button', { name: 'gpt-image-2' })); expect(screen.getByRole('menu', { name: '生成图片模型选项' })).toBeTruthy(); @@ -166,9 +167,40 @@ describe('ImageCanvasGenerationImageOptionsView', () => { expect( screen.getByRole('button', { name: '生成图片模型 gpt-image-2' }), ).toBeTruthy(); - expect(screen.getByRole('button', { name: '生成角色形象' }).textContent).toBe( - '生成20泥点', + expect( + screen.getByRole('button', { name: '生成角色形象' }).textContent, + ).toBe('生成3泥点'); + }); + + it('locks the model trigger when a fixed model is provided', () => { + const rememberImageModel = vi.fn(); + render( + , ); + + const modelButton = screen.getByRole('button', { + name: '生成图片模型 gpt-image-2', + }) as HTMLButtonElement; + + expect(modelButton.disabled).toBe(true); + expect(modelButton.closest('div')?.className).toContain( + 'image-canvas-editor__readonly-generation-option', + ); + expect(modelButton.textContent).not.toContain('nanobanana2'); + fireEvent.click(modelButton); + expect(screen.queryByRole('menu', { name: '生成图片模型选项' })).toBeNull(); + expect(rememberImageModel).not.toHaveBeenCalled(); }); it('renders option menus and shows mud point text in the submit button', () => { @@ -189,9 +221,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => { screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }), ); - expect( - screen.getByRole('menu', { name: '生成图片尺寸选项' }), - ).toBeTruthy(); + expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy(); const submit = screen.getByRole('button', { name: '生成角色形象' }); expect(submit.textContent).toBe('生成12泥点'); }); @@ -219,9 +249,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => { />, ); - expect( - screen.queryByRole('button', { name: /生成图片尺寸/u }), - ).toBeNull(); + expect(screen.queryByRole('button', { name: /生成图片尺寸/u })).toBeNull(); fireEvent.click( screen.getByRole('button', { name: '生成图片模型 nanobanana2' }), diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx index 3d7462c03..cfddaad89 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx @@ -33,6 +33,7 @@ type ImageCanvasGenerationImageOptionsViewProps = { cost?: number; submitLabel?: string; submitAriaLabel?: string; + lockedModel?: string; renderEditorPortal?: (node: ReactNode) => ReactNode; buildPortalMenuStyle?: ( anchor: HTMLElement | null, @@ -53,13 +54,16 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) { function getImageDimensionOptions(model: string | null | undefined) { return ( EDITOR_IMAGE_DIMENSION_OPTIONS[ - (model ?? IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS + (model ?? + IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2] ); } function normalizeImageDialogSelection(dialog: GenerateDialogState) { - const model = normalizeEditorImageModel(dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2); + const model = normalizeEditorImageModel( + dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2, + ); const options = getImageDimensionOptions(model); const aspectRatios = options.aspectRatios as readonly string[]; const imageSizes = options.imageSizes as readonly string[]; @@ -113,19 +117,28 @@ export function ImageCanvasGenerationImageOptionsView({ cost, submitLabel = '生成', submitAriaLabel = '生成', + lockedModel, renderEditorPortal = (node) => node, buildPortalMenuStyle = () => ({}), }: ImageCanvasGenerationImageOptionsViewProps) { const [openPanel, setOpenPanel] = useState(null); const dimensionsButtonRef = useRef(null); const modelButtonRef = useRef(null); - const selection = normalizeImageDialogSelection(dialog); + const normalizedLockedModel = lockedModel + ? normalizeEditorImageModel(lockedModel) + : null; + const selection = normalizeImageDialogSelection( + normalizedLockedModel + ? { ...dialog, imageModel: normalizedLockedModel } + : dialog, + ); const selectedModelLabel = getEditorImageModelDisplayName(selection.model); const selectedSizeLabel = resolveEditorImageSizeLabel({ aspectRatio: selection.aspectRatio, imageSize: selection.imageSize, }); const isGenerating = dialog.status === 'generating'; + const isModelLocked = Boolean(normalizedLockedModel); const labelPrefix = optionLabelPrefix ?? '生成图片'; const dismissOpenPanel = useCallback(() => setOpenPanel(null), []); @@ -257,24 +270,42 @@ export function ImageCanvasGenerationImageOptionsView({ : null} ) : null} -
+
} - onClick={() => togglePanel('model')} + aria-expanded={isModelLocked ? undefined : openPanel === 'model'} + disabled={isGenerating || isModelLocked} + trailingIcon={ + isModelLocked ? undefined : + } + onClick={() => { + if (!isModelLocked) { + togglePanel('model'); + } + }} > - - {openPanel === 'model' + {!isModelLocked && openPanel === 'model' ? renderEditorPortal( { title: '图标规范 8', }, }); - expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual({ - fields: [ - { title: '玩法设定', value: '休闲消除' }, - { title: '美术风格', value: '清爽卡通' }, - ], - references: [ - { - title: '参考图', - label: '参考.png', - refType: 'project-resource', - refId: 'resource-spec-ref', - }, - ], - }); + expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( + { + fields: [ + { title: '玩法设定', value: '休闲消除' }, + { title: '美术风格', value: '清爽卡通' }, + ], + references: [ + { + title: '参考图', + label: '参考.png', + refType: 'project-resource', + refId: 'resource-spec-ref', + }, + ], + }, + ); }); it('treats the renamed UI spec entry as an icon spec asset', () => { @@ -265,22 +268,62 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }, rememberImageModel: 'gpt-image-2', }); - expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual({ - fields: [{ title: '角色设定', value: '白发骑士' }], - references: [ - { - title: '角色规范', - label: '角色规范', - refType: 'project-resource', - refId: 'resource-character-spec', + expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( + { + fields: [{ title: '角色设定', value: '白发骑士' }], + references: [ + { + title: '角色规范', + label: '角色规范', + refType: 'project-resource', + refId: 'resource-character-spec', + }, + { + title: '常规参考图 1', + label: '盔甲参考', + refType: 'project-resource', + refId: 'resource-armor', + }, + ], + }, + ); + }); + + it('locks UI design generation submissions to gpt-image-2', () => { + const plan = buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'ui-design', + prompt: ' 设计移动端商城首页 ', + status: 'idle', + imageModel: 'nanobanana2', + aspectRatio: '16:9', + imageSize: '0.5K', + uiDesignSpecReference: { + id: 'ui-spec', + label: 'UI图标规范', + src: 'data:image/png;base64,ui-spec', + resourceId: 'resource-ui-spec', }, - { - title: '常规参考图 1', - label: '盔甲参考', - refType: 'project-resource', - refId: 'resource-armor', - }, - ], + }, + layers: [], + nextGeneratedIndex: 6, + }); + + expect(plan).toMatchObject({ + kind: 'image', + normalizedPrompt: '设计移动端商城首页', + input: { + kind: 'ui-design', + model: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '16:9', + imageSize: '1K', + referenceImageSrcs: ['data:image/png;base64,ui-spec'], + }, + result: { + assetKind: 'ui-design', + title: 'UI设计图 6', + }, + rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, }); }); @@ -607,7 +650,6 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); - it('builds seedance video plans with multimodal references', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { @@ -788,7 +830,11 @@ describe('ImageCanvasGenerationSubmissionModel', () => { normalizedPrompt: '角色绕场一周', input: { model: 'seedance2.0-fast', - priceMudPoints: calculateEditorVideoPrice('seedance2.0-fast', '480p', 4), + priceMudPoints: calculateEditorVideoPrice( + 'seedance2.0-fast', + '480p', + 4, + ), }, result: { title: '生成视频 3', @@ -846,7 +892,9 @@ describe('ImageCanvasGenerationSubmissionModel', () => { prompt: '金币掉落叮当声', model: 'audio1.0', duration: 7, - priceMudPoints: calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL), + priceMudPoints: calculateEditorSoundEffectPrice( + DEFAULT_SOUND_EFFECT_MODEL, + ), }, result: { title: '游戏音效 4', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index fa6289f8b..54c388074 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -43,6 +43,7 @@ import { DEFAULT_VIDEO_SOUND, DEFAULT_VIDEO_WEB_SEARCH_ENABLED, ICON_DESCRIPTION_LIMIT, + IMAGE_MODEL_GPT_IMAGE_2, normalizeEditorImageModel, resolveCharacterAnimationSourceImageSrc, SEEDANCE_VIDEO_REFERENCE_LIMITS, @@ -68,6 +69,10 @@ export function isSeedanceEditorVideoModel(model: string) { return isSeedanceVideoModel(model); } +function normalizeGptImageSize(imageSize: string | null | undefined) { + return imageSize?.trim().toUpperCase() === '2K' ? '2K' : '1K'; +} + function estimateReferenceRequestBytes(references: Array<{ src: string }>) { // 中文注释:JSON 包装字段还有少量额外字节;这里按 src 字符串长度估算,前后端都会再兜底校验 64MB。 return references.reduce((sum, reference) => sum + reference.src.length, 0); @@ -76,8 +81,10 @@ function estimateReferenceRequestBytes(references: Array<{ src: string }>) { function buildSeedanceVideoReferenceInput( references: NonNullable, ) { - const resolveReferenceSource = (reference: { src: string; objectKey?: string | null }) => - reference.objectKey?.trim() || reference.src; + const resolveReferenceSource = (reference: { + src: string; + objectKey?: string | null; + }) => reference.objectKey?.trim() || reference.src; const imageReferences = references.filter( (reference) => (reference.mediaType ?? 'image') === 'image', ); @@ -97,7 +104,11 @@ function buildSeedanceVideoReferenceInput( if (audioReferences.length > SEEDANCE_VIDEO_REFERENCE_LIMITS.audio) { throw new Error('参考音频最多 3 个'); } - if (audioReferences.length && !imageReferences.length && !videoReferences.length) { + if ( + audioReferences.length && + !imageReferences.length && + !videoReferences.length + ) { throw new Error('参考音频必须搭配参考图片或参考视频'); } if ( @@ -202,7 +213,9 @@ export function buildImageGenerationSubmissionPlan({ dialog.prompt.trim() || getDialogDefaultPrompt(dialog.mode); if (dialog.mode === 'edit') { - const sourceLayer = layers.find((layer) => layer.id === dialog.sourceLayerId); + const sourceLayer = layers.find( + (layer) => layer.id === dialog.sourceLayerId, + ); if (!sourceLayer) { throw new Error('未找到要修改的图片'); } @@ -284,7 +297,7 @@ export function buildImageGenerationSubmissionPlan({ } if (dialog.mode === 'ui-design') { - const imageModel = normalizeEditorImageModel(dialog.imageModel); + const imageModel = IMAGE_MODEL_GPT_IMAGE_2; const referenceImageSrcs = [dialog.uiDesignSpecReference?.src].filter( (src): src is string => Boolean(src), ); @@ -296,7 +309,7 @@ export function buildImageGenerationSubmissionPlan({ kind: 'ui-design', model: imageModel, aspectRatio: dialog.aspectRatio ?? '16:9', - imageSize: dialog.imageSize ?? '1K', + imageSize: normalizeGptImageSize(dialog.imageSize), ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), }, result: { @@ -448,7 +461,8 @@ export function buildImageGenerationSubmissionPlan({ }, result: { title: `游戏背景音乐 ${nextGeneratedIndex}`, - generationInputs: buildBackgroundMusicGenerationInputs(normalizedPrompt), + generationInputs: + buildBackgroundMusicGenerationInputs(normalizedPrompt), }, }; } @@ -484,9 +498,10 @@ export function buildImageGenerationSubmissionPlan({ export function buildIconSpritesheetGenerationSubmissionPlan( dialog: GenerateDialogState, ): IconSpritesheetGenerationSubmissionPlan { - const iconDescriptions = (dialog.prompt.trim() - ? dialog.prompt.split(/[\r\n,,、;;/|]+/u) - : (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS) + const iconDescriptions = ( + dialog.prompt.trim() + ? dialog.prompt.split(/[\r\n,,、;;/|]+/u) + : (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS) ) .map((description) => description.trim()) .filter(Boolean) diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx index fab693824..b4fb4bf76 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx @@ -88,7 +88,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ), ).toBe(true); const submitButton = screen.getByRole('button', { name: '提交生成规范' }); - expect(submitButton.textContent).toBe('生成40泥点'); + expect(submitButton.textContent).toBe('生成5泥点'); const dimensionsButton = screen.getByRole('button', { name: '生成图片尺寸 16:9·2K', }) as HTMLButtonElement; @@ -135,19 +135,23 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ), ).toBe(true); expect(panel.textContent).not.toContain('UI设计要求'); - expect(prompt.getAttribute('placeholder')).toBe( - '你希望这个 UI 长什么样?', - ); + expect(prompt.getAttribute('placeholder')).toBe('你希望这个 UI 长什么样?'); expect(prompt.className).toContain( 'image-canvas-editor__generation-prompt--borderless', ); const submitButton = screen.getByRole('button', { name: '生成UI设计图' }); - expect(submitButton.textContent).toBe('生成12泥点'); - fireEvent.click(screen.getByRole('button', { name: '生成图片模型 nanobanana2' })); - fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' })); - expect(screen.getByRole('button', { name: '生成UI设计图' }).textContent).toBe( - '生成20泥点', + const modelButton = screen.getByRole('button', { + name: '生成图片模型 gpt-image-2', + }) as HTMLButtonElement; + + expect(submitButton.textContent).toBe('生成3泥点'); + expect(modelButton.disabled).toBe(true); + expect(modelButton.closest('div')?.className).toContain( + 'image-canvas-editor__readonly-generation-option', ); + fireEvent.click(modelButton); + expect(screen.queryByRole('menu', { name: '生成图片模型选项' })).toBeNull(); + expect(screen.queryByText('nanobanana2')).toBeNull(); }); it('renders character spec fields and forwards updates', () => { @@ -232,7 +236,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { const submitButton = screen.getByRole('button', { name: '提交生成规范' }); - expect(submitButton.textContent).toBe('生成40泥点'); + expect(submitButton.textContent).toBe('生成5泥点'); }); it('disables controls while generating and renders failure state', () => { @@ -249,9 +253,9 @@ describe('ImageCanvasSpecGenerationPanelView', () => { fireEvent.click(screen.getByRole('button', { name: '提交生成规范' })); - expect((screen.getByLabelText('玩法设定') as HTMLInputElement).disabled).toBe( - true, - ); + expect( + (screen.getByLabelText('玩法设定') as HTMLInputElement).disabled, + ).toBe(true); expect(submitSpec).not.toHaveBeenCalled(); rerender( diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index be8ff1256..c7ec9bc99 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -31,6 +31,7 @@ import { calculateEditorUiDesignPrice, CHARACTER_SPEC_VIEW_OPTIONS, getEditorImageModelDisplayName, + IMAGE_MODEL_GPT_IMAGE_2, resolveEditorImageSizeLabel, SPEC_GENERATION_ASPECT_RATIO, SPEC_GENERATION_IMAGE_SIZE, @@ -126,31 +127,46 @@ export function ImageCanvasSpecGenerationPanelView({ } }} > -
- {(isUiDesignDialog || dialog.specType) ? ( -
{isGenerationReferenceMenuOpen && generationReferenceButtonRef ? renderEditorPortal(