新增UI编辑器识别概览与节点定位

递归统计布局识别状态并显示识别概览。
支持待检查和阻塞节点的循环跨界面聚焦。
抽取通用状态查询与一次性聚焦请求并补充测试和约定。
This commit is contained in:
2026-08-17 15:57:29 +08:00
parent 375519fa2d
commit e0c034a49b
10 changed files with 468 additions and 15 deletions
@@ -29,3 +29,7 @@
- 阶段命令可以只返回该阶段的 DTO;调用方负责将 DTO 显式合并到同一个内存 `State`。仅用于调试的验证入口与正式工作流分离。
- 节点布局与组件是独立通道:`layout_status` / `components_status` 分别记录阶段状态,`allow_llm_edit_layout` / `allow_llm_edit_component` 分别授权 LLM 修改;人工编辑不隐式改变状态或授权。页面根节点可承载组件,但其 Transform 不可修改。
- “组件结构”阶段显示“识别概览”,递归统计 `State.ui_trees` 中全部 Node,不按 `source` 过滤;“已识别 Node”为全部节点数,“待用户检查”为 `layout_status``NeedReview``Blocked` 的数量,“已通过”为 `Passed` 数量,“必须修复”为 `Blocked` 数量。`components_status` 的阶段概览属于后续“绑定概览”。
- “待用户检查”和“必须修复”分别按 `ui_trees` 顺序及树的前序遍历循环定位下一项;各自游标只属于概览组件的临时 UI 状态,树变化后重置,不写入正式 `State`
- 概览定位 Node 时必须统一完成所属界面图激活、节点选择、Inspector 展示、UI Tree 祖先展开与滚动,以及预览画布对节点的带留白适配;状态查询与定位行为应可复用,不在统计卡点击处理器内复制树遍历和跨面板协调。
@@ -0,0 +1,85 @@
import type { Node as UiNode } from './types/Node';
import type { NodeMetadata } from './types/NodeMetadata';
import type { StageStatus } from './types/StageStatus';
import type { UIDesignImageId } from './types/UIDesignImageId';
import type { UITree } from './types/UITree';
export type StageStatusField = Extract<
keyof NodeMetadata,
'layout_status' | 'components_status'
>;
export type UiTreeNodeTarget = {
treeId: UIDesignImageId;
node: UiNode;
};
export type StageStatusOverview = {
total: number;
needsAttention: number;
passed: number;
blocked: number;
};
export function isNeedReview(status: StageStatus): boolean {
return typeof status === 'object' && 'NeedReview' in status;
}
export function collectUiTreeNodeTargets(
uiTrees: UITree[],
): UiTreeNodeTarget[] {
const targets: UiTreeNodeTarget[] = [];
const visit = (treeId: UIDesignImageId, node: UiNode) => {
targets.push({ treeId, node });
for (const child of node.children) visit(treeId, child);
};
for (const tree of uiTrees) visit(tree.src_ui_design, tree.root);
return targets;
}
export function getStageStatusOverview(
uiTrees: UITree[],
field: StageStatusField,
): StageStatusOverview {
const overview: StageStatusOverview = {
total: 0,
needsAttention: 0,
passed: 0,
blocked: 0,
};
for (const { node } of collectUiTreeNodeTargets(uiTrees)) {
const status = node.metadata[field];
overview.total += 1;
if (status === 'Passed') overview.passed += 1;
if (status === 'Blocked') overview.blocked += 1;
if (status === 'Blocked' || isNeedReview(status)) {
overview.needsAttention += 1;
}
}
return overview;
}
export function getStageStatusTargets(
uiTrees: UITree[],
field: StageStatusField,
matches: (status: StageStatus) => boolean,
): UiTreeNodeTarget[] {
return collectUiTreeNodeTargets(uiTrees).filter(({ node }) =>
matches(node.metadata[field]),
);
}
export function getNextUiTreeNodeTarget(
targets: UiTreeNodeTarget[],
previousNodeId: string | null,
): UiTreeNodeTarget | null {
if (targets.length === 0) return null;
const previousIndex = targets.findIndex(
({ node }) => node.id === previousNodeId,
);
return targets[(previousIndex + 1) % targets.length] ?? null;
}
@@ -2,6 +2,7 @@ import { Image as ImageIcon, Plus, ScanSearch } from 'lucide-react';
import type { UiEditorPageController } from '../useUiEditorPage';
import { ImportOverview } from './ImportOverview';
import { RecognitionOverview } from './RecognitionOverview';
import { UiTreePanel } from './UiTreePanel';
export function InputSidebar({
@@ -204,17 +205,25 @@ export function InputSidebar({
</div>
</section>
<ImportOverview
images={images}
sprites={sprites}
uiTrees={editor.state.ui_trees}
/>
{activeTool === 'components' ? (
<RecognitionOverview
uiTrees={editor.state.ui_trees}
onFocusNode={controller.focusNode}
/>
) : activeTool === 'input' ? (
<ImportOverview
images={images}
sprites={sprites}
uiTrees={editor.state.ui_trees}
/>
) : null}
{activeTool !== 'input' ? (
<UiTreePanel
treeId={activeImageId}
root={treeForActiveImage?.root ?? null}
selectedNodeId={selectedNodeId}
focusRequest={controller.focusRequest}
onSelectNode={controller.selectNode}
onInsertNode={(parentId) => {
controller.insertNode(parentId);
@@ -0,0 +1,145 @@
import { useEffect, useMemo, useState } from 'react';
import {
getNextUiTreeNodeTarget,
getStageStatusOverview,
getStageStatusTargets,
isNeedReview,
} from '../../../features/ui-editor/stageStatusOverview';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
import type { UITree } from '../../../features/ui-editor/types/UITree';
export function RecognitionOverview({
uiTrees,
onFocusNode,
}: {
uiTrees: UITree[];
onFocusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
}) {
const [lastAttentionNodeId, setLastAttentionNodeId] = useState<NodeId | null>(
null,
);
const [lastBlockedNodeId, setLastBlockedNodeId] = useState<NodeId | null>(
null,
);
const overview = useMemo(
() => getStageStatusOverview(uiTrees, 'layout_status'),
[uiTrees],
);
const attentionTargets = useMemo(
() =>
getStageStatusTargets(
uiTrees,
'layout_status',
(status) => status === 'Blocked' || isNeedReview(status),
),
[uiTrees],
);
const blockedTargets = useMemo(
() =>
getStageStatusTargets(
uiTrees,
'layout_status',
(status) => status === 'Blocked',
),
[uiTrees],
);
useEffect(() => {
setLastAttentionNodeId(null);
setLastBlockedNodeId(null);
}, [uiTrees]);
const focusNextAttention = () => {
const target = getNextUiTreeNodeTarget(
attentionTargets,
lastAttentionNodeId,
);
if (!target) return;
setLastAttentionNodeId(target.node.id);
onFocusNode(target.treeId, target.node.id);
};
const focusNextBlocked = () => {
const target = getNextUiTreeNodeTarget(blockedTargets, lastBlockedNodeId);
if (!target) return;
setLastBlockedNodeId(target.node.id);
onFocusNode(target.treeId, target.node.id);
};
return (
<section
className="mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3"
aria-label="识别概览"
>
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
Overview
</span>
<h2 className="m-0 text-sm font-semibold"></h2>
<div className="mt-3 grid grid-cols-2 gap-2">
<OverviewValue label="已识别 Node" value={overview.total} />
<OverviewAction
label="待用户检查"
value={overview.needsAttention}
tone="warning"
disabled={attentionTargets.length === 0}
onClick={focusNextAttention}
/>
<OverviewValue label="已通过" value={overview.passed} />
<OverviewAction
label="必须修复"
value={overview.blocked}
tone="danger"
disabled={blockedTargets.length === 0}
onClick={focusNextBlocked}
/>
</div>
</section>
);
}
function OverviewValue({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg border border-(--platform-subpanel-border) bg-white/45 p-2 text-center">
<strong className="block text-base font-semibold text-(--platform-text-strong)">
{value}
</strong>
<span className="block text-[10px] text-(--platform-text-soft)">
{label}
</span>
</div>
);
}
function OverviewAction({
label,
value,
tone,
disabled,
onClick,
}: {
label: string;
value: number;
tone: 'warning' | 'danger';
disabled: boolean;
onClick: () => void;
}) {
const toneClass =
tone === 'danger'
? 'border-red-200 bg-red-50 text-red-800 hover:border-red-300 hover:bg-red-100'
: 'border-amber-200 bg-amber-50 text-amber-900 hover:border-amber-300 hover:bg-amber-100';
return (
<button
type="button"
className={`rounded-lg border p-2 text-center transition disabled:cursor-default disabled:opacity-45 ${toneClass}`}
disabled={disabled}
onClick={onClick}
aria-label={`${label} ${value},定位下一项`}
>
<strong className="block text-base font-semibold">{value}</strong>
<span className="block text-[10px]">{label}</span>
</button>
);
}
@@ -1,20 +1,28 @@
import { ChevronDown, Layers3 } from 'lucide-react';
import { useEffect, useState, type MouseEvent as ReactMouseEvent } from 'react';
import {
Tree,
type MouseEvent as ReactMouseEvent,
useEffect,
useRef,
useState,
} from 'react';
import {
adjustMoveIndex,
type MoveHandler,
type NodeRendererProps,
Tree,
type TreeApi,
} from 'react-arborist';
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
import type { UiEditorNodeFocusRequest } from '../model';
type UiTreePanelProps = {
treeId: UIDesignImageId | null;
root: UiNode | null;
selectedNodeId: NodeId | null;
focusRequest: UiEditorNodeFocusRequest | null;
onSelectNode: (id: NodeId) => void;
onInsertNode: (parentId: NodeId) => void;
onInsertNodeAfter: (nodeId: NodeId) => void;
@@ -86,12 +94,14 @@ export function UiTreePanel({
treeId,
root,
selectedNodeId,
focusRequest,
onSelectNode,
onInsertNode,
onInsertNodeAfter,
onDeleteNode,
onMoveNode,
}: UiTreePanelProps) {
const treeApiRef = useRef<TreeApi<UiNode> | undefined>(undefined);
const [contextMenu, setContextMenu] = useState<{
nodeId: NodeId;
x: number;
@@ -109,6 +119,11 @@ export function UiTreePanel({
};
}, [contextMenu]);
useEffect(() => {
if (!focusRequest || focusRequest.treeId !== treeId) return;
void treeApiRef.current?.scrollTo(focusRequest.nodeId, 'center');
}, [focusRequest, treeId]);
const handleMove: MoveHandler<UiNode> = ({
dragIds,
parentId,
@@ -143,6 +158,7 @@ export function UiTreePanel({
<div className="mt-3 h-64 min-h-0">
{root ? (
<Tree<UiNode>
ref={treeApiRef}
data={[root]}
width="100%"
height={256}
@@ -23,6 +23,7 @@ import {
} from 'react';
import type { UiEditorPageController } from '../../useUiEditorPage';
import { findNodePageContext } from './nodeTransformGeometry';
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
@@ -34,6 +35,7 @@ export function PreviewWorkspace({
const { editor, activeImage, activeImageId, previewUrls } = controller;
const viewportElementRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<CanvasViewport>({ x: 0, y: 0, scale: 0.5 });
const handledFocusRequestIdRef = useRef<number | null>(null);
const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null);
const [viewport, setViewportState] = useState<CanvasViewport>(
viewportRef.current,
@@ -120,11 +122,53 @@ export function PreviewWorkspace({
useEffect(() => {
fitToCanvas();
controller.clearNodeSelection();
// This effect intentionally follows the active image, not every controller render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeImageId, fitToCanvas]);
useEffect(() => {
const request = controller.focusRequest;
if (
!request ||
request.requestId === handledFocusRequestIdRef.current ||
request.treeId !== activeImageId ||
!tree ||
!logicalSize
) {
return;
}
const context = findNodePageContext(tree.root, request.nodeId, {
min: [0, 0],
max: [logicalSize.width, logicalSize.height],
});
if (!context) return;
const element = viewportElementRef.current;
const size = {
width: element?.clientWidth || canvasSize.width,
height: element?.clientHeight || canvasSize.height,
};
handledFocusRequestIdRef.current = request.requestId;
setViewport(
fitViewportToBounds({
bounds: {
x: context.rect.min[0],
y: context.rect.min[1],
width: context.rect.max[0] - context.rect.min[0],
height: context.rect.max[1] - context.rect.min[1],
},
canvasSize: size,
}),
);
}, [
activeImageId,
canvasSize.height,
canvasSize.width,
controller.focusRequest,
logicalSize,
setViewport,
tree,
]);
useEffect(() => {
const isEditableTarget = (target: EventTarget | null) => {
const element = target instanceof HTMLElement ? target : null;
@@ -1,9 +1,17 @@
import type { NodeId } from '../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole';
import type { RemovalImpact } from '../../features/ui-editor/useUiEditorState';
export type UiEditorToolId = 'input' | 'components' | 'assets' | 'layout';
export type UiEditorImportKind = 'design-image' | 'sprite';
export type UiEditorNodeFocusRequest = {
treeId: UIDesignImageId;
nodeId: NodeId;
requestId: number;
};
export type PendingResourceRemoval = {
kind: UiEditorImportKind;
id: string;
@@ -0,0 +1,38 @@
import { useCallback, useRef, useState } from 'react';
import type { NodeId } from '../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
import type { UiEditorNodeFocusRequest } from './model';
export function useUiEditorNodeFocus({
activateImage,
clearSpriteSelection,
selectNode,
}: {
activateImage: (id: UIDesignImageId) => void;
clearSpriteSelection: () => void;
selectNode: (id: NodeId) => void;
}) {
const nextRequestIdRef = useRef(0);
const [focusRequest, setFocusRequest] =
useState<UiEditorNodeFocusRequest | null>(null);
const focusNode = useCallback(
(treeId: UIDesignImageId, nodeId: NodeId) => {
activateImage(treeId);
clearSpriteSelection();
selectNode(nodeId);
nextRequestIdRef.current += 1;
setFocusRequest({
treeId,
nodeId,
requestId: nextRequestIdRef.current,
});
},
[activateImage, clearSpriteSelection, selectNode],
);
const clearFocusRequest = useCallback(() => setFocusRequest(null), []);
return { focusRequest, focusNode, clearFocusRequest };
}
@@ -2,6 +2,7 @@ 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,
prepareSpriteAssetBatch,
@@ -14,7 +15,6 @@ import {
validateLayoutReviewPrerequisites,
} from '../../features/ui-editor/prerequisites';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import { applyBindingResult } from '../../features/ui-editor/binding';
import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
import type { Component } from '../../features/ui-editor/types/Component';
import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO';
@@ -44,6 +44,7 @@ import {
uiEditorOperationError,
type UiEditorToolId,
} from './model';
import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
const ASSET_BATCH_SIZE = 5;
@@ -62,6 +63,11 @@ export function useUiEditorPage(
const [selectedSpriteId, setSelectedSpriteId] =
useState<SpriteAssetId | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<NodeId | null>(null);
const { focusRequest, focusNode, clearFocusRequest } = useUiEditorNodeFocus({
activateImage: setActiveImageId,
clearSpriteSelection: () => setSelectedSpriteId(null),
selectNode: setSelectedNodeId,
});
const [keepChildrenUnchanged, setKeepChildrenUnchanged] = useState(false);
const [importKind, setImportKind] = useState<UiEditorImportKind | null>(null);
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
@@ -82,6 +88,7 @@ export function useUiEditorPage(
const [mergeStatus, setMergeStatus] = useState<string | null>(null);
const [isBinding, setIsBinding] = useState(false);
const [bindingStatus, setBindingStatus] = useState<string | null>(null);
const { replaceState } = editor;
useEffect(() => {
if (!resourceId) {
@@ -94,7 +101,7 @@ export function useUiEditorPage(
.load(resourceId)
.then((state) => {
if (!cancelled) {
editor.replaceState(state);
replaceState(state);
}
})
.catch((cause: unknown) => {
@@ -110,7 +117,7 @@ export function useUiEditorPage(
return () => {
cancelled = true;
};
}, [editor.replaceState, resourceId, stateStore]);
}, [replaceState, resourceId, stateStore]);
const images = editor.state.ui_design_images;
const sprites = editor.state.sprite_assets;
@@ -289,6 +296,7 @@ export function useUiEditorPage(
}
function clearState() {
clearFocusRequest();
editor.clearState();
setImageOrder([]);
setActiveImageId(null);
@@ -300,22 +308,26 @@ export function useUiEditorPage(
}
function selectTool(tool: UiEditorToolId) {
clearFocusRequest();
setActiveTool(tool);
setIssues(null);
}
function selectDesignImage(id: UIDesignImageId) {
clearFocusRequest();
setActiveImageId(id);
setSelectedSpriteId(null);
setSelectedNodeId(null);
}
function selectNode(id: NodeId) {
clearFocusRequest();
setSelectedSpriteId(null);
setSelectedNodeId(id);
}
function clearNodeSelection() {
clearFocusRequest();
setSelectedNodeId(null);
}
@@ -441,6 +453,7 @@ export function useUiEditorPage(
}
function selectSprite(id: SpriteAssetId) {
clearFocusRequest();
setSelectedNodeId(null);
setSelectedSpriteId(id);
}
@@ -562,9 +575,7 @@ export function useUiEditorPage(
index < allSpriteIds.length;
index += ASSET_BATCH_SIZE
) {
batches.push(
allSpriteIds.slice(index, index + ASSET_BATCH_SIZE),
);
batches.push(allSpriteIds.slice(index, index + ASSET_BATCH_SIZE));
}
if (batches.length === 0) batches.push([]);
let current = snapshot;
@@ -578,7 +589,9 @@ export function useUiEditorPage(
current = applyBindingResult(current, result);
editor.replaceState(current);
}
setBindingStatus(`组件绑定完成(${batches.length}/${batches.length})。`);
setBindingStatus(
`组件绑定完成(${batches.length}/${batches.length})。`,
);
});
} catch (cause) {
setBindingStatus(cause instanceof Error ? cause.message : String(cause));
@@ -598,6 +611,7 @@ export function useUiEditorPage(
activeImageId,
selectedSpriteId,
selectedNodeId,
focusRequest,
importKind,
previewUrls,
status,
@@ -619,6 +633,7 @@ export function useUiEditorPage(
selectDesignImage,
selectSprite,
selectNode,
focusNode,
clearNodeSelection,
setNodeTransform,
updateNodeTransform,
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import {
collectUiTreeNodeTargets,
getNextUiTreeNodeTarget,
getStageStatusOverview,
getStageStatusTargets,
isNeedReview,
} from '../src/features/ui-editor/stageStatusOverview';
import type { Node } from '../src/features/ui-editor/types/Node';
import type { StageStatus } from '../src/features/ui-editor/types/StageStatus';
import type { UITree } from '../src/features/ui-editor/types/UITree';
function node(id: string, status: StageStatus, children: Node[] = []): Node {
return {
id,
transform: {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
},
metadata: {
name: id,
description: '',
layout_status: status,
components_status: 'Pending',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
},
components: [],
children,
};
}
const trees: UITree[] = [
{
src_ui_design: 'page-a',
root: node('root-a', 'Passed', [
node('review-a', { NeedReview: '请检查' }),
node('blocked-a', 'Blocked'),
]),
},
{
src_ui_design: 'page-b',
root: node('root-b', 'Pending', [node('passed-b', 'Passed')]),
},
];
describe('stageStatusOverview', () => {
it('counts all nodes and keeps the agreed overlapping status totals', () => {
expect(getStageStatusOverview(trees, 'layout_status')).toEqual({
total: 5,
needsAttention: 2,
passed: 2,
blocked: 1,
});
});
it('uses tree order and preorder traversal for navigation targets', () => {
expect(collectUiTreeNodeTargets(trees).map(({ node }) => node.id)).toEqual([
'root-a',
'review-a',
'blocked-a',
'root-b',
'passed-b',
]);
});
it('cycles from the previous target and restarts when it disappeared', () => {
const targets = getStageStatusTargets(
trees,
'layout_status',
(status) => status === 'Blocked' || isNeedReview(status),
);
expect(getNextUiTreeNodeTarget(targets, null)?.node.id).toBe('review-a');
expect(getNextUiTreeNodeTarget(targets, 'review-a')?.node.id).toBe(
'blocked-a',
);
expect(getNextUiTreeNodeTarget(targets, 'blocked-a')?.node.id).toBe(
'review-a',
);
expect(getNextUiTreeNodeTarget(targets, 'removed')?.node.id).toBe(
'review-a',
);
});
});