修正改造按钮显隐语义

按生成 action 能力决定改造按钮显隐
保留支持 action 的错误入口并在点击后提示原因
补充损坏配方与缺失来源回归测试
同步画布编辑器设计文档
This commit is contained in:
2026-08-07 13:58:26 +08:00
parent 21b05f9446
commit 130dc8ef9a
8 changed files with 75 additions and 63 deletions
File diff suppressed because one or more lines are too long
@@ -494,7 +494,7 @@ describe('ImageCanvasGenerationModel', () => {
sourceType: 'uploaded',
generationInputs: buildImageGenerationInputs('上传文件'),
}),
).toBe(false);
).toBe(true);
for (const action of [
'image.perfect-pixel',
'spritesheet.split',
@@ -556,6 +556,20 @@ describe('ImageCanvasGenerationModel', () => {
},
}),
).not.toThrow();
expect(
canOpenRedrawPanel({
...buildSourceLayer(),
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.generate',
fields: [
{ id: 42, title: '生成提示词', value: '损坏数据' } as never,
],
references: [],
},
}),
).toBe(true);
});
it('stores the video web-search setting in reusable metadata', () => {
@@ -1544,48 +1544,17 @@ const LEGACY_REUSABLE_GENERATION_INPUT_TITLES = new Set(
].map((title) => title.toLowerCase()),
);
// 中文注释:只拒绝 source 丢失后无法由用户手动补齐的动作。image.edit 的快速编辑
// 面板不能替换编辑目标,角色动作面板不能重新选择原角色图层,UI 素材 / spritesheet
// 提取也不能替换原 UI 设计图;缺少这些必需 source 时,当前 UI 根本无法构造并提交
// 可执行请求,所以即使 prompt、模型、尺寸等字段仍可恢复也必须拒绝改造。其它拥有
// 手动补选入口的 reference 缺失时不拒绝,继续按 best-effort 恢复其余参数。
const REQUIRED_SOURCE_GENERATION_ACTIONS = new Set<CanvasGenerationAction>([
'image.edit',
'character-animation.generate',
'ui-design.extract-assets',
]);
export function canOpenRedrawPanel(
layer: CanvasLayer,
availableLayers: CanvasLayer[] = [],
) {
if (
layer.sourceType === 'uploaded' ||
layer.taskId?.startsWith('pixel-art-snap-')
) {
export function canOpenRedrawPanel(layer: CanvasLayer) {
// 中文注释:按钮显隐只表达 action capability。参数损坏或必需来源丢失属于
// 点击后的恢复错误,不能在这里静默吞掉入口。
if (layer.taskId?.startsWith('pixel-art-snap-')) {
return false;
}
const decodedInputs = decodeCanvasGenerationInputs(layer.generationInputs);
if (decodedInputs.ok) {
if (!isRemixableCanvasGenerationAction(decodedInputs.inputs.action)) {
return false;
}
if (!REQUIRED_SOURCE_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) {
return true;
}
const sourceReference = decodedInputs.inputs.references.find(
(reference) => reference.id === 'source',
);
if (!sourceReference) {
return false;
}
return availableLayers.some((candidate) =>
sourceReference.refType === 'project-resource'
? candidate.resourceId === sourceReference.refId
: candidate.sourceAssetId === sourceReference.refId,
);
const action = layer.generationInputs?.action;
if (action !== undefined) {
return isRemixableCanvasGenerationAction(action);
}
if (layer.generationInputs?.version === 2) {
if (layer.sourceType === 'uploaded' || layer.generationInputs?.version === 2) {
return false;
}
return Boolean(
@@ -44,7 +44,6 @@ function renderSelectedToolbar(
const selectedLayer = createLayer();
const props: Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0] = {
selectedLayer,
availableLayers: [selectedLayer],
selectedToolbarStyle: { left: 12, top: 24 },
onOpenQuickEditPanel: vi.fn(),
onOpenRedrawPanel: vi.fn(),
@@ -454,14 +453,10 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
within(actionToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['快速编辑', '下载按钮']);
).toEqual(['快速编辑', '改造', '下载按钮']);
});
it('shows redraw only when a required V2 source is available', () => {
const sourceLayer = createLayer({
id: 'source-layer',
resourceId: 'source-resource',
});
it('keeps redraw visible when a supported action has lost its required source', () => {
const generatedLayer = createLayer({
id: 'animation-layer',
resourceId: 'animation-resource',
@@ -477,18 +472,17 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
title: '原角色图',
label: '角色',
refType: 'project-resource',
refId: 'source-resource',
refId: 'missing-source-resource',
},
],
},
});
renderSelectedToolbar({
selectedLayer: generatedLayer,
availableLayers: [generatedLayer, sourceLayer],
});
const props = renderSelectedToolbar({ selectedLayer: generatedLayer });
expect(screen.getByRole('button', { name: '改造' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '改造' }));
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(generatedLayer);
});
it('renders character animation only for character layers', () => {
@@ -542,7 +536,6 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
const { rerender } = render(
<ImageCanvasSelectedLayerToolbarView
selectedLayer={null}
availableLayers={[]}
selectedToolbarStyle={{ left: 0, top: 0 }}
onOpenQuickEditPanel={vi.fn()}
onOpenRedrawPanel={vi.fn()}
@@ -565,7 +558,6 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
rerender(
<ImageCanvasSelectedLayerToolbarView
selectedLayer={createLayer()}
availableLayers={[]}
selectedToolbarStyle={null}
onOpenQuickEditPanel={vi.fn()}
onOpenRedrawPanel={vi.fn()}
@@ -21,7 +21,6 @@ import {
type ImageCanvasSelectedLayerToolbarViewProps = {
selectedLayer: CanvasLayer | null;
availableLayers: CanvasLayer[];
selectedToolbarStyle: CSSProperties | null;
onOpenQuickEditPanel: (layer: CanvasLayer) => void;
onOpenRedrawPanel: (layer: CanvasLayer) => void;
@@ -40,7 +39,6 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
export function ImageCanvasSelectedLayerToolbarView({
selectedLayer,
availableLayers,
selectedToolbarStyle,
onOpenQuickEditPanel,
onOpenRedrawPanel,
@@ -59,10 +57,7 @@ export function ImageCanvasSelectedLayerToolbarView({
if (!selectedLayer || !selectedToolbarStyle) {
return null;
}
const canRedraw = canOpenRedrawPanel(
selectedLayer,
availableLayers,
);
const canRedraw = canOpenRedrawPanel(selectedLayer);
if (selectedLayer.mediaType === 'audio') {
return (
@@ -404,7 +404,6 @@ export function ImageCanvasStageView({
/>
<ImageCanvasSelectedLayerToolbarView
availableLayers={layers}
selectedLayer={
uiAssetExtractionState || quickEditSelectionState
? null
@@ -5001,6 +5001,37 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
});
it('reports malformed metadata after clicking a supported redraw action', () => {
render(
<GenerationWorkflowHarness
initialLayers={[
createLayer({
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.generate',
fields: [
{
id: 42,
title: '生成提示词',
value: '损坏数据',
} as never,
],
references: [],
},
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'保存的生成参数版本不受支持或数据损坏,无法改造。',
);
});
it('warns when a legacy recipe is restored successfully', () => {
render(
<GenerationWorkflowHarness
@@ -1791,6 +1791,18 @@ export function useImageCanvasGenerationWorkflow({
const decodedGenerationInputs = decodeCanvasGenerationInputs(
sourceLayer.generationInputs,
);
const hasInvalidVersionedGenerationInputs = Boolean(
sourceLayer.generationInputs &&
(sourceLayer.generationInputs.version !== undefined ||
sourceLayer.generationInputs.action !== undefined) &&
!decodedGenerationInputs.ok,
);
if (hasInvalidVersionedGenerationInputs) {
showGenerationWarning(
'保存的生成参数版本不受支持或数据损坏,无法改造。',
);
return;
}
const normalizedGenerationInputs = decodedGenerationInputs.ok
? decodedGenerationInputs.inputs
: null;