优化 UI 编辑器前置条件检查和资源预览管理:

- 更新 UI 树遍历逻辑,改用 `root` 替代 `children`。
- 添加资源预览作用域和请求 ID,防止并发冲突。
- 调整资源导入模块以支持作用域级预览管理。
- 优化界面设计建议合并逻辑,新增节点继承规则。
This commit is contained in:
2026-08-14 12:15:24 +08:00
parent 580f0198c8
commit 588d0d4728
5 changed files with 68 additions and 21 deletions
@@ -11,6 +11,11 @@ import {
readClientAssetBytes,
resolveClientAssetReadUrl,
} from '../../services/clientApi';
import {
cancelLocalProjectResourcePreviewScope,
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport';
import { ThemedModal } from '../modal/ThemedModal';
import {
buildProjectFiles,
@@ -71,6 +76,16 @@ export function AssetImporter({
const [currentPath, setCurrentPath] = useState(ROOT_PATH);
const currentPathRef = useRef(ROOT_PATH);
const remoteLoadedRef = useRef(false);
const previewScopeIdRef = useRef(createProjectResourcePreviewScopeId());
useEffect(() => {
const previousScopeId = previewScopeIdRef.current;
cancelLocalProjectResourcePreviewScope(previousScopeId);
previewScopeIdRef.current = createProjectResourcePreviewScopeId();
return () => {
cancelLocalProjectResourcePreviewScope(previewScopeIdRef.current);
};
}, [projectPath]);
const loadLocal = useCallback(async () => {
setError(null);
@@ -213,6 +228,8 @@ export function AssetImporter({
void invoke<ImagePreviewResponse>('read_local_project_image_preview', {
projectPath,
relativePath: item.asset?.localPath,
scopeId: previewScopeIdRef.current,
requestId: createProjectResourcePreviewRequestId(),
})
.then((value) => setPreview(value.dataUrl))
.catch(() => setPreview(null));
@@ -226,7 +243,9 @@ export function AssetImporter({
title: '选择图片素材',
multiple: true,
directory: false,
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp'] }],
filters: [
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp'] },
],
});
const sourcePaths = Array.isArray(paths) ? paths : paths ? [paths] : [];
if (!sourcePaths.length) return;
@@ -1,6 +1,10 @@
import { invoke } from '@tauri-apps/api/core';
import type { ImportedAsset } from '../../components/AssetImporter';
import {
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport';
import type { SpriteAsset } from './types/SpriteAsset';
import type { UIDesignImage } from './types/UIDesignImage';
import type { UIDesignImageId } from './types/UIDesignImageId';
@@ -35,10 +39,16 @@ export function decodeImageSize(src: string): Promise<[number, number]> {
async function readImportedImage(
projectPath: string,
asset: ImportedAsset,
scopeId: string,
) {
const preview = await invoke<ImagePreviewResponse>(
'read_local_project_image_preview',
{ projectPath, relativePath: asset.localPath },
{
projectPath,
relativePath: asset.localPath,
scopeId,
requestId: createProjectResourcePreviewRequestId(),
},
);
return {
previewUrl: preview.dataUrl,
@@ -50,11 +60,13 @@ export async function prepareDesignImageBatch(
projectPath: string,
assets: readonly ImportedAsset[],
): Promise<Array<PreparedImageAsset<DesignImageInput>>> {
const scopeId = createProjectResourcePreviewScopeId();
return Promise.all(
assets.map(async (asset) => {
const { previewUrl, pixelSize } = await readImportedImage(
projectPath,
asset,
scopeId,
);
const image: UIDesignImage = {
metadata: {
@@ -79,11 +91,13 @@ export async function prepareSpriteAssetBatch(
projectPath: string,
assets: readonly ImportedAsset[],
): Promise<Array<PreparedImageAsset<SpriteAsset>>> {
const scopeId = createProjectResourcePreviewScopeId();
return Promise.all(
assets.map(async (asset) => {
const { previewUrl, pixelSize } = await readImportedImage(
projectPath,
asset,
scopeId,
);
const resource: SpriteAsset = {
asset_id: asset.id,
@@ -107,7 +107,7 @@ export function validateLayoutGenerationPrerequisites(
message: '请先导入并绑定独立素材',
});
}
const visit = (nodes: State['ui_trees'][number]['children']) => {
const visit = (nodes: State['ui_trees'][number]['root']['children']) => {
for (const node of nodes) {
for (const component of node.components) {
if (
@@ -136,7 +136,7 @@ export function validateLayoutGenerationPrerequisites(
visit(node.children);
}
};
for (const tree of state.ui_trees) visit(tree.children);
for (const tree of state.ui_trees) visit([tree.root]);
return issues;
}
@@ -1,36 +1,50 @@
import type { State } from './types/State';
import type { UIDesignSuggestion } from './types/UIDesignSuggestion';
import type { UIDesignImageId } from './types/UIDesignImageId';
import type { UIDesignSuggestionTreeNode } from './types/UIDesignSuggestionTreeNode';
/**
* Applies semantic suggestions conservatively: null means "do not modify",
* an already curated value wins, and suggestions for resources that disappeared
* from the current State are ignored.
* Applies semantic suggestions conservatively. The tree carries relationships;
* existing metadata remains authoritative whenever it is already populated.
* Persisted `slave_to` remains the owning Page ID, so nested descendants inherit
* their root Page rather than pointing at an intermediate Section.
*/
export function applyUiDesignSuggestions(
state: State,
suggestions: readonly UIDesignSuggestion[],
suggestions: readonly UIDesignSuggestionTreeNode[],
): State {
const next = structuredClone(state);
for (const suggestion of suggestions) {
const image = next.ui_design_images[suggestion.ui_design_image_id];
if (!image) continue;
function applyNode(
node: UIDesignSuggestionTreeNode,
pageId: UIDesignImageId | null,
): void {
const image = next.ui_design_images[node.id];
if (!image) return;
if (image.metadata.name.trim().length === 0 && suggestion.name?.trim()) {
image.metadata.name = suggestion.name.trim();
if (image.metadata.name.trim().length === 0 && node.name.trim()) {
image.metadata.name = node.name.trim();
}
if (image.metadata.role === null && suggestion.role !== null) {
image.metadata.role = suggestion.role;
if (image.metadata.role === null) {
image.metadata.role = node.role;
}
if (image.metadata.slave_to === null && suggestion.slave_to !== null) {
image.metadata.slave_to = suggestion.slave_to;
if (image.metadata.slave_to === null && pageId !== null) {
image.metadata.slave_to = pageId;
}
if (
image.metadata.description.trim().length === 0 &&
suggestion.description?.trim()
node.description.trim()
) {
image.metadata.description = suggestion.description.trim();
image.metadata.description = node.description.trim();
}
const childPageId = node.role === 'Page' ? node.id : pageId;
for (const child of node.children) {
applyNode(child, childPageId);
}
}
for (const root of suggestions) {
applyNode(root, null);
}
return next;
@@ -111,7 +111,7 @@ export async function requestClientApiBytes(
}
let response: Response;
try {
response = await fetch(resolveClientApiUrl(url), {
response = await fetchClientHttp(url, {
...init,
credentials: 'same-origin',
headers,