新增UI编辑器识别概览与节点定位
递归统计布局识别状态并显示识别概览。 支持待检查和阻塞节点的循环跨界面聚焦。 抽取通用状态查询与一次性聚焦请求并补充测试和约定。
This commit is contained in:
@@ -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}
|
||||
|
||||
+45
-1
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user