修复UI编辑器状态高亮循环定位

高亮状态绑定到具体节点与状态字段

循环定位时重建状态提示并滚入Inspector可视区

补充多节点连续定位的回归测试
This commit is contained in:
2026-08-19 19:16:50 +08:00
parent 1a7a03234a
commit 67c050ee5d
4 changed files with 64 additions and 11 deletions
@@ -1,14 +1,17 @@
import { Boxes, LockKeyhole, Trash2, Unlock } from 'lucide-react';
import type {
InputHTMLAttributes,
import {
type InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
TextareaHTMLAttributes,
useEffect,
useRef,
} from 'react';
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 { 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 { uiEditorPrivateFontFamily } from '../../../../features/ui-editor/useUiEditorFontFaces';
@@ -307,6 +310,7 @@ function NodeInspector({
onKeepChildrenUnchangedChange: (value: boolean) => void;
onMetadataChange: UiEditorInspectorProjection['setNodeMetadata'];
highlightedStatusField: {
nodeId: NodeId;
field: StageStatusField;
requestId: number;
} | null;
@@ -412,11 +416,13 @@ function NodeInspector({
</div>
<div className="space-y-2">
<NodeStageSelect
key={`layout-status-${node.id}-${highlightedStatusField?.requestId ?? 'idle'}`}
label="布局状态"
value={node.metadata.layout_status}
disabled={isReadOnly}
highlight={
highlightedStatusField?.field === 'layout_status'
highlightedStatusField?.nodeId === node.id &&
highlightedStatusField.field === 'layout_status'
? highlightedStatusField
: null
}
@@ -425,11 +431,13 @@ function NodeInspector({
}}
/>
<NodeStageSelect
key={`components-status-${node.id}-${highlightedStatusField?.requestId ?? 'idle'}`}
label="组件状态"
value={node.metadata.components_status}
disabled={isReadOnly}
highlight={
highlightedStatusField?.field === 'components_status'
highlightedStatusField?.nodeId === node.id &&
highlightedStatusField.field === 'components_status'
? highlightedStatusField
: null
}
@@ -805,6 +813,7 @@ function NodeStageSelect({
onChange: (value: SelectedNode['metadata']['layout_status']) => void;
}) {
const readOnly = useInspectorReadOnly();
const statusRowRef = useRef<HTMLLabelElement>(null);
const kind =
typeof value === 'string'
? value
@@ -818,9 +827,18 @@ function NodeStageSelect({
: value.Blocked
: null;
const attentionTone = kind === 'Blocked' ? 'blocked' : 'review';
useEffect(() => {
if (!highlight) return;
statusRowRef.current?.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
}, [highlight]);
return (
<label
key={highlight?.requestId}
ref={statusRowRef}
data-status-attention={highlight ? attentionTone : undefined}
data-status-attention-request={highlight?.requestId}
className={`block rounded-lg p-2 text-[10px] text-(--platform-text-soft) transition ${
@@ -183,7 +183,7 @@ export default function UiEditorPage({
uiTrees={session.input.uiTrees}
onFocusStatusNode={(treeId, nodeId) => {
session.input.focusNode(treeId, nodeId);
session.input.highlightStatusField('layout_status');
session.input.highlightStatusField(nodeId, 'layout_status');
}}
/>
) : session.input.activeStep === 'visual-binding' ? (
@@ -192,7 +192,7 @@ export default function UiEditorPage({
sprites={session.input.sprites}
onFocusStatusNode={(treeId, nodeId) => {
session.input.focusNode(treeId, nodeId);
session.input.highlightStatusField('components_status');
session.input.highlightStatusField(nodeId, 'components_status');
}}
/>
) : (
@@ -64,6 +64,7 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
const ASSET_BATCH_SIZE = 5;
type StatusFieldHighlight = {
nodeId: NodeId;
field: StageStatusField;
requestId: number;
};
@@ -684,8 +685,9 @@ export function useUiEditorSession(
setHighlightedStatusField(null);
}
function highlightStatusField(field: StageStatusField) {
function highlightStatusField(nodeId: NodeId, field: StageStatusField) {
setHighlightedStatusField((current) => ({
nodeId,
field,
requestId: (current?.requestId ?? 0) + 1,
}));
@@ -424,15 +424,19 @@ describe('UiEditorPage', () => {
act(() => {
otherNodeId = result.current.input.insertNode(rootId, 'page')?.value;
result.current.input.selectNode(rootId);
result.current.input.highlightStatusField('layout_status');
result.current.input.highlightStatusField(rootId, 'layout_status');
});
expect(result.current.inspector.highlightedStatusField).toMatchObject({
nodeId: rootId,
field: 'layout_status',
requestId: 1,
});
act(() => result.current.input.highlightStatusField('layout_status'));
act(() =>
result.current.input.highlightStatusField(rootId, 'layout_status'),
);
expect(result.current.inspector.highlightedStatusField).toMatchObject({
nodeId: rootId,
field: 'layout_status',
requestId: 2,
});
@@ -441,7 +445,10 @@ describe('UiEditorPage', () => {
expect(result.current.inspector.highlightedStatusField).toBeNull();
act(() => {
result.current.input.highlightStatusField('components_status');
result.current.input.highlightStatusField(
otherNodeId!,
'components_status',
);
result.current.inspector.setNodeMetadata({
components_status: 'NoProblem',
});
@@ -449,6 +456,32 @@ describe('UiEditorPage', () => {
expect(result.current.inspector.highlightedStatusField).toBeNull();
});
it('retargets the status highlight to every overview navigation result', async () => {
const { result } = await renderLoadedSession(stateWithPages(['page']));
const rootId = 'page-root';
let otherNodeId: string | undefined;
act(() => {
otherNodeId = result.current.input.insertNode(rootId, 'page')?.value;
result.current.input.focusNode('page', rootId);
result.current.input.highlightStatusField(rootId, 'layout_status');
});
expect(result.current.inspector.highlightedStatusField).toMatchObject({
nodeId: rootId,
field: 'layout_status',
requestId: 1,
});
act(() => {
result.current.input.focusNode('page', otherNodeId!);
result.current.input.highlightStatusField(otherNodeId!, 'layout_status');
});
expect(result.current.inspector.highlightedStatusField).toMatchObject({
nodeId: otherNodeId,
field: 'layout_status',
requestId: 2,
});
});
it('shares exclusive child visibility between tree actions and final preview state', async () => {
const { result } = await renderLoadedSession(stateWithPages(['page']));
const rootId = 'page-root';