新增UI编辑器绑定概览与循环定位
统计图像和文本组件的素材槽位绑定状态。 新增绑定待处理、待检查和必须修复的循环定位。 复用识别概览的前序候选查询与导航逻辑。
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user