修复UI编辑器阶段概览与状态提示

按阶段展示导入、识别和绑定概览

在Inspector显示状态原因并单独高亮对应状态字段

为阻塞状态保留识别失败原因并移除待处理定位行为
This commit is contained in:
2026-08-19 11:43:29 +08:00
parent fbc1ce0e59
commit fb5c4a002b
13 changed files with 239 additions and 65 deletions
@@ -221,13 +221,16 @@ fn convert_node(
let source_min_px = Vector2::new(source.global_pos_x_px as f32, source.global_pos_y_px as f32);
let source_size_node_px = Vector2::new(source.width_px as f32, source.height_px as f32);
let source_max_px = source_min_px + source_size_node_px;
let mut blocked = source.width_px == 0 || source.height_px == 0;
let mut blocked_reasons = Vec::new();
if source.width_px == 0 || source.height_px == 0 {
blocked_reasons.push("节点宽度或高度为 0");
}
if source_min_px.x < 0.0
|| source_min_px.y < 0.0
|| source_max_px.x > image_size_px.x
|| source_max_px.y > image_size_px.y
{
blocked = true;
blocked_reasons.push("节点像素范围超出界面图边界");
}
let ppu = image.pixels_per_unit.get();
if !ppu.is_finite() || ppu <= 0.0 {
@@ -245,23 +248,23 @@ fn convert_node(
|| target_rect.max().x > parent_rect.max().x
|| target_rect.max().y > parent_rect.max().y
{
blocked = true;
blocked_reasons.push("节点布局范围超出父节点边界");
}
if anchor_ranges(&source.local_anchor).is_err() {
blocked = true;
blocked_reasons.push("锚点范围无效");
}
if source.name.trim().is_empty() {
blocked = true;
blocked_reasons.push("节点名称为空");
}
let mut transform = Transform::new(anchor_min, anchor_max, Vector2::zeros(), Vector2::zeros());
transform.set_resolved_rect(&parent_rect, target_rect);
let status = if blocked {
StageStatus::Blocked
} else {
let status = if blocked_reasons.is_empty() {
match &source.confidence {
Confidence::Confident => StageStatus::Passed,
Confidence::UnSure(reason) => StageStatus::NeedReview(reason.clone()),
}
} else {
StageStatus::Blocked(blocked_reasons.join(""))
};
let children = source
.children
@@ -440,6 +443,33 @@ mod tests {
assert_eq!(converted.metadata.components_status, StageStatus::Passed);
}
#[test]
fn conversion_records_every_blocking_reason_for_the_inspector() {
let image_id = UIDesignImageId::new("page").expect("valid image id");
let image = test_image();
let root_rect = UIRect::new(Point2::origin(), image_layout_size(&image).unwrap());
let mut node = test_node();
node.width_px = 0;
node.global_pos_x_px = 1001;
node.local_anchor = Anchor::CustomMinMax(CustomMinMaxAnchor {
min_x: 0.9,
min_y: 0.0,
max_x: 0.1,
max_y: 1.0,
});
node.name = " ".to_string();
let converted = convert_node(&node, &image_id, &image, root_rect)
.expect("blocking recognition node still materializes");
assert_eq!(
converted.metadata.layout_status,
StageStatus::Blocked(
"节点宽度或高度为 0;节点像素范围超出界面图边界;节点布局范围超出父节点边界;锚点范围无效;节点名称为空"
.to_string(),
),
);
}
#[test]
fn tree_validation_requires_exactly_one_tree_per_context_image() {
let page = UIDesignImageId::new("page").expect("valid image id");
@@ -30,7 +30,7 @@ pub enum StageStatus {
Pending,
Passed,
NeedReview(String), // reason inside
Blocked,
Blocked(String), // reason inside
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
@@ -1,5 +1,6 @@
import {
collectUiTreeNodeTargets,
isBlocked,
isNeedReview,
type UiTreeNodeTarget,
} from './stageStatusOverview';
@@ -84,8 +85,8 @@ export function getBindingOverview(
for (const { node } of collectUiTreeNodeTargets(uiTrees)) {
const status = node.metadata.components_status;
if (status === 'Blocked') overview.blocked += 1;
if (status === 'Blocked' || isNeedReview(status)) {
if (isBlocked(status)) overview.blocked += 1;
if (isBlocked(status) || isNeedReview(status)) {
overview.needsAttention += 1;
}
for (const component of node.components) {
@@ -104,9 +105,9 @@ export function nodeHasPendingBinding(target: UiTreeNodeTarget): boolean {
export function nodeNeedsComponentReview(target: UiTreeNodeTarget): boolean {
const status = target.node.metadata.components_status;
return status === 'Blocked' || isNeedReview(status);
return isBlocked(status) || isNeedReview(status);
}
export function nodeHasBlockedComponents(target: UiTreeNodeTarget): boolean {
return target.node.metadata.components_status === 'Blocked';
return isBlocked(target.node.metadata.components_status);
}
@@ -25,6 +25,10 @@ export function isNeedReview(status: StageStatus): boolean {
return typeof status === 'object' && 'NeedReview' in status;
}
export function isBlocked(status: StageStatus): boolean {
return typeof status === 'object' && 'Blocked' in status;
}
export function collectUiTreeNodeTargets(
uiTrees: UITree[],
): UiTreeNodeTarget[] {
@@ -54,8 +58,8 @@ export function getStageStatusOverview(
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)) {
if (isBlocked(status)) overview.blocked += 1;
if (isBlocked(status) || isNeedReview(status)) {
overview.needsAttention += 1;
}
}
@@ -1,3 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type StageStatus = "Pending" | "Passed" | { "NeedReview": string } | "Blocked";
export type StageStatus =
'Pending' | 'Passed' | { NeedReview: string } | { Blocked: string };
@@ -3,7 +3,6 @@ import { useMemo } from 'react';
import {
getBindingOverview,
nodeHasBlockedComponents,
nodeHasPendingBinding,
nodeNeedsComponentReview,
} from '../../../features/ui-editor/bindingOverview';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
@@ -15,29 +14,24 @@ import { useUiTreeNodeCycle } from '../useUiTreeNodeCycle';
export function BindingOverview({
uiTrees,
sprites,
onFocusNode,
onFocusStatusNode,
}: {
uiTrees: UITree[];
sprites: Record<string, SpriteAsset>;
onFocusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
onFocusStatusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
}) {
const overview = useMemo(
() => getBindingOverview(uiTrees, sprites),
[sprites, uiTrees],
);
const pendingCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode,
matches: nodeHasPendingBinding,
});
const attentionCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode,
onFocusNode: onFocusStatusNode,
matches: nodeNeedsComponentReview,
});
const blockedCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode,
onFocusNode: onFocusStatusNode,
matches: nodeHasBlockedComponents,
});
@@ -57,13 +51,7 @@ export function BindingOverview({
/>
<OverviewValue label="素材槽位" value={overview.assetSlots} />
<OverviewValue label="已绑定" value={overview.boundSlots} />
<OverviewAction
label="待处理"
value={overview.pendingSlots}
tone="warning"
disabled={overview.pendingSlots === 0}
onClick={pendingCycle.focusNext}
/>
<OverviewValue label="待处理" value={overview.pendingSlots} />
<OverviewAction
label="待用户检查"
value={overview.needsAttention}
@@ -6,7 +6,9 @@ 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 { UiEditorPageController } from '../useUiEditorPage';
import { BindingOverview } from './BindingOverview';
import { ImportOverview } from './ImportOverview';
import { RecognitionOverview } from './RecognitionOverview';
import { UiTreePanel } from './UiTreePanel';
const SUPER_ROOT_ID = '__ui-editor-super-root__';
@@ -19,6 +21,7 @@ export function InputSidebar({
const {
projectPath,
editor,
activeStep,
imageOrder,
activeImageId,
previewUrls,
@@ -47,11 +50,18 @@ export function InputSidebar({
if (editor.state.ui_trees.length === 0) return null;
return {
id: SUPER_ROOT_ID,
transform: {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
layout: {
transform: {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
},
custom_minimum_size: [0, 0],
size_flags_horizontal: 1,
size_flags_vertical: 1,
size_flags_stretch_ratio: 1,
container: 'None',
},
metadata: {
name: 'UI Trees',
@@ -304,11 +314,30 @@ export function InputSidebar({
</div>
</section>
<ImportOverview
images={images}
sprites={sprites}
uiTrees={editor.state.ui_trees}
/>
{activeStep === 'structure-recognition' ? (
<RecognitionOverview
uiTrees={editor.state.ui_trees}
onFocusStatusNode={(treeId, nodeId) => {
controller.focusNode(treeId, nodeId);
controller.highlightStatusField('layout_status');
}}
/>
) : activeStep === 'visual-binding' ? (
<BindingOverview
uiTrees={editor.state.ui_trees}
sprites={sprites}
onFocusStatusNode={(treeId, nodeId) => {
controller.focusNode(treeId, nodeId);
controller.highlightStatusField('components_status');
}}
/>
) : (
<ImportOverview
images={images}
sprites={sprites}
uiTrees={editor.state.ui_trees}
/>
)}
<UiTreePanel
root={superRoot}
@@ -8,6 +8,7 @@ import type {
import { FontSamplePreview } from '../../../../features/ui-editor/components/FontSamplePreview';
import { SpriteImagePreview } from '../../../../features/ui-editor/components/SpriteImagePreview';
import type { StageStatusField } from '../../../../features/ui-editor/stageStatusOverview';
import type { UIDesignImageId } from '../../../../features/ui-editor/types/UIDesignImageId';
import type { UIDesignImageRole } from '../../../../features/ui-editor/types/UIDesignImageRole';
import { uiEditorPrivateFontFamily } from '../../../../features/ui-editor/useUiEditorFontFaces';
@@ -98,6 +99,7 @@ export function InspectorSidebar({
keepChildrenUnchanged={controller.keepChildrenUnchanged}
onKeepChildrenUnchangedChange={controller.setKeepChildrenUnchanged}
onMetadataChange={controller.setNodeMetadata}
highlightedStatusField={controller.highlightedStatusField}
onTransformChange={controller.setNodeTransform}
onLayoutChange={controller.setNodeLayout}
sprites={controller.sprites}
@@ -108,6 +110,11 @@ export function InspectorSidebar({
onInsertComponent={controller.insertNodeComponent}
onDeleteComponent={controller.deleteNodeComponent}
onMoveComponent={controller.moveNodeComponent}
onDeleteNode={() => controller.deleteNode(view.node.id)}
deleteDisabled={
controller.editor.isLocked ||
view.node.id === controller.treeForActiveImage?.root.id
}
/>
);
break;
@@ -268,6 +275,7 @@ function NodeInspector({
keepChildrenUnchanged,
onKeepChildrenUnchangedChange,
onMetadataChange,
highlightedStatusField,
onTransformChange,
onLayoutChange,
sprites,
@@ -278,6 +286,8 @@ function NodeInspector({
onInsertComponent,
onDeleteComponent,
onMoveComponent,
onDeleteNode,
deleteDisabled,
}: {
node: SelectedNode;
parentSize: UiEditorPageController['selectedNodeParentSize'];
@@ -290,6 +300,7 @@ function NodeInspector({
keepChildrenUnchanged: boolean;
onKeepChildrenUnchangedChange: (value: boolean) => void;
onMetadataChange: UiEditorPageController['setNodeMetadata'];
highlightedStatusField: StageStatusField | null;
onTransformChange: UiEditorPageController['setNodeTransform'];
onLayoutChange: UiEditorPageController['setNodeLayout'];
sprites: UiEditorPageController['sprites'];
@@ -300,6 +311,8 @@ function NodeInspector({
onInsertComponent: UiEditorPageController['insertNodeComponent'];
onDeleteComponent: UiEditorPageController['deleteNodeComponent'];
onMoveComponent: UiEditorPageController['moveNodeComponent'];
onDeleteNode: () => void;
deleteDisabled: boolean;
}) {
const inspectorReadOnly = useInspectorReadOnly();
const isReadOnly = readOnly || inspectorReadOnly;
@@ -388,11 +401,12 @@ function NodeInspector({
{node.components.length}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<NodeStageSelect
label="布局状态"
value={node.metadata.layout_status}
disabled={isReadOnly}
highlighted={highlightedStatusField === 'layout_status'}
onChange={(layout_status) => {
if (!isReadOnly) onMetadataChange({ layout_status });
}}
@@ -401,6 +415,7 @@ function NodeInspector({
label="组件状态"
value={node.metadata.components_status}
disabled={isReadOnly}
highlighted={highlightedStatusField === 'components_status'}
onChange={(components_status) => {
if (!isReadOnly) onMetadataChange({ components_status });
}}
@@ -467,6 +482,13 @@ function NodeInspector({
onMoveComponent={onMoveComponent}
/>
<ResourceId value={node.id} />
<DeleteResourceButton
label="删除节点及子节点"
disabled={deleteDisabled || isReadOnly}
onClick={() => {
if (!deleteDisabled && !isReadOnly) onDeleteNode();
}}
/>
</div>
);
}
@@ -757,11 +779,13 @@ function NodeStageSelect({
label,
value,
disabled,
highlighted,
onChange,
}: {
label: string;
value: SelectedNode['metadata']['layout_status'];
disabled: boolean;
highlighted: boolean;
onChange: (value: SelectedNode['metadata']['layout_status']) => void;
}) {
const readOnly = useInspectorReadOnly();
@@ -771,8 +795,20 @@ function NodeStageSelect({
: 'NeedReview' in value
? 'NeedReview'
: 'Blocked';
const reason =
typeof value === 'object'
? 'NeedReview' in value
? value.NeedReview
: value.Blocked
: null;
return (
<label className="text-[10px] text-(--platform-text-soft)">
<label
className={`block rounded-lg p-2 text-[10px] text-(--platform-text-soft) transition ${
highlighted
? 'border-2 border-orange-400 bg-orange-50 shadow-[0_0_0_3px_rgb(251_146_60_/_0.2)]'
: 'border border-transparent'
}`}
>
{label}
<select
className={INSPECTOR_INPUT_CLASS_NAME}
@@ -781,22 +817,22 @@ function NodeStageSelect({
onChange={(event) => {
if (readOnly) return;
const next = event.target.value;
onChange(
next === 'NeedReview'
? { NeedReview: '待审' }
: next === 'Blocked'
? 'Blocked'
: next === 'Pending'
? 'Pending'
: 'Passed',
);
if (next === 'NeedReview' || next === 'Blocked') return;
onChange(next === 'Pending' ? 'Pending' : 'Passed');
}}
>
<option value="Pending"></option>
<option value="Passed"></option>
<option value="NeedReview"></option>
<option value="Blocked"></option>
{kind === 'NeedReview' ? (
<option value="NeedReview"></option>
) : null}
{kind === 'Blocked' ? <option value="Blocked"></option> : null}
</select>
{reason ? (
<span className="mt-1 block rounded-md bg-black/4 px-2 py-1.5 leading-5 text-(--platform-text-strong)">
{reason}
</span>
) : null}
</label>
);
}
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import {
getStageStatusOverview,
isBlocked,
isNeedReview,
} from '../../../features/ui-editor/stageStatusOverview';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
@@ -11,10 +12,10 @@ import { useUiTreeNodeCycle } from '../useUiTreeNodeCycle';
export function RecognitionOverview({
uiTrees,
onFocusNode,
onFocusStatusNode,
}: {
uiTrees: UITree[];
onFocusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
onFocusStatusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
}) {
const overview = useMemo(
() => getStageStatusOverview(uiTrees, 'layout_status'),
@@ -22,16 +23,16 @@ export function RecognitionOverview({
);
const attentionCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode,
onFocusNode: onFocusStatusNode,
matches: ({ node }) => {
const status = node.metadata.layout_status;
return status === 'Blocked' || isNeedReview(status);
return isBlocked(status) || isNeedReview(status);
},
});
const blockedCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode,
matches: ({ node }) => node.metadata.layout_status === 'Blocked',
onFocusNode: onFocusStatusNode,
matches: ({ node }) => isBlocked(node.metadata.layout_status),
});
return (
@@ -10,6 +10,7 @@ import {
} from '../../features/ui-editor/importAdapter';
import { applyMergeResult } from '../../features/ui-editor/merge';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
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';
@@ -131,6 +132,8 @@ export function useUiEditorPage(
null,
);
const [selectedNodeId, setSelectedNodeId] = useState<NodeId | null>(null);
const [highlightedStatusField, setHighlightedStatusField] =
useState<StageStatusField | null>(null);
// NodeId 在 State 内全局唯一;隐藏状态直接记录节点 ID,跨树移动时随节点保留。
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<NodeId>>(
() => new Set(),
@@ -623,12 +626,18 @@ export function useUiEditorPage(
setSelectedSpriteId(null);
setSelectedFontId(null);
setSelectedNodeId(null);
setHighlightedStatusField(null);
}
function selectNode(id: NodeId) {
setSelectedSpriteId(null);
setSelectedFontId(null);
setSelectedNodeId(id);
setHighlightedStatusField(null);
}
function highlightStatusField(field: StageStatusField) {
setHighlightedStatusField(field);
}
function clearNodeSelection() {
@@ -658,6 +667,13 @@ export function useUiEditorPage(
function setNodeMetadata(patch: NodeMetadataPatch) {
if (!activeImageId || !selectedNodeId) return;
const result = editor.setNodeMetadata(activeImageId, selectedNodeId, patch);
if (
result.ok &&
(patch.layout_status !== undefined ||
patch.components_status !== undefined)
) {
setHighlightedStatusField(null);
}
if (!result.ok) setStatus('节点信息更新失败。');
return result;
}
@@ -938,6 +954,7 @@ export function useUiEditorPage(
selectedSpriteId,
selectedFontId,
selectedNodeId,
highlightedStatusField,
focusRequest,
importKind,
previewUrls,
@@ -984,6 +1001,7 @@ export function useUiEditorPage(
selectNode,
focusNode,
clearNodeSelection,
highlightStatusField,
setNodeTransform,
updateNodeTransform,
setNodeMetadata,
@@ -96,7 +96,7 @@ const trees: UITree[] = [
'Passed',
[
node('review', [image(null)], { NeedReview: '确认素材' }),
node('blocked', [text('SystemFont')], 'Blocked'),
node('blocked', [text('SystemFont')], { Blocked: '素材绑定失败' }),
],
),
},
@@ -5,6 +5,7 @@ import {
getNextUiTreeNodeTarget,
getStageStatusOverview,
getStageStatusTargets,
isBlocked,
isNeedReview,
} from '../src/features/ui-editor/stageStatusOverview';
import type { Node } from '../src/features/ui-editor/types/Node';
@@ -47,7 +48,7 @@ const trees: UITree[] = [
src_ui_design: 'page-a',
root: node('root-a', 'Passed', [
node('review-a', { NeedReview: '请检查' }),
node('blocked-a', 'Blocked'),
node('blocked-a', { Blocked: '节点范围越界' }),
]),
},
{
@@ -80,7 +81,7 @@ describe('stageStatusOverview', () => {
const targets = getStageStatusTargets(
trees,
'layout_status',
(status) => status === 'Blocked' || isNeedReview(status),
(status) => isBlocked(status) || isNeedReview(status),
);
expect(getNextUiTreeNodeTarget(targets, null)?.node.id).toBe('review-a');
@@ -102,6 +102,31 @@ describe('UiEditorPage', () => {
expect(screen.queryByText('Pause Dialog')).toBeNull();
});
it('renders the overview that belongs to the active workflow stage', () => {
render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' }));
expect(screen.getByRole('heading', { name: '导入概览' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /识别界面结构/ }));
fireEvent.click(screen.getByRole('button', { name: '仍然继续' }));
expect(screen.getByRole('heading', { name: '识别概览' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /绑定视觉素材/ }));
fireEvent.click(screen.getByRole('button', { name: '仍然继续' }));
expect(screen.getByRole('heading', { name: '绑定概览' })).toBeTruthy();
});
it('keeps the pending binding count informational instead of navigable', () => {
render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' }));
fireEvent.click(screen.getByRole('button', { name: /绑定视觉素材/ }));
fireEvent.click(screen.getByRole('button', { name: '仍然继续' }));
expect(
screen.queryByRole('button', { name: /待处理.*定位下一项/ }),
).toBeNull();
});
it('switches tools freely without inventing completed workflow state', () => {
render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' }));
@@ -253,6 +278,46 @@ describe('UiEditorPage', () => {
expect(result.current.hiddenNodeIds.size).toBe(0);
});
it('keeps Inspector status highlighting separate from node navigation', () => {
const { result } = renderHook(() => useUiEditorPage('/tmp/ui-editor'));
act(() => {
result.current.editor.addDesignImages([
{
id: 'page',
image: {
metadata: {
name: 'Page',
description: '',
role: 'Page',
slave_to: null,
},
path: 'assets/page.png',
pixel_size: [320, 180],
pixels_per_unit: 1,
},
},
]);
result.current.selectDesignImage('page');
});
const rootId = result.current.treeForActiveImage!.root.id;
let otherNodeId: string | undefined;
act(() => {
otherNodeId = result.current.insertNode(rootId)?.value;
result.current.selectNode(rootId);
result.current.highlightStatusField('layout_status');
});
expect(result.current.highlightedStatusField).toBe('layout_status');
act(() => result.current.selectNode(otherNodeId!));
expect(result.current.highlightedStatusField).toBeNull();
act(() => {
result.current.highlightStatusField('components_status');
result.current.setNodeMetadata({ components_status: 'Passed' });
});
expect(result.current.highlightedStatusField).toBeNull();
});
it('shares exclusive child visibility between tree actions and final preview state', () => {
const { result } = renderHook(() => useUiEditorPage('/tmp/ui-editor'));
act(() => {