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 ? (
+
+ {sceneContentError}
+
+ ) : null}
{dialog.mode === 'scene' &&
dialog.sceneStylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET ? (
) : null}
+ {sceneCustomStyleError ? (
+
+ {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;