diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/bindingOverview.ts b/apps/ai-game-creator-shell/src/features/ui-editor/bindingOverview.ts new file mode 100644 index 000000000..53cee5d9f --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/bindingOverview.ts @@ -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['Image'], +): ComponentBindingCounts { + return countRequiredAssetSlot(component.target_graphic !== null); +} + +export function getTextBindingCounts( + component: Extract['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, +): 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'; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts b/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts index 18651e830..1fd090f7d 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts @@ -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, + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/BindingOverview.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/BindingOverview.tsx new file mode 100644 index 000000000..bc76210e6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/BindingOverview.tsx @@ -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; + 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 ( +
+ + Overview + +

绑定概览

+
+ + + + + + + +
+
+ ); +} + +function OverviewValue({ + label, + value, + wide = false, +}: { + label: string; + value: number; + wide?: boolean; +}) { + return ( +
+ {value} + + {label} + +
+ ); +} + +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 ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx index 4021ad86c..23586ba1c 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -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' ? ( + ) : activeTool === 'input' ? ( void; }) { - const [lastAttentionNodeId, setLastAttentionNodeId] = useState( - null, - ); - const [lastBlockedNodeId, setLastBlockedNodeId] = useState( - 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 (
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiTreeNodeCycle.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiTreeNodeCycle.ts new file mode 100644 index 000000000..6d32b3983 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiTreeNodeCycle.ts @@ -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(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 }; +} diff --git a/apps/ai-game-creator-shell/tests/bindingOverview.test.ts b/apps/ai-game-creator-shell/tests/bindingOverview.test.ts new file mode 100644 index 000000000..966170970 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/bindingOverview.test.ts @@ -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'); + }); +});