Add editor generation model pricing admin UI & API
Introduce centralized model pricing for editor generations: add EditorGenerationPricing types and admin API client methods (GET/POST), backend default config and runtime override support, and server route/handler changes to expose admin editor-generation-pricing. Add an AdminEditorGenerationPricingPage component, styles, route entry, icon, and tests, plus a test for admin routes. Update docs and decision log to describe the pricing contract and management workflow. This enables managing per-model pricing (perGeneration / perSecond, flat or tiered) from the admin panel and exposes runtime pricing to the main site.
This commit is contained in:
@@ -171,6 +171,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
model: dialog.imageModel,
|
||||
imageSize: dialog.imageSize,
|
||||
})}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
|
||||
@@ -294,6 +294,7 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
kind: 'character',
|
||||
model: dialog.imageModel,
|
||||
imageSize: dialog.imageSize,
|
||||
})}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
canvasDisplayScaleToViewportScale,
|
||||
canvasDisplayViewportToViewport,
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
createLayerFromAsset,
|
||||
formatCanvasDisplayScalePercent,
|
||||
hydrateLayer,
|
||||
normalizeAssetLibrary,
|
||||
normalizeCanvasBackgroundHex,
|
||||
@@ -10,6 +13,8 @@ import {
|
||||
serializeCanvasLayout,
|
||||
serializeLayer,
|
||||
splitCanvasLayoutItems,
|
||||
viewportScaleToCanvasDisplayScale,
|
||||
viewportToCanvasDisplayViewport,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
@@ -18,6 +23,23 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
describe('ImageCanvasEditorModel', () => {
|
||||
it('maps viewport scale to the user-facing canvas zoom scale', () => {
|
||||
expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25);
|
||||
expect(canvasDisplayScaleToViewportScale(1)).toBe(0.5);
|
||||
expect(canvasDisplayScaleToViewportScale(2)).toBe(1);
|
||||
expect(viewportScaleToCanvasDisplayScale(0.25)).toBe(0.5);
|
||||
expect(viewportScaleToCanvasDisplayScale(0.5)).toBe(1);
|
||||
expect(viewportScaleToCanvasDisplayScale(1)).toBe(2);
|
||||
expect(formatCanvasDisplayScalePercent(0.5)).toBe('100%');
|
||||
expect(formatCanvasDisplayScalePercent(Number.NaN)).toBe('100%');
|
||||
expect(
|
||||
viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }),
|
||||
).toEqual({ x: 10, y: 20, scale: 0.5 });
|
||||
expect(
|
||||
canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }),
|
||||
).toEqual({ x: 10, y: 20, scale: 1 });
|
||||
});
|
||||
|
||||
it('normalizes valid canvas background hex values and rejects invalid input', () => {
|
||||
expect(normalizeCanvasBackgroundHex(' #ABC ')).toBe('#aabbcc');
|
||||
expect(normalizeCanvasBackgroundHex('#f8fafc')).toBe('#f8fafc');
|
||||
|
||||
@@ -31,6 +31,7 @@ export const CANVAS_WORLD_SIZE = 12000;
|
||||
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
|
||||
export const MIN_SCALE = 0.24;
|
||||
export const MAX_SCALE = 3.2;
|
||||
export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
|
||||
export const TOOLBAR_HALF_WIDTH = 132;
|
||||
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
|
||||
export const SNAP_THRESHOLD_SCREEN_PX = 18;
|
||||
@@ -77,6 +78,39 @@ export function formatPercent(value: number) {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
export function formatCanvasDisplayScalePercent(scale: number) {
|
||||
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
|
||||
return formatPercent(safeScale / CANVAS_DISPLAY_SCALE_BASE);
|
||||
}
|
||||
|
||||
export function canvasDisplayScaleToViewportScale(displayScale: number) {
|
||||
const safeDisplayScale = Number.isFinite(displayScale) ? displayScale : 1;
|
||||
return safeDisplayScale * CANVAS_DISPLAY_SCALE_BASE;
|
||||
}
|
||||
|
||||
export function viewportScaleToCanvasDisplayScale(scale: number) {
|
||||
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
|
||||
return safeScale / CANVAS_DISPLAY_SCALE_BASE;
|
||||
}
|
||||
|
||||
export function viewportToCanvasDisplayViewport(
|
||||
viewport: CanvasViewport,
|
||||
): CanvasViewport {
|
||||
return {
|
||||
...viewport,
|
||||
scale: viewportScaleToCanvasDisplayScale(viewport.scale),
|
||||
};
|
||||
}
|
||||
|
||||
export function canvasDisplayViewportToViewport(
|
||||
viewport: CanvasViewport,
|
||||
): CanvasViewport {
|
||||
return {
|
||||
...viewport,
|
||||
scale: canvasDisplayScaleToViewportScale(viewport.scale),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatImageSizeValue(width: number, height: number) {
|
||||
const safeWidth = Math.max(1, Math.round(width || 1024));
|
||||
const safeHeight = Math.max(1, Math.round(height || 1024));
|
||||
|
||||
@@ -1093,7 +1093,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' }));
|
||||
expect(
|
||||
screen.getByRole('button', { name: /当前缩放比例 \d+%/u }),
|
||||
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
|
||||
@@ -1216,6 +1216,17 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(ctrlWheelEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('renders 100 percent zoom with the previous 50 percent visual scale', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByTestId('image-canvas-world') as HTMLElement).style.transform,
|
||||
).toBe('translate(0px, 0px) scale(0.5)');
|
||||
});
|
||||
|
||||
it('selects multiple canvas layers with shift click', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -1497,8 +1508,8 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
||||
pointerId: 21,
|
||||
clientX: -60,
|
||||
clientY: 180,
|
||||
clientX: 30,
|
||||
clientY: 150,
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createEditorAsset,
|
||||
createEditorProjectResource,
|
||||
type EditorAssetSnapshot,
|
||||
loadEditorGenerationPricing,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
@@ -33,6 +34,7 @@ import type {
|
||||
QuickEditPanelState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { getEditorUploadAccept } from './ImageCanvasFileModel';
|
||||
import { applyEditorGenerationPricingConfig } from './ImageCanvasGenerationModel';
|
||||
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import { useCanvasHistory } from './useCanvasHistory';
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
|
||||
export function ImageCanvasEditorView() {
|
||||
const authUi = useAuthUi();
|
||||
const [, setGenerationPricingVersion] = useState(0);
|
||||
const editorRootRef = useRef<HTMLElement | null>(null);
|
||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const assetListRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -78,6 +81,24 @@ export function ImageCanvasEditorView() {
|
||||
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const selectedLayerIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
void loadEditorGenerationPricing()
|
||||
.then((pricing) => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
applyEditorGenerationPricingConfig(pricing);
|
||||
setGenerationPricingVersion((version) => version + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
// 中文注释:价格接口异常时保留前端内置兜底,不阻断画板使用。
|
||||
});
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
const selectedLayerIdsRef = useRef<string[]>([]);
|
||||
const setQuickEditPanelRef = useRef<
|
||||
Dispatch<SetStateAction<QuickEditPanelState | null>>
|
||||
|
||||
@@ -510,6 +510,23 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
const paramsPanel = screen.getByRole('menu', {
|
||||
name: '视频参数选项',
|
||||
});
|
||||
const ratioChoice = within(paramsPanel).getByRole('button', {
|
||||
name: '比例 16:9',
|
||||
});
|
||||
expect(ratioChoice.className).toContain(
|
||||
'image-canvas-editor__option-popover-choice--ratio',
|
||||
);
|
||||
expect(
|
||||
ratioChoice
|
||||
.querySelector('.image-canvas-editor__ratio-wireframe')
|
||||
?.getAttribute('data-ratio'),
|
||||
).toBe('16:9');
|
||||
expect(
|
||||
within(paramsPanel)
|
||||
.getByRole('button', { name: '比例 21:9' })
|
||||
.querySelector('.image-canvas-editor__ratio-wireframe')
|
||||
?.getAttribute('data-ratio'),
|
||||
).toBe('21:9');
|
||||
fireEvent.change(within(paramsPanel).getByRole('slider', { name: '视频时长' }), {
|
||||
target: { value: '5' },
|
||||
});
|
||||
|
||||
@@ -430,11 +430,12 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
比例
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
|
||||
{EDITOR_VIDEO_ASPECT_RATIO_OPTIONS.map((option) => (
|
||||
<VideoOptionChoice
|
||||
key={option}
|
||||
selected={aspectRatio === option}
|
||||
className="image-canvas-editor__option-popover-choice--ratio"
|
||||
disabled={isGenerating}
|
||||
ariaLabel={`比例 ${option}`}
|
||||
onClick={() =>
|
||||
@@ -443,7 +444,12 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
})
|
||||
}
|
||||
>
|
||||
{option}
|
||||
<span
|
||||
className="image-canvas-editor__ratio-wireframe"
|
||||
data-ratio={option}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{option}</span>
|
||||
</VideoOptionChoice>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,10 @@ function ImageOptionsHarness({
|
||||
setGenerateDialog={setDialog}
|
||||
includeDimensions={includeDimensions}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
cost={calculateEditorImageGenerationPrice({ model: dialog.imageModel })}
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
model: dialog.imageModel,
|
||||
imageSize: dialog.imageSize,
|
||||
})}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成角色形象"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type {
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
BACKGROUND_MUSIC_MODEL_SUNO,
|
||||
applyEditorGenerationPricingConfig,
|
||||
buildCharacterGenerationInputs,
|
||||
buildEditGenerationInputs,
|
||||
buildIconGenerationInputs,
|
||||
@@ -30,12 +31,11 @@ import {
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
|
||||
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG,
|
||||
EDITOR_VIDEO_MODEL_OPTIONS,
|
||||
getGenerationFrameAriaLabel,
|
||||
@@ -52,26 +52,30 @@ import {
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
describe('ImageCanvasGenerationModel', () => {
|
||||
afterEach(() => {
|
||||
applyEditorGenerationPricingConfig({
|
||||
models: EDITOR_MODEL_MUD_POINT_CONFIG,
|
||||
});
|
||||
});
|
||||
|
||||
it('为所有编辑器生成模型提供显式定价配置', () => {
|
||||
expect(
|
||||
EDITOR_IMAGE_MODEL_OPTIONS.map((option) => option.value).filter(
|
||||
(model) => !(model in EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG),
|
||||
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
EDITOR_VIDEO_MODEL_OPTIONS.map((option) => option.value).filter(
|
||||
(model) => !(model in EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG),
|
||||
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.map((option) => option.value).filter(
|
||||
(model) => !(model in EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG),
|
||||
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]).toBe(5);
|
||||
expect(
|
||||
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL],
|
||||
).toBeTruthy();
|
||||
expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K']).toBe(40);
|
||||
expect(EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]).toBeTruthy();
|
||||
expect(
|
||||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO],
|
||||
).toBe(5);
|
||||
@@ -84,23 +88,25 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
|
||||
it('所有生成按钮价格都从模型定价配置推导', () => {
|
||||
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL],
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
|
||||
);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(20);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(20);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(40);
|
||||
expect(
|
||||
calculateEditorImageGenerationPrice({
|
||||
kind: 'character',
|
||||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
imageSize: '1K',
|
||||
}),
|
||||
).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]);
|
||||
).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']);
|
||||
expect(calculateEditorIconSpritesheetPrice(DEFAULT_IMAGE_MODEL)).toBe(
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL],
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
|
||||
);
|
||||
expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
|
||||
expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
|
||||
);
|
||||
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
|
||||
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
|
||||
);
|
||||
expect(calculateEditorVideoPrice(DEFAULT_VIDEO_MODEL, '720p', 5)).toBe(
|
||||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL]['720p'] * 5,
|
||||
@@ -119,9 +125,7 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
);
|
||||
expect(calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6))
|
||||
.toBe(
|
||||
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[
|
||||
CHARACTER_ANIMATION_MODEL
|
||||
]['720p'] * 6,
|
||||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]['720p'] * 6,
|
||||
);
|
||||
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(
|
||||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
|
||||
@@ -131,6 +135,46 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('支持用后端下发的模型定价覆盖按钮泥点', () => {
|
||||
applyEditorGenerationPricingConfig({
|
||||
models: {
|
||||
...EDITOR_MODEL_MUD_POINT_CONFIG,
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: {
|
||||
unit: 'perGeneration',
|
||||
prices: {
|
||||
'1K': 29,
|
||||
'2K': 58,
|
||||
},
|
||||
},
|
||||
[VIDEO_MODEL_SEEDANCE_2]: {
|
||||
unit: 'perSecond',
|
||||
prices: {
|
||||
...EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2],
|
||||
'720p': 26,
|
||||
},
|
||||
},
|
||||
[DEFAULT_SOUND_EFFECT_MODEL]: {
|
||||
unit: 'perGeneration',
|
||||
price: 15,
|
||||
},
|
||||
[BACKGROUND_MUSIC_MODEL_SUNO]: {
|
||||
unit: 'perGeneration',
|
||||
price: 9,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(29);
|
||||
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(58);
|
||||
expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '720p', 5)).toBe(
|
||||
130,
|
||||
);
|
||||
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(15);
|
||||
expect(calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO)).toBe(
|
||||
9,
|
||||
);
|
||||
});
|
||||
|
||||
it('builds user-facing generation input snapshots instead of backend prompts', () => {
|
||||
expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({
|
||||
fields: [{ title: '生成提示词', value: '一张明亮主视觉' }],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type { EditorGenerationPricingConfig } from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
@@ -22,24 +23,18 @@ import {
|
||||
type PublicationMaterialsWorkflow,
|
||||
} from './ImageCanvasPublicationMaterialsModel';
|
||||
|
||||
// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名语义,当前作为前端展示兜底。
|
||||
export const EDITOR_GENERATION_MUD_POINT_CONFIG = {
|
||||
image: 12,
|
||||
spec: 5,
|
||||
character: 12,
|
||||
icon: 12,
|
||||
uiDesign: 12,
|
||||
videoPerSecond: {
|
||||
'480p': 10,
|
||||
'720p': 20,
|
||||
},
|
||||
characterAnimationPerSecond: {
|
||||
'480p': 10,
|
||||
'720p': 20,
|
||||
},
|
||||
soundEffect: 10,
|
||||
backgroundMusic: 5,
|
||||
} as const;
|
||||
// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名模型定价语义,前端仅作为展示兜底。
|
||||
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
|
||||
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
|
||||
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
|
||||
const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
'nanobanana2',
|
||||
'nano-banana',
|
||||
]);
|
||||
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
|
||||
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
|
||||
export const SPEC_GENERATION_SIZE = '2048x1152';
|
||||
export const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
|
||||
export const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
|
||||
@@ -73,27 +68,19 @@ export const PUBLICATION_FRAME_DISPLAY_SIZE: Record<
|
||||
'publication-detail-gallery': { width: 360, height: 640 },
|
||||
'publication-promo-poster': { width: 560, height: 315 },
|
||||
};
|
||||
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
|
||||
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
|
||||
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
|
||||
const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
'nanobanana2',
|
||||
'nano-banana',
|
||||
]);
|
||||
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = {
|
||||
[IMAGE_MODEL_NANOBANANA2]: EDITOR_GENERATION_MUD_POINT_CONFIG.image,
|
||||
// 中文注释:gpt-image-2 作为更高成本图片模型独立计费,避免切换模型后按钮价格不变。
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: 20,
|
||||
} as const;
|
||||
export const EDITOR_SPEC_MODEL_MUD_POINT_CONFIG = {
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: EDITOR_GENERATION_MUD_POINT_CONFIG.spec,
|
||||
[IMAGE_MODEL_NANOBANANA2]: {
|
||||
'0.5K': 8,
|
||||
'1K': 12,
|
||||
'2K': 24,
|
||||
},
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: {
|
||||
'1K': 20,
|
||||
'2K': 40,
|
||||
},
|
||||
} as const;
|
||||
export const SPEC_GENERATION_COST =
|
||||
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL];
|
||||
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
|
||||
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL][SPEC_GENERATION_IMAGE_SIZE];
|
||||
export const ICON_DESCRIPTION_LIMIT = 100;
|
||||
export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = {
|
||||
gameName: '',
|
||||
@@ -322,9 +309,9 @@ export const SOUND_EFFECT_DURATION_OPTIONS = Array.from(
|
||||
export const BACKGROUND_MUSIC_MODEL_SUNO = 'chirp-v5';
|
||||
export const DEFAULT_BACKGROUND_MUSIC_MODEL = BACKGROUND_MUSIC_MODEL_SUNO;
|
||||
const EDITOR_VIDEO_RESOLUTION_RATE_CONFIG = {
|
||||
'480p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['480p'],
|
||||
'720p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['720p'],
|
||||
'1080p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['720p'] * 2,
|
||||
'480p': 10,
|
||||
'720p': 20,
|
||||
'1080p': 40,
|
||||
} as const;
|
||||
const EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG = {
|
||||
'480p': 12,
|
||||
@@ -346,22 +333,63 @@ export const EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG = {
|
||||
[VIDEO_MODEL_SEEDANCE_2]: EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_KLING_3]: EDITOR_VIDEO_KLING_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_KLING_3_OMNI]: EDITOR_VIDEO_KLING_OMNI_RESOLUTION_RATE_CONFIG,
|
||||
// 中文注释:前端不展示 Veo 入口,但类型与后端兼容层仍可接收旧布局回放,定价表必须同步覆盖。
|
||||
// 中文注释:前端不展示 Veo 入口,但客户端类型和后端兼容层仍可接收旧布局回放,定价表必须同步覆盖。
|
||||
[VIDEO_MODEL_VEO_3_1]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
|
||||
[VIDEO_MODEL_VEO_3_1_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
|
||||
} as const;
|
||||
export const EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG = {
|
||||
[CHARACTER_ANIMATION_MODEL]: {
|
||||
'480p': EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond['480p'],
|
||||
'720p': EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond['720p'],
|
||||
},
|
||||
} as const;
|
||||
export const EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG = {
|
||||
[SOUND_EFFECT_MODEL_VIDU]: EDITOR_GENERATION_MUD_POINT_CONFIG.soundEffect,
|
||||
[SOUND_EFFECT_MODEL_VIDU]: 10,
|
||||
} as const;
|
||||
export const EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG = {
|
||||
[BACKGROUND_MUSIC_MODEL_SUNO]: EDITOR_GENERATION_MUD_POINT_CONFIG.backgroundMusic,
|
||||
[BACKGROUND_MUSIC_MODEL_SUNO]: 5,
|
||||
} as const;
|
||||
export const EDITOR_MODEL_MUD_POINT_CONFIG = {
|
||||
[IMAGE_MODEL_NANOBANANA2]: {
|
||||
unit: 'perGeneration',
|
||||
prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_NANOBANANA2],
|
||||
},
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: {
|
||||
unit: 'perGeneration',
|
||||
prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
|
||||
},
|
||||
[VIDEO_MODEL_SEEDANCE_2_FAST]: {
|
||||
unit: 'perSecond',
|
||||
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2_FAST],
|
||||
},
|
||||
[VIDEO_MODEL_SEEDANCE_2]: {
|
||||
unit: 'perSecond',
|
||||
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2],
|
||||
},
|
||||
[VIDEO_MODEL_KLING_3]: {
|
||||
unit: 'perSecond',
|
||||
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3],
|
||||
},
|
||||
[VIDEO_MODEL_KLING_3_OMNI]: {
|
||||
unit: 'perSecond',
|
||||
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3_OMNI],
|
||||
},
|
||||
[VIDEO_MODEL_VEO_3_1]: {
|
||||
unit: 'perSecond',
|
||||
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1],
|
||||
},
|
||||
[VIDEO_MODEL_VEO_3_1_FAST]: {
|
||||
unit: 'perSecond',
|
||||
prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1_FAST],
|
||||
},
|
||||
[SOUND_EFFECT_MODEL_VIDU]: {
|
||||
unit: 'perGeneration',
|
||||
price: EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[SOUND_EFFECT_MODEL_VIDU],
|
||||
},
|
||||
[BACKGROUND_MUSIC_MODEL_SUNO]: {
|
||||
unit: 'perGeneration',
|
||||
price: EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
|
||||
BACKGROUND_MUSIC_MODEL_SUNO
|
||||
],
|
||||
},
|
||||
} satisfies EditorGenerationPricingConfig['models'];
|
||||
let runtimeEditorGenerationPricingConfig: EditorGenerationPricingConfig = {
|
||||
models: cloneModelPricing(EDITOR_MODEL_MUD_POINT_CONFIG),
|
||||
};
|
||||
export const EDITOR_SOUND_EFFECT_MODEL_OPTIONS = [
|
||||
{ label: 'Vidu', value: SOUND_EFFECT_MODEL_VIDU },
|
||||
] as const;
|
||||
@@ -653,17 +681,73 @@ export function formatLayerImageType(layer: CanvasLayer) {
|
||||
return isGeneratedLayer(layer) ? '生成图片' : '上传图片';
|
||||
}
|
||||
|
||||
function normalizeImagePriceSize(
|
||||
model: string,
|
||||
imageSize: string | null | undefined,
|
||||
) {
|
||||
const normalizedImageSize = getSafeImageSizeValue(imageSize);
|
||||
if (model === IMAGE_MODEL_NANOBANANA2 && normalizedImageSize === '0.5K') {
|
||||
return '0.5K';
|
||||
}
|
||||
if (normalizedImageSize === '2K') {
|
||||
return '2K';
|
||||
}
|
||||
return '1K';
|
||||
}
|
||||
|
||||
function getModelPrices(model: string | null | undefined) {
|
||||
const normalizedModel = model?.trim() || '';
|
||||
return normalizedModel
|
||||
? runtimeEditorGenerationPricingConfig.models[normalizedModel]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readModelTierPrice({
|
||||
model,
|
||||
fallbackModel,
|
||||
tier,
|
||||
fallbackTier,
|
||||
}: {
|
||||
model: string;
|
||||
fallbackModel: string;
|
||||
tier: string;
|
||||
fallbackTier: string;
|
||||
}) {
|
||||
const modelPricing = getModelPrices(model) ?? getModelPrices(fallbackModel);
|
||||
const fallbackPricing = getModelPrices(fallbackModel);
|
||||
return (
|
||||
modelPricing?.prices?.[tier] ??
|
||||
modelPricing?.prices?.[fallbackTier] ??
|
||||
modelPricing?.price ??
|
||||
fallbackPricing?.prices?.[tier] ??
|
||||
fallbackPricing?.prices?.[fallbackTier] ??
|
||||
fallbackPricing?.price ??
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function readModelFlatPrice(model: string, fallbackModel: string) {
|
||||
return (
|
||||
getModelPrices(model)?.price ??
|
||||
getModelPrices(fallbackModel)?.price ??
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateCharacterAnimationPrice(
|
||||
model: string | null | undefined,
|
||||
resolution: EditorCharacterAnimationResolution,
|
||||
durationSeconds: number,
|
||||
) {
|
||||
const normalizedModel = (model ?? CHARACTER_ANIMATION_MODEL).trim();
|
||||
const modelRates =
|
||||
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[
|
||||
normalizedModel as keyof typeof EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG
|
||||
] ?? EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL];
|
||||
return modelRates[resolution] * durationSeconds;
|
||||
return (
|
||||
readModelTierPrice({
|
||||
model: normalizedModel,
|
||||
fallbackModel: CHARACTER_ANIMATION_MODEL,
|
||||
tier: resolution,
|
||||
fallbackTier: '480p',
|
||||
}) * durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateEditorVideoPrice(
|
||||
@@ -672,83 +756,100 @@ export function calculateEditorVideoPrice(
|
||||
durationSeconds: number,
|
||||
) {
|
||||
const normalizedModel = (model ?? DEFAULT_VIDEO_MODEL).trim();
|
||||
const modelRates =
|
||||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[
|
||||
normalizedModel as keyof typeof EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG
|
||||
] ?? EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL];
|
||||
return modelRates[resolution] * durationSeconds;
|
||||
return (
|
||||
readModelTierPrice({
|
||||
model: normalizedModel,
|
||||
fallbackModel: DEFAULT_VIDEO_MODEL,
|
||||
tier: resolution,
|
||||
fallbackTier: '480p',
|
||||
}) * durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateEditorImageModelPrice(
|
||||
model: string | null | undefined,
|
||||
imageSize: string | null | undefined = '1K',
|
||||
) {
|
||||
const normalizedModel = normalizeEditorImageModel(model);
|
||||
return (
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[
|
||||
normalizedModel as keyof typeof EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG
|
||||
] ?? EDITOR_GENERATION_MUD_POINT_CONFIG.image
|
||||
);
|
||||
const normalizedSize = normalizeImagePriceSize(normalizedModel, imageSize);
|
||||
return readModelTierPrice({
|
||||
model: normalizedModel,
|
||||
fallbackModel: DEFAULT_IMAGE_MODEL,
|
||||
tier: normalizedSize,
|
||||
fallbackTier: '1K',
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateEditorSpecGenerationPrice(
|
||||
model: string | null | undefined = SPEC_GENERATION_MODEL,
|
||||
) {
|
||||
const normalizedModel = (model ?? SPEC_GENERATION_MODEL).trim();
|
||||
return (
|
||||
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[
|
||||
normalizedModel as keyof typeof EDITOR_SPEC_MODEL_MUD_POINT_CONFIG
|
||||
] ?? EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL]
|
||||
);
|
||||
return calculateEditorImageModelPrice(model ?? SPEC_GENERATION_MODEL, SPEC_GENERATION_IMAGE_SIZE);
|
||||
}
|
||||
|
||||
export function calculateEditorImageGenerationPrice({
|
||||
kind,
|
||||
model,
|
||||
imageSize,
|
||||
}: {
|
||||
kind?: string | null;
|
||||
model?: string | null;
|
||||
imageSize?: string | null;
|
||||
} = {}) {
|
||||
const normalizedKind = kind?.trim();
|
||||
if (normalizedKind === 'spec') {
|
||||
return calculateEditorSpecGenerationPrice(model);
|
||||
}
|
||||
return calculateEditorImageModelPrice(model);
|
||||
return calculateEditorImageModelPrice(model, imageSize);
|
||||
}
|
||||
|
||||
export function calculateEditorIconSpritesheetPrice(
|
||||
model: string | null | undefined,
|
||||
imageSize: string | null | undefined = '1K',
|
||||
) {
|
||||
return calculateEditorImageModelPrice(model);
|
||||
return calculateEditorImageModelPrice(model, imageSize);
|
||||
}
|
||||
|
||||
export function calculateEditorUiDesignPrice(
|
||||
model: string | null | undefined,
|
||||
imageSize: string | null | undefined = '1K',
|
||||
) {
|
||||
return calculateEditorImageModelPrice(model);
|
||||
return calculateEditorImageModelPrice(model, imageSize);
|
||||
}
|
||||
|
||||
export function calculateEditorSoundEffectPrice(
|
||||
model: string | null | undefined,
|
||||
) {
|
||||
const normalizedModel = (model ?? DEFAULT_SOUND_EFFECT_MODEL).trim();
|
||||
return (
|
||||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[
|
||||
normalizedModel as keyof typeof EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG
|
||||
] ?? EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL]
|
||||
);
|
||||
return readModelFlatPrice(normalizedModel, DEFAULT_SOUND_EFFECT_MODEL);
|
||||
}
|
||||
|
||||
export function calculateEditorBackgroundMusicPrice(
|
||||
model: string | null | undefined = DEFAULT_BACKGROUND_MUSIC_MODEL,
|
||||
) {
|
||||
const normalizedModel = (model ?? DEFAULT_BACKGROUND_MUSIC_MODEL).trim();
|
||||
return (
|
||||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
|
||||
normalizedModel as keyof typeof EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG
|
||||
] ??
|
||||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
|
||||
DEFAULT_BACKGROUND_MUSIC_MODEL
|
||||
]
|
||||
return readModelFlatPrice(normalizedModel, DEFAULT_BACKGROUND_MUSIC_MODEL);
|
||||
}
|
||||
|
||||
export function applyEditorGenerationPricingConfig(
|
||||
pricing: EditorGenerationPricingConfig,
|
||||
) {
|
||||
runtimeEditorGenerationPricingConfig = {
|
||||
models: cloneModelPricing(pricing.models),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneModelPricing<T extends EditorGenerationPricingConfig['models']>(
|
||||
value: T,
|
||||
): EditorGenerationPricingConfig['models'] {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([model, pricing]) => [
|
||||
model,
|
||||
{
|
||||
unit: pricing.unit,
|
||||
...(typeof pricing.price === 'number' ? { price: pricing.price } : {}),
|
||||
...(pricing.prices ? { prices: { ...pricing.prices } } : {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,12 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
title: '图标规范 2',
|
||||
},
|
||||
});
|
||||
expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty(
|
||||
'referenceImageSrcs',
|
||||
);
|
||||
expect(plan.kind === 'image' ? plan.input.prompt : '').not.toContain(
|
||||
'参考图生成规范',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds character plans with references and remembered model', () => {
|
||||
@@ -536,7 +542,10 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '3:2',
|
||||
imageSize: '2K',
|
||||
priceMudPoints: calculateEditorIconSpritesheetPrice('gpt-image-2'),
|
||||
priceMudPoints: calculateEditorIconSpritesheetPrice(
|
||||
'gpt-image-2',
|
||||
'2K',
|
||||
),
|
||||
},
|
||||
generationInputs: {
|
||||
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
|
||||
|
||||
@@ -516,7 +516,10 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
model: rememberImageModel,
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
priceMudPoints: calculateEditorIconSpritesheetPrice(rememberImageModel),
|
||||
priceMudPoints: calculateEditorIconSpritesheetPrice(
|
||||
rememberImageModel,
|
||||
dialog.imageSize ?? '1K',
|
||||
),
|
||||
},
|
||||
generationInputs: buildIconGenerationInputs(
|
||||
iconDescriptions,
|
||||
|
||||
@@ -200,7 +200,10 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={calculateEditorIconSpritesheetPrice(dialog.imageModel)}
|
||||
cost={calculateEditorIconSpritesheetPrice(
|
||||
dialog.imageModel,
|
||||
dialog.imageSize,
|
||||
)}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
|
||||
@@ -80,16 +80,25 @@ describe('ImageCanvasPanelDockView', () => {
|
||||
|
||||
it('renders zoom and background settings with callback wiring', () => {
|
||||
const props = renderPanelDock({
|
||||
viewport: { x: 0, y: 0, scale: 0.5 },
|
||||
isZoomMenuOpen: true,
|
||||
isBackgroundSettingsOpen: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至200%' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '显示画布所有元素' }));
|
||||
|
||||
expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(0.25);
|
||||
expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(0.5);
|
||||
expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(1);
|
||||
expect(props.onFitLayers).toHaveBeenCalledTimes(1);
|
||||
expect(props.onCloseZoomMenu).toHaveBeenCalledTimes(2);
|
||||
expect(props.onCloseZoomMenu).toHaveBeenCalledTimes(4);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '画布背景设置' });
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@ import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
|
||||
import {
|
||||
canvasDisplayScaleToViewportScale,
|
||||
CANVAS_BACKGROUND_OPTIONS,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
formatPercent,
|
||||
formatCanvasDisplayScalePercent,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type { CanvasViewport, SidebarPanel } from './ImageCanvasEditorTypes';
|
||||
|
||||
@@ -103,12 +104,12 @@ export function ImageCanvasPanelDockView({
|
||||
<div className="image-canvas-editor__zoom-menu-wrap">
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__zoom-trigger"
|
||||
aria-label={`当前缩放比例 ${formatPercent(viewport.scale)}`}
|
||||
aria-label={`当前缩放比例 ${formatCanvasDisplayScalePercent(viewport.scale)}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isZoomMenuOpen}
|
||||
onClick={onToggleZoomMenu}
|
||||
>
|
||||
{formatPercent(viewport.scale)}
|
||||
{formatCanvasDisplayScalePercent(viewport.scale)}
|
||||
</PlatformInlineOptionButton>
|
||||
{isZoomMenuOpen ? (
|
||||
<PlatformFloatingMenu label="缩放菜单" placement="top-start">
|
||||
@@ -139,16 +140,18 @@ export function ImageCanvasPanelDockView({
|
||||
>
|
||||
显示画布所有元素
|
||||
</PlatformFloatingMenuItem>
|
||||
{[0.5, 1, 2].map((scale) => (
|
||||
{[0.5, 1, 2].map((displayScale) => (
|
||||
<PlatformFloatingMenuItem
|
||||
key={scale}
|
||||
key={displayScale}
|
||||
className="image-canvas-editor__zoom-menu-item"
|
||||
onClick={() => {
|
||||
onUpdateScaleFromCenter(scale);
|
||||
onUpdateScaleFromCenter(
|
||||
canvasDisplayScaleToViewportScale(displayScale),
|
||||
);
|
||||
onCloseZoomMenu();
|
||||
}}
|
||||
>
|
||||
缩放至{Math.round(scale * 100)}%
|
||||
缩放至{Math.round(displayScale * 100)}%
|
||||
</PlatformFloatingMenuItem>
|
||||
))}
|
||||
</PlatformFloatingMenu>
|
||||
|
||||
@@ -303,7 +303,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
|
||||
includeDimensions={false}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="宣发素材"
|
||||
cost={calculateEditorImageModelPrice(dialog.imageModel)}
|
||||
cost={calculateEditorImageModelPrice(dialog.imageModel, workflow.imageSize)}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
|
||||
@@ -448,7 +448,7 @@ export function ImageCanvasQuickEditPanelView({
|
||||
<>
|
||||
<span>生成</span>
|
||||
<span className="image-canvas-editor__mud-point-inline">
|
||||
{calculateEditorImageModelPrice(panel.model)}泥点
|
||||
{calculateEditorImageModelPrice(panel.model, selectedImageSize)}泥点
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
),
|
||||
).toBe(true);
|
||||
const submitButton = screen.getByRole('button', { name: '提交生成规范' });
|
||||
expect(submitButton.textContent).toBe('生成5泥点');
|
||||
expect(submitButton.textContent).toBe('生成40泥点');
|
||||
const dimensionsButton = screen.getByRole('button', {
|
||||
name: '生成图片尺寸 16:9·2K',
|
||||
}) as HTMLButtonElement;
|
||||
@@ -232,7 +232,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: '提交生成规范' });
|
||||
|
||||
expect(submitButton.textContent).toBe('生成5泥点');
|
||||
expect(submitButton.textContent).toBe('生成40泥点');
|
||||
});
|
||||
|
||||
it('disables controls while generating and renders failure state', () => {
|
||||
|
||||
@@ -359,7 +359,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
setGenerateDialog={setGenerateDialog ?? (() => undefined)}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
cost={calculateEditorUiDesignPrice(dialog.imageModel)}
|
||||
cost={calculateEditorUiDesignPrice(dialog.imageModel, dialog.imageSize)}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成UI设计图"
|
||||
optionLabelPrefix="生成图片"
|
||||
|
||||
@@ -693,6 +693,7 @@ export function ImageCanvasWorldView({
|
||||
return (
|
||||
<div
|
||||
className="image-canvas-editor__world"
|
||||
data-testid="image-canvas-world"
|
||||
style={{
|
||||
width: CANVAS_WORLD_SIZE,
|
||||
height: CANVAS_WORLD_SIZE,
|
||||
|
||||
@@ -937,6 +937,73 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('submits icon spec objects without requiring an icon spec reference', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
imageSrc: 'data:image/png;base64,icon-spec',
|
||||
width: 2048,
|
||||
height: 1152,
|
||||
prompt: '图标规范提示词',
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialDialog={{
|
||||
id: 'dialog-icon-spec',
|
||||
mode: 'spec',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
specType: 'icon',
|
||||
specValues: {
|
||||
playSetting: '背包整理玩法',
|
||||
artStyle: '清爽卡通',
|
||||
bodyRatio: '3',
|
||||
characterView: '',
|
||||
customPrompt: '',
|
||||
},
|
||||
placeholder: {
|
||||
x: 120,
|
||||
y: 140,
|
||||
width: 640,
|
||||
height: 360,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 1152,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'spec',
|
||||
assetKind: 'icon-spec',
|
||||
assetLabel: '图标规范 1',
|
||||
size: '2048x1152',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
}),
|
||||
);
|
||||
});
|
||||
const payload = generateEditorImageMock.mock.calls[0]?.[0] ?? {};
|
||||
expect(payload).not.toHaveProperty('referenceImageSrcs');
|
||||
expect(payload.prompt).toContain('背包整理玩法');
|
||||
expect(payload.prompt).not.toContain('参考图生成规范');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-generated-1:图标规范 1:-:icon-spec',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('dialog').textContent).not.toContain(
|
||||
'请选择图标规范',
|
||||
);
|
||||
});
|
||||
|
||||
it('validates icon submission before calling the API', async () => {
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
|
||||
@@ -996,6 +996,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
submissionPlan.input.kind === 'publication-material'
|
||||
? await normalizePublicationReferenceImages(submissionPlan.input)
|
||||
: submissionPlan.input;
|
||||
const canvasCompletionPlaceholder =
|
||||
getGeneratingDialogPlaceholder(dialog);
|
||||
const generated = await generateEditorImage({
|
||||
...imageGenerationInput,
|
||||
projectId,
|
||||
@@ -1003,12 +1005,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
generationInputs: submissionPlan.result.generationInputs,
|
||||
assetFolderId,
|
||||
assetLabel: submissionPlan.result.title,
|
||||
...(canvasDialog && projectId
|
||||
...(canvasDialog && projectId && canvasCompletionPlaceholder
|
||||
? {
|
||||
canvasCompletion: {
|
||||
dialogId: canvasDialog.id,
|
||||
title: submissionPlan.result.title,
|
||||
placeholder: getGeneratingDialogPlaceholder(dialog),
|
||||
title:
|
||||
submissionPlan.result.title ??
|
||||
imageGenerationInput.prompt ??
|
||||
'AI 生成图片',
|
||||
placeholder: canvasCompletionPlaceholder,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -186,6 +181,15 @@ function GenerationWorkflowHarness({
|
||||
?.map((reference) => reference.label)
|
||||
.join('|') ?? '-'}
|
||||
</span>
|
||||
<span data-testid="character-spec-reference">
|
||||
{activeDialog?.characterSpecReference?.label ?? '-'}
|
||||
</span>
|
||||
<span data-testid="icon-spec-reference">
|
||||
{activeDialog?.iconSpecReference?.label ?? '-'}
|
||||
</span>
|
||||
<span data-testid="ui-design-spec-reference">
|
||||
{activeDialog?.uiDesignSpecReference?.label ?? '-'}
|
||||
</span>
|
||||
<span data-testid="quick-edit">
|
||||
{workflow.quickEditPanel
|
||||
? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}`
|
||||
@@ -210,9 +214,18 @@ function GenerationWorkflowHarness({
|
||||
<button type="button" onClick={workflow.openGenerateDialog}>
|
||||
打开生成
|
||||
</button>
|
||||
<button type="button" onClick={workflow.openCharacterGenerationDialog}>
|
||||
打开角色生成
|
||||
</button>
|
||||
<button type="button" onClick={workflow.openIconGenerationDialog}>
|
||||
打开图标生成
|
||||
</button>
|
||||
<button type="button" onClick={() => workflow.openSpecDialog('ui')}>
|
||||
打开UI规范
|
||||
</button>
|
||||
<button type="button" onClick={workflow.openUiDesignGenerationDialog}>
|
||||
打开UI设计生成
|
||||
</button>
|
||||
<button type="button" onClick={workflow.openVideoGenerationDialog}>
|
||||
打开视频生成
|
||||
</button>
|
||||
@@ -264,7 +277,10 @@ function GenerationWorkflowHarness({
|
||||
>
|
||||
打开快速编辑
|
||||
</button>
|
||||
<button type="button" onClick={() => workflow.openRedrawPanel(layers[0]!)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => workflow.openRedrawPanel(layers[0]!)}
|
||||
>
|
||||
打开图片改造
|
||||
</button>
|
||||
<button
|
||||
@@ -358,6 +374,94 @@ function GenerationWorkflowHarness({
|
||||
>
|
||||
选择画布参考图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.pickCharacterSpecFromLayer(
|
||||
createLayer({
|
||||
id: 'layer-character-spec',
|
||||
resourceId: 'resource-character-spec',
|
||||
title: '上一张角色规范',
|
||||
src: 'data:image/png;base64,character-spec',
|
||||
assetKind: 'spec',
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
选择角色规范
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.pickIconSpecFromLayer(
|
||||
createLayer({
|
||||
id: 'layer-icon-spec',
|
||||
resourceId: 'resource-icon-spec',
|
||||
title: '上一张图标规范',
|
||||
src: 'data:image/png;base64,icon-spec',
|
||||
assetKind: 'icon-spec',
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
选择图标规范
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.pickUiDesignSpecFromLayer(
|
||||
createLayer({
|
||||
id: 'layer-ui-icon-spec',
|
||||
resourceId: 'resource-ui-icon-spec',
|
||||
title: '上一张UI图标规范',
|
||||
src: 'data:image/png;base64,ui-icon-spec',
|
||||
assetKind: 'icon-spec',
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
选择UI图标规范
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dialogs.setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
? {
|
||||
...currentDialog,
|
||||
characterSpecReference: {
|
||||
id: 'upload-character-spec',
|
||||
label: '上传角色规范',
|
||||
src: 'data:image/png;base64,upload-character-spec',
|
||||
resourceId: 'resource-upload-character-spec',
|
||||
},
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
>
|
||||
模拟上传角色规范
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dialogs.setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'icon'
|
||||
? {
|
||||
...currentDialog,
|
||||
iconSpecReference: {
|
||||
id: 'upload-icon-spec',
|
||||
label: '上传图标规范',
|
||||
src: 'data:image/png;base64,upload-icon-spec',
|
||||
resourceId: 'resource-upload-icon-spec',
|
||||
},
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
>
|
||||
模拟上传图标规范
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -426,6 +530,7 @@ function GenerationWorkflowHarness({
|
||||
describe('useImageCanvasGenerationWorkflow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('opens a movable canvas generation placeholder and keeps toolbar state active', () => {
|
||||
@@ -672,6 +777,146 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses the last picked character spec when opening the next character material dialog', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
|
||||
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
|
||||
'-',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择角色规范' }));
|
||||
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
|
||||
'上一张角色规范',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'character:idle:open:-:placeholder',
|
||||
);
|
||||
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
|
||||
'上一张角色规范',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the last picked character spec from local cache after remounting the editor', () => {
|
||||
const firstRender = render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择角色规范' }));
|
||||
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
|
||||
'上一张角色规范',
|
||||
);
|
||||
|
||||
firstRender.unmount();
|
||||
render(<GenerationWorkflowHarness />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
|
||||
|
||||
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
|
||||
'上一张角色规范',
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses uploaded character and icon specs when opening matching material dialogs again', async () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '模拟上传角色规范' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
|
||||
'上传角色规范',
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
|
||||
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
|
||||
'上传角色规范',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
|
||||
expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-');
|
||||
fireEvent.click(screen.getByRole('button', { name: '模拟上传图标规范' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
|
||||
'上传图标规范',
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'icon:idle:open:-:placeholder',
|
||||
);
|
||||
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
|
||||
'上传图标规范',
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the last picked icon spec when opening the next icon material dialog', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
|
||||
expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
|
||||
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
|
||||
'上一张图标规范',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'icon:idle:open:-:placeholder',
|
||||
);
|
||||
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
|
||||
'上一张图标规范',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the last picked icon spec from local cache for icon and UI design dialogs', () => {
|
||||
const firstRender = render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
|
||||
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
|
||||
'上一张图标规范',
|
||||
);
|
||||
|
||||
firstRender.unmount();
|
||||
render(<GenerationWorkflowHarness />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
|
||||
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
|
||||
'上一张图标规范',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开UI设计生成' }));
|
||||
expect(screen.getByTestId('ui-design-spec-reference').textContent).toBe(
|
||||
'上一张图标规范',
|
||||
);
|
||||
});
|
||||
|
||||
it('shares the cached icon spec between icon material and UI design dialogs', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开UI设计生成' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'ui-design:idle:open:-:placeholder',
|
||||
);
|
||||
expect(screen.getByTestId('ui-design-spec-reference').textContent).toBe(
|
||||
'上一张图标规范',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择UI图标规范' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
|
||||
|
||||
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
|
||||
'上一张UI图标规范',
|
||||
);
|
||||
});
|
||||
|
||||
it('submits spec generation with reference image and reference prompt semantics', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: 'UI规范图' }),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user