修复角色规范多参考图删除

保证上传参考图身份唯一并按选中位置逐张删除

让规范参考图删除入口稳定可见并统一模型上限

补充多图删除、身份冲突和上限回归测试

同步更新画布角色规范参考图文档
This commit is contained in:
2026-08-12 18:21:37 +08:00
parent d9f5443c7c
commit d5aed9c4e9
10 changed files with 115 additions and 13 deletions
@@ -85,9 +85,9 @@
## 生成规范参考图
- `生成规范 -> 角色规范``图标规范``图标规范``自定义规范` 的设定面板支持上传 1 张参考图,并统一放在面板首行参考图区域。
- `生成规范 -> 角色规范``自定义规范` 的设定面板支持上传多张参考图,最多 5 张;`图标规范` 保持单张规范参考图。所有参考图统一放在面板首行参考图区域,并可逐张删除
- 参考图入口只展示字段标题、缩略图或上传图标、文件名,不把参考规则说明铺在 UI 上。
- 提交生成规范时,若存在参考图,前端必须把参考图作为 `referenceImageSrcs[0]` 提交到 `/api/editor/images/generations`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图 1 的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。
- 提交生成规范时,若存在参考图,前端必须按用户当前顺序把全部参考图提交到 `/api/editor/images/generations``referenceImageSrcs`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。
- 生成结果的信息快照必须记录该参考图,标题为 `参考图`,便于后续在图片信息面板回看生成输入。
## 新建生成图落点避让
@@ -52,6 +52,7 @@ import {
isNormalizedCanvasGenerationInputs,
isQuickEditSupportedLayer,
resolveCharacterAnimationSourceImageSrc,
resolveDialogExtraImageReferenceLimit,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
VIDEO_MODEL_KLING_3,
@@ -68,6 +69,26 @@ describe('ImageCanvasGenerationModel', () => {
});
});
it('uses the fixed spec model provider limit for multi-reference spec dialogs', () => {
expect(
resolveDialogExtraImageReferenceLimit({
mode: 'spec',
specType: 'character',
prompt: '',
status: 'idle',
}),
).toBe(5);
expect(
resolveDialogExtraImageReferenceLimit({
mode: 'spec',
specType: 'custom',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
}),
).toBe(5);
});
it('展示时长时收敛到两位小数且不改动其他字段', () => {
const format = (title: string, value: string) =>
formatEditorGenerationInputFieldValue({ title, value });
@@ -464,7 +464,8 @@ export function resolveExtraImageReferenceLimit(
export function resolveDialogExtraImageReferenceLimit(
dialog: GenerateDialogState,
model: string | null | undefined = dialog.imageModel,
model: string | null | undefined = dialog.imageModel ??
(dialog.mode === 'spec' ? SPEC_GENERATION_MODEL : undefined),
) {
if (dialog.mode === 'icon') {
return resolveExtraImageReferenceLimit(
@@ -494,7 +495,10 @@ export function resolveDialogExtraImageReferenceLimit(
1,
);
}
return IMAGE_GENERATION_REFERENCE_LIMIT;
return resolveExtraImageReferenceLimit(
model,
IMAGE_GENERATION_REFERENCE_LIMIT,
);
}
export function countDialogExtraImageReferences(dialog: GenerateDialogState) {
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
@@ -14,7 +14,10 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
describe('ImageCanvasReferenceSlot', () => {
beforeEach(() => {
useResolvedAssetReadUrlMock.mockImplementation(
(source: string | null | undefined, options?: { objectKey?: string | null }) => ({
(
source: string | null | undefined,
options?: { objectKey?: string | null },
) => ({
resolvedUrl: options?.objectKey
? `https://oss.example.com/${options.objectKey}?signature=1`
: (source ?? ''),
@@ -49,4 +52,21 @@ describe('ImageCanvasReferenceSlot', () => {
'https://oss.example.com/generated-character-drafts/editor/legacy-resources/editor-resource-1.png?signature=1',
);
});
it('keeps an explicitly marked reference remove action visible', () => {
render(
<ImageCanvasReferenceSlot
icon={<span />}
imageSrc="/reference.png"
label="参考图1"
ariaLabel="参考图1"
onRemove={vi.fn()}
alwaysShowRemove
/>,
);
expect(
screen.getByRole('button', { name: '删除参考图1' }).className,
).toContain('image-canvas-editor__reference-chip-remove--always-visible');
});
});
@@ -28,6 +28,7 @@ type ImageCanvasReferenceSlotProps = {
onClick?: () => void;
onRemove?: () => void;
removeLabel?: string;
alwaysShowRemove?: boolean;
};
export function ImageCanvasReferenceSlot({
@@ -45,6 +46,7 @@ export function ImageCanvasReferenceSlot({
onClick,
onRemove,
removeLabel,
alwaysShowRemove = false,
}: ImageCanvasReferenceSlotProps) {
const hasPreviewImage = Boolean(imageSrc?.trim() || objectKey?.trim());
const slotClassName = [
@@ -106,7 +108,11 @@ export function ImageCanvasReferenceSlot({
{onRemove ? (
<button
type="button"
className="image-canvas-editor__reference-chip-remove"
className={`image-canvas-editor__reference-chip-remove${
alwaysShowRemove
? ' image-canvas-editor__reference-chip-remove--always-visible'
: ''
}`}
disabled={disabled}
aria-label={removeLabel ?? `删除${ariaLabel}`}
title="删除"
@@ -302,6 +302,27 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
expect(screen.queryByLabelText('界面风格参考')).toBeNull();
});
it('removes only the selected character spec reference and keeps its action visible', () => {
render(
<UiDesignHarness
initialDialog={createSpecDialog({
generationReferences: [
{ id: 'ref-a', label: '参考一', src: 'data:image/png;base64,a' },
{ id: 'ref-b', label: '参考二', src: 'data:image/png;base64,b' },
{ id: 'ref-c', label: '参考三', src: 'data:image/png;base64,c' },
],
})}
/>,
);
expect(screen.getAllByRole('button', { name: /删除参考/ })).toHaveLength(3);
fireEvent.click(screen.getByRole('button', { name: '删除参考二' }));
expect(screen.queryByLabelText('参考二')).toBeNull();
expect(screen.getByLabelText('参考一')).toBeTruthy();
expect(screen.getByLabelText('参考三')).toBeTruthy();
});
it('renders character spec fields and forwards updates', () => {
const updateSpecFormValue = vi.fn();
renderPanel({
@@ -345,7 +345,7 @@ export function ImageCanvasSpecGenerationPanelView({
) : null}
{extraSpecReferences.map((item, index) => (
<ImageCanvasReferenceSlot
key={item.id}
key={`${item.id}-${index}`}
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
imageSrc={item.src}
@@ -372,8 +372,8 @@ export function ImageCanvasSpecGenerationPanelView({
generationReferences: (
currentDialog.generationReferences ?? []
).filter(
(referenceItem) =>
referenceItem.id !== item.id,
(_referenceItem, referenceIndex) =>
referenceIndex !== index,
),
}
: currentDialog,
@@ -381,6 +381,7 @@ export function ImageCanvasSpecGenerationPanelView({
: undefined
}
removeLabel={`删除${item.label || `参考图${index + 1}`}`}
alwaysShowRemove
/>
))}
{!isUiDesignDialog && dialog.specType !== 'icon' ? (
@@ -349,7 +349,7 @@ describe('ImageCanvasUploadModel', () => {
imageSrc: 'data:image/png;base64,ref',
}),
).toEqual({
id: 'upload-character-reference-1781697600000-2',
id: 'upload-character-reference-1781697600000-2-1',
label: '参考图3',
src: 'data:image/png;base64,ref',
mediaType: 'image',
@@ -361,6 +361,26 @@ describe('ImageCanvasUploadModel', () => {
});
});
it('keeps uploaded reference identities unique across same-time batches', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-17T12:00:00.000Z'));
const first = createUploadedGenerationReference({
idPrefix: 'upload-spec-reference',
index: 0,
fallbackLabel: '参考图1',
imageSrc: 'data:image/png;base64,first',
});
const second = createUploadedGenerationReference({
idPrefix: 'upload-spec-reference',
index: 0,
fallbackLabel: '参考图1',
imageSrc: 'data:image/png;base64,second',
});
expect(first.id).not.toBe(second.id);
});
it('resets failed generation dialogs when reference uploads are applied', () => {
expect(
setFailedGenerationIdle(
@@ -47,6 +47,8 @@ type GenerationReferenceUploadTarget =
| 'ui-design-icon-spec'
| 'publication-reference';
let uploadedGenerationReferenceSequence = 0;
const VIDEO_REFERENCE_LIMITS = {
image: 9,
video: 3,
@@ -128,11 +130,12 @@ export function createUploadedGenerationReference({
resourceId?: string | null;
sourceAssetId?: string | null;
}): CharacterReferenceImage {
uploadedGenerationReferenceSequence += 1;
return {
id:
typeof index === 'number'
? `${idPrefix}-${Date.now()}-${index}`
: `${idPrefix}-${Date.now()}`,
? `${idPrefix}-${Date.now()}-${index}-${uploadedGenerationReferenceSequence}`
: `${idPrefix}-${Date.now()}-${uploadedGenerationReferenceSequence}`,
label: fileName || fallbackLabel,
src: imageSrc,
mediaType,
+6
View File
@@ -7024,6 +7024,12 @@ button.image-canvas-editor__reference-chip:disabled {
transform 120ms ease;
}
.image-canvas-editor__reference-chip-remove--always-visible {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
.image-canvas-editor__reference-slot:hover
.image-canvas-editor__reference-chip-remove,
.image-canvas-editor__reference-slot:focus-within