新增UI编辑器绑定概览与循环定位

统计图像和文本组件的素材槽位绑定状态。
新增绑定待处理、待检查和必须修复的循环定位。
复用识别概览的前序候选查询与导航逻辑。
This commit is contained in:
2026-08-17 16:45:32 +08:00
parent e0c034a49b
commit cb96e6c6cd
7 changed files with 451 additions and 53 deletions
@@ -0,0 +1,111 @@
import {
collectUiTreeNodeTargets,
isNeedReview,
type UiTreeNodeTarget,
} from './stageStatusOverview';
import type { Component } from './types/Component';
import type { SpriteAsset } from './types/SpriteAsset';
import type { UITree } from './types/UITree';
export type ComponentBindingCounts = {
componentsNeedingAssets: number;
assetSlots: number;
boundSlots: number;
pendingSlots: number;
};
export type BindingOverview = ComponentBindingCounts & {
needsAttention: number;
blocked: number;
independentAssets: number;
};
const EMPTY_COMPONENT_BINDING_COUNTS: ComponentBindingCounts = {
componentsNeedingAssets: 0,
assetSlots: 0,
boundSlots: 0,
pendingSlots: 0,
};
function countRequiredAssetSlot(isBound: boolean): ComponentBindingCounts {
return {
componentsNeedingAssets: 1,
assetSlots: 1,
boundSlots: isBound ? 1 : 0,
pendingSlots: isBound ? 0 : 1,
};
}
export function getImageBindingCounts(
component: Extract<Component, { Image: unknown }>['Image'],
): ComponentBindingCounts {
return countRequiredAssetSlot(component.target_graphic !== null);
}
export function getTextBindingCounts(
component: Extract<Component, { Text: unknown }>['Text'],
): ComponentBindingCounts {
return countRequiredAssetSlot(component.font !== null);
}
export function getComponentBindingCounts(
component: Component,
): ComponentBindingCounts {
if ('Image' in component) {
return getImageBindingCounts(component.Image);
}
if ('Text' in component) {
return getTextBindingCounts(component.Text);
}
return EMPTY_COMPONENT_BINDING_COUNTS;
}
function addBindingCounts(
overview: ComponentBindingCounts,
counts: ComponentBindingCounts,
) {
overview.componentsNeedingAssets += counts.componentsNeedingAssets;
overview.assetSlots += counts.assetSlots;
overview.boundSlots += counts.boundSlots;
overview.pendingSlots += counts.pendingSlots;
}
export function getBindingOverview(
uiTrees: UITree[],
spriteAssets: Record<string, SpriteAsset>,
): BindingOverview {
const overview: BindingOverview = {
...EMPTY_COMPONENT_BINDING_COUNTS,
needsAttention: 0,
blocked: 0,
independentAssets: Object.keys(spriteAssets).length,
};
for (const { node } of collectUiTreeNodeTargets(uiTrees)) {
const status = node.metadata.components_status;
if (status === 'Blocked') overview.blocked += 1;
if (status === 'Blocked' || isNeedReview(status)) {
overview.needsAttention += 1;
}
for (const component of node.components) {
addBindingCounts(overview, getComponentBindingCounts(component));
}
}
return overview;
}
export function nodeHasPendingBinding(target: UiTreeNodeTarget): boolean {
return target.node.components.some(
(component) => getComponentBindingCounts(component).pendingSlots > 0,
);
}
export function nodeNeedsComponentReview(target: UiTreeNodeTarget): boolean {
const status = target.node.metadata.components_status;
return status === 'Blocked' || isNeedReview(status);
}
export function nodeHasBlockedComponents(target: UiTreeNodeTarget): boolean {
return target.node.metadata.components_status === 'Blocked';
}
@@ -83,3 +83,14 @@ export function getNextUiTreeNodeTarget(
);
return targets[(previousIndex + 1) % targets.length] ?? null;
}
export function getNextMatchingUiTreeNodeTarget(
uiTrees: UITree[],
previousNodeId: string | null,
matches: (target: UiTreeNodeTarget) => boolean,
): UiTreeNodeTarget | null {
return getNextUiTreeNodeTarget(
collectUiTreeNodeTargets(uiTrees).filter(matches),
previousNodeId,
);
}
@@ -0,0 +1,141 @@
import { useMemo } from 'react';
import {
getBindingOverview,
nodeHasBlockedComponents,
nodeHasPendingBinding,
nodeNeedsComponentReview,
} from '../../../features/ui-editor/bindingOverview';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
import type { SpriteAsset } from '../../../features/ui-editor/types/SpriteAsset';
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
import type { UITree } from '../../../features/ui-editor/types/UITree';
import { useUiTreeNodeCycle } from '../useUiTreeNodeCycle';
export function BindingOverview({
uiTrees,
sprites,
onFocusNode,
}: {
uiTrees: UITree[];
sprites: Record<string, SpriteAsset>;
onFocusNode: (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,
matches: nodeNeedsComponentReview,
});
const blockedCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode,
matches: nodeHasBlockedComponents,
});
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="需要素材的组件"
value={overview.componentsNeedingAssets}
/>
<OverviewValue label="素材槽位" value={overview.assetSlots} />
<OverviewValue label="已绑定" value={overview.boundSlots} />
<OverviewAction
label="待处理"
value={overview.pendingSlots}
tone="warning"
disabled={overview.pendingSlots === 0}
onClick={pendingCycle.focusNext}
/>
<OverviewAction
label="待用户检查"
value={overview.needsAttention}
tone="warning"
disabled={overview.needsAttention === 0}
onClick={attentionCycle.focusNext}
/>
<OverviewAction
label="必须修复"
value={overview.blocked}
tone="danger"
disabled={overview.blocked === 0}
onClick={blockedCycle.focusNext}
/>
<OverviewValue
label="独立素材"
value={overview.independentAssets}
wide
/>
</div>
</section>
);
}
function OverviewValue({
label,
value,
wide = false,
}: {
label: string;
value: number;
wide?: boolean;
}) {
return (
<div
className={`rounded-lg border border-(--platform-subpanel-border) bg-white/45 p-2 text-center text-(--platform-text-strong) ${wide ? 'col-span-2' : ''}`}
>
<strong className="block text-base font-semibold">{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,6 +1,7 @@
import { Image as ImageIcon, Plus, ScanSearch } from 'lucide-react';
import type { UiEditorPageController } from '../useUiEditorPage';
import { BindingOverview } from './BindingOverview';
import { ImportOverview } from './ImportOverview';
import { RecognitionOverview } from './RecognitionOverview';
import { UiTreePanel } from './UiTreePanel';
@@ -210,6 +211,12 @@ export function InputSidebar({
uiTrees={editor.state.ui_trees}
onFocusNode={controller.focusNode}
/>
) : activeTool === 'assets' ? (
<BindingOverview
uiTrees={editor.state.ui_trees}
sprites={sprites}
onFocusNode={controller.focusNode}
/>
) : activeTool === 'input' ? (
<ImportOverview
images={images}
@@ -1,14 +1,13 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo } 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';
import { useUiTreeNodeCycle } from '../useUiTreeNodeCycle';
export function RecognitionOverview({
uiTrees,
@@ -17,56 +16,23 @@ export function RecognitionOverview({
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);
};
const attentionCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode,
matches: ({ node }) => {
const status = node.metadata.layout_status;
return status === 'Blocked' || isNeedReview(status);
},
});
const blockedCycle = useUiTreeNodeCycle({
uiTrees,
onFocusNode,
matches: ({ node }) => node.metadata.layout_status === 'Blocked',
});
return (
<section
@@ -83,16 +49,16 @@ export function RecognitionOverview({
label="待用户检查"
value={overview.needsAttention}
tone="warning"
disabled={attentionTargets.length === 0}
onClick={focusNextAttention}
disabled={overview.needsAttention === 0}
onClick={attentionCycle.focusNext}
/>
<OverviewValue label="已通过" value={overview.passed} />
<OverviewAction
label="必须修复"
value={overview.blocked}
tone="danger"
disabled={blockedTargets.length === 0}
onClick={focusNextBlocked}
disabled={overview.blocked === 0}
onClick={blockedCycle.focusNext}
/>
</div>
</section>
@@ -0,0 +1,36 @@
import { useCallback, useEffect, useState } from 'react';
import {
getNextMatchingUiTreeNodeTarget,
type UiTreeNodeTarget,
} 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 useUiTreeNodeCycle({
uiTrees,
matches,
onFocusNode,
}: {
uiTrees: UITree[];
matches: (target: UiTreeNodeTarget) => boolean;
onFocusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
}) {
const [lastNodeId, setLastNodeId] = useState<NodeId | null>(null);
useEffect(() => setLastNodeId(null), [uiTrees]);
const focusNext = useCallback(() => {
const target = getNextMatchingUiTreeNodeTarget(
uiTrees,
lastNodeId,
matches,
);
if (!target) return;
setLastNodeId(target.node.id);
onFocusNode(target.treeId, target.node.id);
}, [lastNodeId, matches, onFocusNode, uiTrees]);
return { focusNext };
}
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import {
getBindingOverview,
nodeHasBlockedComponents,
nodeHasPendingBinding,
nodeNeedsComponentReview,
} from '../src/features/ui-editor/bindingOverview';
import { getNextMatchingUiTreeNodeTarget } from '../src/features/ui-editor/stageStatusOverview';
import type { Component } from '../src/features/ui-editor/types/Component';
import type { Node } from '../src/features/ui-editor/types/Node';
import type { SpriteAsset } from '../src/features/ui-editor/types/SpriteAsset';
import type { UITree } from '../src/features/ui-editor/types/UITree';
function image(targetGraphic: string | null): Component {
return {
Image: {
target_graphic: targetGraphic,
image_type: { Simple: { preserve_aspect: false } },
},
};
}
function text(font: string | null): Component {
return {
Text: {
content: '文本',
font,
font_style: 'Normal',
font_sizing: { Fixed: 14 },
color: [255, 255, 255, 255],
alignment: 'UpperLeft',
horizontal_overflow: 'Wrap',
vertical_overflow: 'Truncate',
line_spacing: 1,
},
};
}
function node(
id: string,
components: Component[],
componentsStatus: Node['metadata']['components_status'] = 'Passed',
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: 'Passed',
components_status: componentsStatus,
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
},
components,
children,
};
}
function sprite(id: string): SpriteAsset {
return {
asset_id: id,
metadata: { name: id, asset_type: 'Normal' },
path: `${id}.png`,
pixel_size: [16, 16],
pixels_per_unit: 1,
border: { left: 0, right: 0, top: 0, bottom: 0 },
};
}
const sprites = {
validSprite: sprite('validSprite'),
unused: sprite('unused'),
};
const trees: UITree[] = [
{
src_ui_design: 'page-a',
root: node('root', [image('validSprite'), text('font-id')], 'Passed', [
node('review', [image(null)], { NeedReview: '确认素材' }),
node('blocked', [text(null)], 'Blocked'),
]),
},
];
describe('getBindingOverview', () => {
it('uses per-component helpers to include both image and text slots', () => {
expect(getBindingOverview(trees, sprites)).toEqual({
componentsNeedingAssets: 4,
assetSlots: 4,
boundSlots: 2,
pendingSlots: 2,
needsAttention: 2,
blocked: 1,
independentAssets: 2,
});
});
it('reuses the common preorder next-target search for every binding queue', () => {
expect(
getNextMatchingUiTreeNodeTarget(trees, null, (target) =>
nodeHasPendingBinding(target),
)?.node.id,
).toBe('review');
expect(
getNextMatchingUiTreeNodeTarget(trees, 'review', (target) =>
nodeHasPendingBinding(target),
)?.node.id,
).toBe('blocked');
expect(
getNextMatchingUiTreeNodeTarget(trees, null, nodeNeedsComponentReview)
?.node.id,
).toBe('review');
expect(
getNextMatchingUiTreeNodeTarget(trees, null, nodeHasBlockedComponents)
?.node.id,
).toBe('blocked');
});
});