修复图标规范评审问题

迁移旧版 ui 规范快照并隔离跨对话框优化结果。

关闭非幂等图标规范生成重试并统一参考图解析逻辑。

限制 LLM 重试范围并严格校验优化文本与补全参数。

修正图集键色、数量与素材间距提示约束并补充行为测试。

同步图片画布与生成面板技术文档。
This commit is contained in:
2026-08-05 10:55:09 +08:00
parent f5035af504
commit ac12e929f2
14 changed files with 343 additions and 124 deletions
@@ -785,6 +785,18 @@ describe('ImageCanvasEditorModel', () => {
).toBeUndefined();
});
it('migrates restored legacy ui specs to the icon spec flow', () => {
expect(
hydrateCanvasGenerationDialog({
id: 'generation-dialog-legacy-ui-spec',
mode: 'spec',
prompt: '',
status: 'idle',
specType: 'ui',
})?.specType,
).toBe('icon');
});
it('drops restored generator references owned by another user', () => {
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-owner',
@@ -575,9 +575,7 @@ export function hydrateCanvasGenerationDialog(
: true,
sourceLayerId: stringOrUndefined(snapshot.sourceLayerId),
generatedLayerId: stringOrUndefined(snapshot.generatedLayerId),
specType: isSpecGenerationType(snapshot.specType)
? snapshot.specType
: undefined,
specType: normalizeSpecGenerationType(snapshot.specType),
specValues: hydrateSpecFormValues(snapshot.specValues),
specReference: hydrateCharacterReference(
snapshot.specReference,
@@ -1250,12 +1248,16 @@ function isGenerationStatus(
function isSpecGenerationType(
value: unknown,
): value is NonNullable<CanvasGenerationDialogState['specType']> {
return (
value === 'character' ||
value === 'ui' ||
value === 'icon' ||
value === 'custom'
);
return value === 'character' || value === 'icon' || value === 'custom';
}
function normalizeSpecGenerationType(
value: unknown,
): CanvasGenerationDialogState['specType'] {
if (value === 'ui') {
return 'icon';
}
return isSpecGenerationType(value) ? value : undefined;
}
function hydratePublicationWorkflowId(
@@ -353,11 +353,13 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
'玩法设定',
) as HTMLTextAreaElement;
const artStyle = screen.getByLabelText('美术风格') as HTMLTextAreaElement;
expect(playSetting.maxLength).toBe(200);
expect(artStyle.maxLength).toBe(200);
expect(playSetting.hasAttribute('maxlength')).toBe(false);
expect(artStyle.hasAttribute('maxlength')).toBe(false);
fireEvent.change(playSetting, { target: { value: overLimit } });
expect(Array.from(playSetting.value)).toHaveLength(200);
fireEvent.change(playSetting, { target: { value: '🎮'.repeat(201) } });
expect(Array.from(playSetting.value)).toHaveLength(200);
fireEvent.click(screen.getByRole('button', { name: '提交生成规范' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
@@ -451,6 +453,36 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
).toBe(false);
});
it('ignores an optimization result after the active dialog changes', async () => {
const deferred = createDeferred<string>();
const updateSpecFormValue = vi.fn();
iconSpecClientMocks.refineGamePlay.mockReturnValueOnce(deferred.promise);
const { rerender } = render(
<ImageCanvasSpecGenerationPanelView
dialog={createSpecDialog({ id: 'dialog-a', specType: 'icon' })}
style={{ left: 10, top: 20 }}
onUpdateSpecFormValue={updateSpecFormValue}
onRequestUpload={vi.fn()}
onSubmit={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
rerender(
<ImageCanvasSpecGenerationPanelView
dialog={createSpecDialog({ id: 'dialog-b', specType: 'icon' })}
style={{ left: 10, top: 20 }}
onUpdateSpecFormValue={updateSpecFormValue}
onRequestUpload={vi.fn()}
onSubmit={vi.fn()}
/>,
);
await act(async () => deferred.resolve('不应写入新对话框'));
expect(updateSpecFormValue).not.toHaveBeenCalled();
expect(screen.queryByRole('alert')).toBeNull();
});
it('keeps one undo snapshot per icon spec field and replaces it after a later successful optimization', async () => {
iconSpecClientMocks.refineGamePlay
.mockResolvedValueOnce('第一次优化')
@@ -6,6 +6,7 @@ import {
type RefObject,
type SetStateAction,
useEffect,
useRef,
useState,
} from 'react';
@@ -143,7 +144,16 @@ export function ImageCanvasSpecGenerationPanelView({
const [optimizationError, setOptimizationError] = useState<string | null>(
null,
);
const optimizationDialogKey = `${dialog.id ?? 'active'}:${dialog.mode}:${dialog.specType ?? ''}`;
const activeOptimizationDialogKeyRef = useRef(optimizationDialogKey);
const optimizationRequestVersionsRef = useRef({
playSetting: 0,
artStyle: 0,
});
activeOptimizationDialogKeyRef.current = optimizationDialogKey;
useEffect(() => {
optimizationRequestVersionsRef.current.playSetting += 1;
optimizationRequestVersionsRef.current.artStyle += 1;
setPlaySettingOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
setArtStyleOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
setOptimizationError(null);
@@ -185,23 +195,39 @@ export function ImageCanvasSpecGenerationPanelView({
const setOptimization = isPlaySetting
? setPlaySettingOptimization
: setArtStyleOptimization;
const requestVersion = optimizationRequestVersionsRef.current[field] + 1;
optimizationRequestVersionsRef.current[field] = requestVersion;
const requestDialogKey = optimizationDialogKey;
setOptimization((current) => ({ ...current, optimizing: true }));
setOptimizationError(null);
try {
const refined = isPlaySetting
? await refineEditorIconSpecPlaySetting(value)
: await refineEditorIconSpecArtStyle(value);
if (
activeOptimizationDialogKeyRef.current !== requestDialogKey ||
optimizationRequestVersionsRef.current[field] !== requestVersion
) {
return;
}
onUpdateSpecFormValue(key, refined);
setOptimization({ optimizing: false, undoValue: value });
} catch (error) {
if (
activeOptimizationDialogKeyRef.current !== requestDialogKey ||
optimizationRequestVersionsRef.current[field] !== requestVersion
) {
return;
}
setOptimization((current) => ({ ...current, optimizing: false }));
setOptimizationError(
const fallbackMessage = isPlaySetting
? '优化玩法设定失败'
: '优化美术风格失败';
const errorMessage =
error instanceof Error && error.message.trim()
? error.message
: isPlaySetting
? '优化玩法设定失败'
: '优化美术风格失败',
);
: fallbackMessage;
setOptimizationError(errorMessage);
}
};
@@ -389,7 +415,8 @@ export function ImageCanvasSpecGenerationPanelView({
}}
/>
</label>
) : dialog.specType === 'custom' ? (
) : null}
{!isUiDesignDialog && dialog.specType === 'custom' ? (
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
@@ -411,7 +438,8 @@ export function ImageCanvasSpecGenerationPanelView({
}
/>
</label>
) : isIconSpec ? (
) : null}
{!isUiDesignDialog && isIconSpec ? (
<>
{(
[
@@ -448,7 +476,6 @@ export function ImageCanvasSpecGenerationPanelView({
variant="textarea"
aria-label={item.title}
value={value}
maxLength={EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH}
placeholder={item.placeholder}
disabled={isGenerating || item.optimization.optimizing}
size="sm"
@@ -504,7 +531,8 @@ export function ImageCanvasSpecGenerationPanelView({
);
})}
</>
) : (
) : null}
{!isUiDesignDialog && dialog.specType !== 'custom' && !isIconSpec ? (
<>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
@@ -612,7 +640,7 @@ export function ImageCanvasSpecGenerationPanelView({
</>
) : null}
</>
)}
) : null}
</div>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
@@ -310,11 +310,13 @@ type GenerationSubmissionWorkflowOptions = {
onGenerationWarning?: (message: string) => void;
};
async function normalizeImageGenerationReferenceImages(
input: Parameters<typeof generateEditorImage>[0],
async function normalizeGenerationReferenceImages<
T extends { referenceImageSrcs?: string[] },
>(
input: T,
references: CharacterReferenceImage[],
projectId?: string | null,
) {
): Promise<T> {
if (!input.referenceImageSrcs?.length) {
return input;
}
@@ -335,30 +337,6 @@ async function normalizeImageGenerationReferenceImages(
};
}
async function normalizeIconSpecGenerationReferenceImages(
input: Parameters<typeof generateEditorIconSpec>[0],
references: CharacterReferenceImage[],
projectId?: string | null,
) {
if (!input.referenceImageSrcs?.length) {
return input;
}
return {
...input,
referenceImageSrcs: await Promise.all(
input.referenceImageSrcs.map((referenceImageSrc, index) =>
resolveEditorGenerationMediaReference(
references[index]
? { ...references[index], src: referenceImageSrc }
: { src: referenceImageSrc },
'image',
projectId,
),
),
),
};
}
function resolveImageGenerationDialogReferences(
dialog: GenerateDialogState,
): CharacterReferenceImage[] {
@@ -1941,12 +1919,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
submissionPlan.result.title,
);
} else if (submissionPlan.kind === 'icon-spec') {
const iconSpecInput =
await normalizeIconSpecGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const iconSpecInput = await normalizeGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await runEditorGenerationWithWalletRefresh(
@@ -2118,12 +2095,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
canvasDialog?.id,
);
} else {
const imageGenerationInput =
await normalizeImageGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const imageGenerationInput = await normalizeGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const resultTitle =
submissionPlan.result.title ??
`生成图片 ${layerCounterRef.current + 1}`;
@@ -24,9 +24,9 @@ import {
loadEditorGenerationPricing,
loadEditorProject,
loadOrCreateRecentEditorProject,
removeEditorImageBackground,
refineEditorIconSpecArtStyle,
refineEditorIconSpecPlaySetting,
removeEditorImageBackground,
renameEditorProject,
saveEditorProjectLayout,
splitEditorIconSpritesheet,
@@ -1356,6 +1356,9 @@ describe('editorProjectClient', () => {
expect(body).not.toHaveProperty('prompt');
expect(body).not.toHaveProperty('kind');
expect(body).not.toHaveProperty('assetKind');
expect(requestJsonMock.mock.calls[0]?.[3]).toEqual({
timeoutMs: 1_200_000,
});
});
it('passes publication material generation kind and size to the backend BFF', async () => {
@@ -1020,7 +1020,6 @@ export async function generateEditorIconSpec(
'生成图标规范失败',
{
timeoutMs: 1_200_000,
retry: EDITOR_REQUEST_RETRY_OPTIONS,
},
);
}