diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index 8abd6a34a..e3ce9e4da 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -21,9 +21,16 @@ import type { } from './ImageCanvasEditorTypes'; import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel'; +import { + EDITOR_SCENE_CONTENT_REQUIRED_ERROR, + EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR, +} from './ImageCanvasGenerationSubmissionModel'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss'; +const EDITOR_SCENE_CONTENT_ERROR_ID = 'editor-scene-content-error'; +const EDITOR_SCENE_CUSTOM_STYLE_ERROR_ID = 'editor-scene-custom-style-error'; + type ReferenceLabelFormatter = ( reference: CharacterReferenceImage, index: number, @@ -173,6 +180,22 @@ export function ImageCanvasBasicGenerationComposerView({ const finalFooterClassName = footerClassName ?? 'image-canvas-editor__generation-composer-footer'; + const sceneContentError = + dialog.mode === 'scene' && + dialog.status === 'failed' && + dialog.errorMessage === EDITOR_SCENE_CONTENT_REQUIRED_ERROR + ? dialog.errorMessage + : null; + const sceneCustomStyleError = + dialog.mode === 'scene' && + dialog.status === 'failed' && + dialog.errorMessage === EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR + ? dialog.errorMessage + : null; + const hasSceneFieldError = Boolean( + sceneContentError || sceneCustomStyleError, + ); + useImageCanvasFloatingOptionDismiss({ isOpen: isGenerationReferenceMenuOpen, boundaryRefs: [generationReferenceButtonRef], @@ -252,6 +275,10 @@ export function ImageCanvasBasicGenerationComposerView({ ) : null} + {sceneContentError ? ( + + ) : null} {dialog.mode === 'scene' && dialog.sceneStylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET ? ( ) : null} + {sceneCustomStyleError ? ( + + ) : null}
) : null} - {dialog.status === 'failed' ? ( + {dialog.status === 'failed' && !hasSceneFieldError ? ( () { return vi.fn() as unknown as Dispatch>; @@ -105,6 +107,41 @@ function createComposerProps( }; } +function SceneValidationHarness({ + initialDialog, +}: { + initialDialog: GenerateDialogState; +}) { + const [dialog, setDialog] = useState( + initialDialog, + ); + if (!dialog) { + return null; + } + + const props = createComposerProps(dialog, { + setGenerateDialog: setDialog, + onSubmitImageGeneration: (submittedDialog) => { + try { + buildImageGenerationSubmissionPlan({ + dialog: submittedDialog, + layers: [], + nextGeneratedIndex: 1, + }); + } catch (error) { + setDialog({ + ...submittedDialog, + status: 'failed', + composerOpen: true, + errorMessage: resolveImageGenerationErrorMessage(error), + }); + } + }, + }); + + return ; +} + function renderComposer( generateDialog: GenerateDialogState, overrides: Partial< @@ -216,6 +253,92 @@ describe('ImageCanvasGenerationComposerView', () => { expect(customStyle.className).not.toContain('platform-text-field'); }); + it('提交空场景内容时在对应字段附近呈现关联错误', () => { + render( + , + ); + + const panel = screen.getByRole('dialog', { name: '游戏场景生成器' }); + const sceneContent = within(panel).getByRole('textbox', { + name: '画面内容', + }); + + fireEvent.click(within(panel).getByRole('button', { name: '生成' })); + + const error = within(panel).getByRole('alert'); + expect(error.textContent).toBe('请填写画面内容'); + expect(error.className).toContain( + 'image-canvas-editor__scene-field-error', + ); + expect(sceneContent.getAttribute('aria-invalid')).toBe('true'); + expect(sceneContent.getAttribute('aria-describedby')).toBe(error.id); + expect( + panel.querySelector('.image-canvas-editor__generate-status'), + ).toBeNull(); + + fireEvent.change(sceneContent, { target: { value: '雨夜中的海边车站' } }); + + expect(within(panel).queryByRole('alert')).toBeNull(); + expect(sceneContent.getAttribute('aria-invalid')).toBeNull(); + expect(sceneContent.getAttribute('aria-describedby')).toBeNull(); + }); + + it('提交空自定义画风时在自定义字段附近呈现关联错误', () => { + render( + , + ); + + const panel = screen.getByRole('dialog', { name: '游戏场景生成器' }); + const customStyle = within(panel).getByRole('textbox', { + name: '自定义画风', + }); + + fireEvent.click(within(panel).getByRole('button', { name: '生成' })); + + const error = within(panel).getByRole('alert'); + expect(error.textContent).toBe('请填写自定义画风'); + expect(error.className).toContain( + 'image-canvas-editor__scene-field-error', + ); + expect(customStyle.getAttribute('aria-invalid')).toBe('true'); + expect(customStyle.getAttribute('aria-describedby')).toBe(error.id); + expect( + panel.querySelector('.image-canvas-editor__generate-status'), + ).toBeNull(); + + fireEvent.change(customStyle, { target: { value: '90 年代复古像素风' } }); + + expect(within(panel).queryByRole('alert')).toBeNull(); + expect(customStyle.getAttribute('aria-invalid')).toBeNull(); + expect(customStyle.getAttribute('aria-describedby')).toBeNull(); + }); + it('让生成UI设计图面板复用普通图片生成面板的纵向结构', () => { const setGenerateDialog = vi.fn(); renderComposer({ diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 08eb81925..7678c08fe 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -63,6 +63,9 @@ type ImageGenerationSubmissionOptions = { nextGeneratedIndex: number; }; +export const EDITOR_SCENE_CONTENT_REQUIRED_ERROR = '请填写画面内容'; +export const EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR = '请填写自定义画风'; + export const EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS = 80; export function resolveGenerationAssetLabel( @@ -323,13 +326,13 @@ export function buildImageGenerationSubmissionPlan({ if (dialog.mode === 'scene') { const sceneContent = dialog.prompt.trim(); if (!sceneContent) { - throw new Error('请填写画面内容'); + throw new Error(EDITOR_SCENE_CONTENT_REQUIRED_ERROR); } const stylePreset = dialog.sceneStylePreset ?? DEFAULT_EDITOR_SCENE_STYLE_PRESET; const customStyle = dialog.sceneCustomStyle?.trim() ?? ''; if (stylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET && !customStyle) { - throw new Error('请填写自定义画风'); + throw new Error(EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR); } const references = dialog.generationReferences ?? []; const imageModel = normalizeEditorImageModel(dialog.imageModel); diff --git a/src/index.css b/src/index.css index ecf04d14c..46d8e7c6e 100644 --- a/src/index.css +++ b/src/index.css @@ -6771,6 +6771,26 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { color: #94a3b8; } +.image-canvas-editor__generation-prompt[aria-invalid='true'] { + border-color: #e11d48; + background: #fff7f8; +} + +.image-canvas-editor__generation-prompt[aria-invalid='true']:focus { + border-color: #e11d48; + box-shadow: 0 0 0 3px rgba(225, 29, 72, 0.14); +} + +.image-canvas-editor__scene-field-error { + grid-column: 1 / -1; + margin: 0; + padding: 0.02rem 0.82rem 0.12rem; + color: #be123c; + font-size: 0.74rem; + font-weight: 760; + line-height: 1.35; +} + .image-canvas-editor__video-prompt { grid-column: 1 / -1; min-height: 4.1rem;