将视觉步骤切换为自动分离
前端仅调用一次 separate_ui,并完成 cut 图片登记、SpriteAsset 回填和 State 保存。 增加 sidecar 恢复选择面板,旧 bind_components 标记为未调用遗留命令。
This commit is contained in:
@@ -108,6 +108,8 @@ const rustSharedContractSource = fs.readFileSync(
|
||||
);
|
||||
const allowedUncalledTauriCommands = [
|
||||
'append_direct_project_conversation_message',
|
||||
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
||||
'bind_components',
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'create_ui_design_resource',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type SeparationRecoveryDTO = { exists: boolean, bound_node_count: number, problematic_node_count: number, has_pending_tree: boolean, };
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ThemedModal } from '../../../components/modal/ThemedModal';
|
||||
import type { UiEditorWorkflowProjection } from '../useUiEditorPage';
|
||||
|
||||
export function WorkflowActionCard({
|
||||
@@ -10,19 +11,19 @@ export function WorkflowActionCard({
|
||||
(workflow.activeStep === 'reference-analysis' && workflow.isSuggesting) ||
|
||||
(workflow.activeStep === 'structure-recognition' &&
|
||||
workflow.isRecognizing) ||
|
||||
(workflow.activeStep === 'visual-binding' && workflow.isBinding);
|
||||
(workflow.activeStep === 'visual-binding' && workflow.isSeparating);
|
||||
const status =
|
||||
workflow.activeStep === 'reference-analysis'
|
||||
? workflow.suggestionStatus
|
||||
: workflow.activeStep === 'structure-recognition'
|
||||
? workflow.recognitionStatus
|
||||
: workflow.bindingStatus;
|
||||
: workflow.separationStatus;
|
||||
const hasRun =
|
||||
workflow.activeStep === 'reference-analysis'
|
||||
? workflow.hasSuggested
|
||||
: workflow.activeStep === 'structure-recognition'
|
||||
? workflow.hasRecognized
|
||||
: workflow.hasBound;
|
||||
: workflow.hasSeparated;
|
||||
|
||||
return (
|
||||
<section className="shrink-0 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-4 py-3">
|
||||
@@ -56,6 +57,44 @@ export function WorkflowActionCard({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<ThemedModal
|
||||
open={workflow.separationRecovery !== null}
|
||||
ariaLabel="恢复自动分离"
|
||||
onClose={workflow.cancelSeparationRecovery}
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
panelClassName="w-full max-w-md rounded-2xl p-5 shadow-2xl"
|
||||
>
|
||||
<h3 className="m-0 text-base font-semibold">发现未完成的自动分离</h3>
|
||||
<p className="mt-3 text-sm text-(--platform-text-soft)">
|
||||
上次分离留下了可恢复状态(已登记{' '}
|
||||
{workflow.separationRecovery?.bound_node_count ?? 0}{' '}
|
||||
个节点)。请选择继续上次分离,或开始新的分离。
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-1.5 text-xs"
|
||||
onClick={workflow.cancelSeparationRecovery}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-orange-500 px-3 py-1.5 text-xs"
|
||||
onClick={() => void workflow.restartSeparation()}
|
||||
>
|
||||
开始新的分离
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-orange-600 px-3 py-1.5 text-xs font-semibold text-white"
|
||||
onClick={() => void workflow.continueSeparation()}
|
||||
>
|
||||
继续上次分离
|
||||
</button>
|
||||
</div>
|
||||
</ThemedModal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -76,8 +115,8 @@ function getStepAction(workflow: UiEditorWorkflowProjection) {
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: '绑定视觉素材',
|
||||
runningLabel: '绑定中…',
|
||||
action: workflow.bindComponents,
|
||||
label: '自动分离并绑定视觉素材',
|
||||
runningLabel: '自动分离中…',
|
||||
action: workflow.separateUi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { ImportedAsset } from '../../components/AssetImporter';
|
||||
import { applyBindingResult } from '../../features/ui-editor/binding';
|
||||
import {
|
||||
prepareDesignImageBatch,
|
||||
prepareFontAssetBatch,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
type StageStatusField,
|
||||
} from '../../features/ui-editor/stageStatusOverview';
|
||||
import { collectUiNodeIds } from '../../features/ui-editor/treeUtils';
|
||||
import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
|
||||
import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode';
|
||||
import type { Component } from '../../features/ui-editor/types/Component';
|
||||
import type { FontAssetId } from '../../features/ui-editor/types/FontAssetId';
|
||||
@@ -23,6 +21,8 @@ import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO';
|
||||
import type { Node as UiNode } from '../../features/ui-editor/types/Node';
|
||||
import type { NodeId } from '../../features/ui-editor/types/NodeId';
|
||||
import type { RecognitionDTO } from '../../features/ui-editor/types/RecognitionDTO';
|
||||
import type { SeparationDTO } from '../../features/ui-editor/types/SeparationDTO';
|
||||
import type { SeparationRecoveryDTO } from '../../features/ui-editor/types/SeparationRecoveryDTO';
|
||||
import type { SpriteAssetId } from '../../features/ui-editor/types/SpriteAssetId';
|
||||
import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder';
|
||||
import type { State } from '../../features/ui-editor/types/State';
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from '../../features/ui-editor/uiDesignStateStore';
|
||||
import { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSuggestions';
|
||||
import { useUiEditorFontFaces } from '../../features/ui-editor/useUiEditorFontFaces';
|
||||
import { addSpriteAssetsToState } from '../../features/ui-editor/useUiEditorState';
|
||||
import {
|
||||
EMPTY_UI_EDITOR_STATE,
|
||||
type NodeLayoutPatch,
|
||||
@@ -70,7 +71,15 @@ import {
|
||||
} from './model';
|
||||
import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
|
||||
|
||||
const ASSET_BATCH_SIZE = 5;
|
||||
const SEPARATION_IMPORT_BATCH_SIZE = 100;
|
||||
|
||||
type LocalImageImportResponse = {
|
||||
assets: Array<{ id: string; localPath: string; assetKind?: string | null }>;
|
||||
};
|
||||
|
||||
function normalizeProjectRelativePath(path: string): string {
|
||||
return path.replaceAll('\\', '/').replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
type StatusFieldHighlight = {
|
||||
nodeId: NodeId;
|
||||
@@ -250,11 +259,13 @@ export function useUiEditorSession(
|
||||
);
|
||||
const [isMerging, setIsMerging] = useState(false);
|
||||
const [mergeStatus, setMergeStatus] = useState<string | null>(null);
|
||||
const [isBinding, setIsBinding] = useState(false);
|
||||
const [bindingStatus, setBindingStatus] = useState<string | null>(null);
|
||||
const [isSeparating, setIsSeparating] = useState(false);
|
||||
const [separationStatus, setSeparationStatus] = useState<string | null>(null);
|
||||
const [separationRecovery, setSeparationRecovery] =
|
||||
useState<SeparationRecoveryDTO | null>(null);
|
||||
const [hasSuggested, setHasSuggested] = useState(false);
|
||||
const [hasRecognized, setHasRecognized] = useState(false);
|
||||
const [hasBound, setHasBound] = useState(false);
|
||||
const [hasSeparated, setHasSeparated] = useState(false);
|
||||
const [completionNotice, setCompletionNotice] =
|
||||
useState<WorkflowCompletionNotice | null>(null);
|
||||
|
||||
@@ -393,7 +404,8 @@ export function useUiEditorSession(
|
||||
image.metadata.role === 'Page' &&
|
||||
!isSlaveToDescendant(images, id as UIDesignImageId, activeImageId),
|
||||
);
|
||||
const isAiRunning = isSuggesting || isRecognizing || isBinding || isMerging;
|
||||
const isAiRunning =
|
||||
isSuggesting || isRecognizing || isMerging || isSeparating;
|
||||
const isWorkflowBusy =
|
||||
isAiRunning || isSaving || isGenerating || isLoading || editor.isLocked;
|
||||
const stateSignature = JSON.stringify(editor.state);
|
||||
@@ -1061,53 +1073,227 @@ export function useUiEditorSession(
|
||||
}
|
||||
}
|
||||
|
||||
async function bindComponents() {
|
||||
if (isBinding || isWorkflowBusy) return;
|
||||
async function runSeparationWorkflow() {
|
||||
if (!resourceId) return;
|
||||
setIsSeparating(true);
|
||||
setSeparationStatus(null);
|
||||
setCompletionNotice(null);
|
||||
setBindingStatus(null);
|
||||
setIsBinding(true);
|
||||
let preparedSprites: Awaited<ReturnType<typeof prepareSpriteAssetBatch>> =
|
||||
[];
|
||||
try {
|
||||
let backfillErrors: string[] = [];
|
||||
let separationResult: SeparationDTO | null = null;
|
||||
await editor.runWithStateLocked(async (snapshot) => {
|
||||
const allSpriteIds = Object.keys(snapshot.sprite_assets);
|
||||
const batches: string[][] = [];
|
||||
const result = await invoke<SeparationDTO>('separate_ui', {
|
||||
projectPath,
|
||||
assetId: resourceId,
|
||||
state: snapshot,
|
||||
});
|
||||
separationResult = result;
|
||||
|
||||
const uniquePaths = [
|
||||
...new Set(
|
||||
result.bound_nodes.map((bound) =>
|
||||
normalizeProjectRelativePath(bound.cut_image_path),
|
||||
),
|
||||
),
|
||||
];
|
||||
const importedByPath = new Map<
|
||||
string,
|
||||
{ id: string; localPath: string; assetKind: string | null }
|
||||
>();
|
||||
for (
|
||||
let index = 0;
|
||||
index < allSpriteIds.length;
|
||||
index += ASSET_BATCH_SIZE
|
||||
index < uniquePaths.length;
|
||||
index += SEPARATION_IMPORT_BATCH_SIZE
|
||||
) {
|
||||
batches.push(allSpriteIds.slice(index, index + ASSET_BATCH_SIZE));
|
||||
const relativePaths = uniquePaths.slice(
|
||||
index,
|
||||
index + SEPARATION_IMPORT_BATCH_SIZE,
|
||||
);
|
||||
const imported = await invoke<LocalImageImportResponse>(
|
||||
'import_local_project_image_assets',
|
||||
{ projectPath, relativePaths },
|
||||
);
|
||||
for (const [assetIndex, asset] of imported.assets.entries()) {
|
||||
const normalizedAsset = {
|
||||
id: asset.id,
|
||||
localPath: normalizeProjectRelativePath(asset.localPath),
|
||||
assetKind: asset.assetKind ?? null,
|
||||
};
|
||||
// The importer may copy a sidecar file into assets/uploads and
|
||||
// therefore return a different localPath. Keep both identities:
|
||||
// the cut path is the separation contract, while the returned
|
||||
// path is the SpriteAsset resource path.
|
||||
importedByPath.set(normalizedAsset.localPath, normalizedAsset);
|
||||
const requestedPath = relativePaths[assetIndex];
|
||||
if (requestedPath) {
|
||||
importedByPath.set(
|
||||
normalizeProjectRelativePath(requestedPath),
|
||||
normalizedAsset,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (batches.length === 0) batches.push([]);
|
||||
let current = snapshot;
|
||||
for (const [index, spriteIds] of batches.entries()) {
|
||||
setBindingStatus(`绑定组件中(${index + 1}/${batches.length})…`);
|
||||
const result = await invoke<BindingDTO>('bind_components', {
|
||||
projectPath,
|
||||
state: current,
|
||||
spriteIds,
|
||||
});
|
||||
current = applyBindingResult(current, result);
|
||||
editor.replaceState(current, {
|
||||
history: index < batches.length - 1 ? 'skip' : 'record',
|
||||
});
|
||||
|
||||
const missingImports = uniquePaths.filter(
|
||||
(path) => !importedByPath.has(path),
|
||||
);
|
||||
backfillErrors = missingImports.map(
|
||||
(path) => `未能登记分离图片:${path}`,
|
||||
);
|
||||
const importedAssets: ImportedAsset[] = [
|
||||
...new Map(
|
||||
[...importedByPath.values()].map((asset) => [asset.id, asset]),
|
||||
).values(),
|
||||
];
|
||||
preparedSprites = await prepareSpriteAssetBatch(
|
||||
projectPath,
|
||||
importedAssets,
|
||||
);
|
||||
const spriteById = new Map(
|
||||
preparedSprites.map((item) => [
|
||||
item.resource.asset_id,
|
||||
item.resource,
|
||||
]),
|
||||
);
|
||||
const spriteByPath = new Map(
|
||||
[...importedByPath.entries()].flatMap(([path, asset]) => {
|
||||
const sprite = spriteById.get(asset.id);
|
||||
return sprite ? [[path, sprite] as const] : [];
|
||||
}),
|
||||
);
|
||||
const added = addSpriteAssetsToState(
|
||||
snapshot,
|
||||
preparedSprites.map((item) => item.resource),
|
||||
);
|
||||
if (!added.ok) {
|
||||
throw new Error(uiEditorOperationError(added.reason));
|
||||
}
|
||||
const next = added.value;
|
||||
for (const bound of result.bound_nodes) {
|
||||
const path = normalizeProjectRelativePath(bound.cut_image_path);
|
||||
const sprite = spriteByPath.get(path);
|
||||
if (!sprite) {
|
||||
backfillErrors.push(
|
||||
`节点 ${bound.node_id} 缺少已登记的分离图片:${path}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const location = next.ui_trees
|
||||
.map((tree) => findUiNodeLocation(tree.root, bound.node_id))
|
||||
.find((candidate) => candidate !== null);
|
||||
if (!location) {
|
||||
backfillErrors.push(`节点 ${bound.node_id} 已不存在,素材已保留`);
|
||||
continue;
|
||||
}
|
||||
const imageComponent = location.node.components.find(
|
||||
(component): component is Extract<Component, { Image: unknown }> =>
|
||||
'Image' in component && component.Image.target_graphic === null,
|
||||
);
|
||||
if (!imageComponent || !('Image' in imageComponent)) {
|
||||
backfillErrors.push(
|
||||
`节点 ${bound.node_id} 没有可回填的未绑定 Image 组件,素材已保留`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
imageComponent.Image.target_graphic = sprite.asset_id;
|
||||
}
|
||||
editor.replaceState(next);
|
||||
});
|
||||
|
||||
setPreviewUrls((current) => ({
|
||||
...current,
|
||||
...Object.fromEntries(
|
||||
preparedSprites.map((item) => [
|
||||
item.resource.asset_id,
|
||||
item.previewUrl,
|
||||
]),
|
||||
),
|
||||
}));
|
||||
if (separationResult === null) throw new Error('自动分离没有返回结果');
|
||||
const completedResult = separationResult as SeparationDTO;
|
||||
if (!(await save())) {
|
||||
throw new Error(
|
||||
'分离结果已写入编辑器,但 State 保存失败;sidecar 已保留,可继续恢复。',
|
||||
);
|
||||
}
|
||||
if (backfillErrors.length > 0) {
|
||||
reportWorkflowCompletion(
|
||||
'visual-binding',
|
||||
'success',
|
||||
`视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`,
|
||||
setBindingStatus,
|
||||
'failure',
|
||||
`自动分离已完成,但有 ${backfillErrors.length} 项未能回填;已登记素材并保留恢复状态。\n${backfillErrors.join('\n')}`,
|
||||
setSeparationStatus,
|
||||
);
|
||||
setHasBound(true);
|
||||
return;
|
||||
}
|
||||
await invoke('finalize_separation', {
|
||||
projectPath,
|
||||
assetId: resourceId,
|
||||
});
|
||||
setHasSeparated(true);
|
||||
reportWorkflowCompletion(
|
||||
'visual-binding',
|
||||
'success',
|
||||
`自动分离完成:${completedResult.bound_nodes.length} 个已绑定,${completedResult.problematic_nodes.length} 个待处理。`,
|
||||
setSeparationStatus,
|
||||
);
|
||||
} catch (cause) {
|
||||
reportWorkflowCompletion(
|
||||
'visual-binding',
|
||||
'failure',
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
setBindingStatus,
|
||||
setSeparationStatus,
|
||||
);
|
||||
} finally {
|
||||
setIsBinding(false);
|
||||
setIsSeparating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function separateUi() {
|
||||
if (
|
||||
isSeparating ||
|
||||
isWorkflowBusy ||
|
||||
separationRecovery !== null ||
|
||||
!resourceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const recovery = await invoke<SeparationRecoveryDTO>(
|
||||
'inspect_separation_recovery',
|
||||
{ projectPath, assetId: resourceId },
|
||||
);
|
||||
if (recovery.exists) {
|
||||
setSeparationRecovery(recovery);
|
||||
return;
|
||||
}
|
||||
await runSeparationWorkflow();
|
||||
} catch (cause) {
|
||||
setSeparationStatus(
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function continueSeparation() {
|
||||
setSeparationRecovery(null);
|
||||
await runSeparationWorkflow();
|
||||
}
|
||||
|
||||
async function restartSeparation() {
|
||||
if (!resourceId) return;
|
||||
setSeparationRecovery(null);
|
||||
try {
|
||||
await invoke('discard_separation_recovery', {
|
||||
projectPath,
|
||||
assetId: resourceId,
|
||||
});
|
||||
await runSeparationWorkflow();
|
||||
} catch (cause) {
|
||||
setSeparationStatus(
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1244,17 +1430,15 @@ export function useUiEditorSession(
|
||||
selectedNodeId,
|
||||
focusRequest,
|
||||
operations: {
|
||||
isBinding,
|
||||
isMerging,
|
||||
isRecognizing,
|
||||
isSuggesting,
|
||||
bindingStatus,
|
||||
mergeStatus,
|
||||
recognitionStatus,
|
||||
suggestionStatus,
|
||||
},
|
||||
checkPrerequisites,
|
||||
bindComponents,
|
||||
separateUi,
|
||||
mergeUi,
|
||||
recognizeUi,
|
||||
suggestUiDesignSemantics,
|
||||
@@ -1367,11 +1551,15 @@ export function useUiEditorSession(
|
||||
hasRecognized,
|
||||
recognitionStatus,
|
||||
recognizeUi,
|
||||
isBinding,
|
||||
hasBound,
|
||||
bindingStatus,
|
||||
hasSeparated,
|
||||
completionNotice,
|
||||
bindComponents,
|
||||
separateUi,
|
||||
isSeparating,
|
||||
separationStatus,
|
||||
separationRecovery,
|
||||
continueSeparation,
|
||||
restartSeparation,
|
||||
cancelSeparationRecovery: () => setSeparationRecovery(null),
|
||||
requestStepChange,
|
||||
continueToNextStep: () => {
|
||||
if (nextStep) requestStepChange(nextStep);
|
||||
|
||||
Reference in New Issue
Block a user