修复UI编辑器阶段概览与状态提示
按阶段展示导入、识别和绑定概览 在Inspector显示状态原因并单独高亮对应状态字段 为阻塞状态保留识别失败原因并移除待处理定位行为
This commit is contained in:
@@ -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}
|
||||
|
||||
+49
-13
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user