From 5cb3801ba401807827f142da62f287b091934578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 14 Sep 2026 14:15:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20UI=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=BE=85=E5=8A=9E=E5=BE=AA=E7=8E=AF=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复受控树选中回调清除 Inspector 高亮的问题 循环游标同时使用界面树与节点 ID 补充多节点循环和动画重启回归测试 --- .../features/ui-editor/stageStatusOverview.ts | 24 +++- .../ui-editor/components/InputSidebar.tsx | 12 +- .../components/Inspector/InspectorSidebar.tsx | 19 +++- .../src/view/ui-editor/useUiTreeNodeCycle.ts | 11 +- .../tests/stageStatusOverview.test.ts | 30 +++++ .../tests/uiEditorPage.test.ts | 106 ++++++++++++++++++ 6 files changed, 187 insertions(+), 15 deletions(-) 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 80dc27fac..eb4b99f61 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 @@ -1,4 +1,5 @@ import type { Node as UiNode } from './types/Node'; +import type { NodeId } from './types/NodeId'; import type { NodeMetadata } from './types/NodeMetadata'; import type { StageStatus } from './types/StageStatus'; import type { UIDesignImageId } from './types/UIDesignImageId'; @@ -14,6 +15,11 @@ export type UiTreeNodeTarget = { node: UiNode; }; +export type UiTreeNodeCursor = { + treeId: UIDesignImageId; + nodeId: NodeId; +}; + export type StageStatusOverview = { total: number; needsAttention: number; @@ -79,22 +85,28 @@ export function getStageStatusTargets( export function getNextUiTreeNodeTarget( targets: UiTreeNodeTarget[], - previousNodeId: string | null, + previous: string | UiTreeNodeCursor | null, ): UiTreeNodeTarget | null { if (targets.length === 0) return null; - const previousIndex = targets.findIndex( - ({ node }) => node.id === previousNodeId, - ); + const previousIndex = + typeof previous === 'string' + ? targets.findIndex(({ node }) => node.id === previous) + : previous + ? targets.findIndex( + ({ treeId, node }) => + treeId === previous.treeId && node.id === previous.nodeId, + ) + : -1; return targets[(previousIndex + 1) % targets.length] ?? null; } export function getNextMatchingUiTreeNodeTarget( uiTrees: UITree[], - previousNodeId: string | null, + previous: string | UiTreeNodeCursor | null, matches: (target: UiTreeNodeTarget) => boolean, ): UiTreeNodeTarget | null { return getNextUiTreeNodeTarget( collectUiTreeNodeTargets(uiTrees).filter(matches), - previousNodeId, + previous, ); } 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 b473accb2..79ba4206a 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 @@ -83,8 +83,16 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) { treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null} isNodePreviewVisible={input.isNodePreviewVisible} onSelectNode={(treeId, nodeId) => { - input.selectDesignImage(treeId); - input.selectNode(nodeId); + // react-arborist emits `onSelect` when its controlled `selection` + // prop is updated. Overview navigation updates the selection and + // the status highlight in the same render, so treating that + // programmatic notification as a fresh user selection would clear + // the highlight before it can be painted. Only mutate selection + // state when the target actually differs from the current one. + const sameImage = input.activeImageId === treeId; + const sameNode = input.selectedNodeId === nodeId; + if (!sameImage) input.selectDesignImage(treeId); + if (!sameImage || !sameNode) input.selectNode(nodeId); }} onToggleNodeVisibility={(nodeId) => input.toggleNodePreviewVisibility(nodeId) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx index 64cf5c1b8..22258f091 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx @@ -827,11 +827,26 @@ function NodeStageSelect({ const attentionTone = kind === 'Blocked' ? 'blocked' : 'review'; useEffect(() => { - if (!highlight) return; - statusRowRef.current?.scrollIntoView({ + const statusRow = statusRowRef.current; + if (!highlight || !statusRow) return; + statusRow.scrollIntoView({ block: 'nearest', behavior: 'smooth', }); + + // 强制制造一次样式边界,避免 A → B → A 时浏览器复用已完成的动画。 + statusRow.classList.remove('ui-editor-status-attention'); + void statusRow.offsetWidth; + statusRow.classList.add('ui-editor-status-attention'); + + // 多节点切换会复用 Inspector 树,显式重启动画,确保回到已查看节点时 + // 仍能再次播放提示,而不是依赖 class/key 的重协调行为。 + if (typeof statusRow.getAnimations === 'function') { + for (const animation of statusRow.getAnimations()) { + animation.cancel(); + animation.play(); + } + } }, [highlight]); 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 index 6d32b3983..cb0053562 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiTreeNodeCycle.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiTreeNodeCycle.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { getNextMatchingUiTreeNodeTarget, + type UiTreeNodeCursor, type UiTreeNodeTarget, } from '../../features/ui-editor/stageStatusOverview'; import type { NodeId } from '../../features/ui-editor/types/NodeId'; @@ -17,20 +18,20 @@ export function useUiTreeNodeCycle({ matches: (target: UiTreeNodeTarget) => boolean; onFocusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void; }) { - const [lastNodeId, setLastNodeId] = useState(null); + const [lastCursor, setLastCursor] = useState(null); - useEffect(() => setLastNodeId(null), [uiTrees]); + useEffect(() => setLastCursor(null), [uiTrees]); const focusNext = useCallback(() => { const target = getNextMatchingUiTreeNodeTarget( uiTrees, - lastNodeId, + lastCursor, matches, ); if (!target) return; - setLastNodeId(target.node.id); + setLastCursor({ treeId: target.treeId, nodeId: target.node.id }); onFocusNode(target.treeId, target.node.id); - }, [lastNodeId, matches, onFocusNode, uiTrees]); + }, [lastCursor, matches, onFocusNode, uiTrees]); return { focusNext }; } diff --git a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts index 8e7389b02..b7677ead3 100644 --- a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts +++ b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts @@ -95,4 +95,34 @@ describe('stageStatusOverview', () => { 'review-a', ); }); + + it('uses the tree and node ids together when node ids repeat across trees', () => { + const duplicateTargets = [ + { treeId: 'page-a', node: node('same', { NeedReview: 'A' }) }, + { treeId: 'page-b', node: node('same', { NeedReview: 'B' }) }, + { treeId: 'page-c', node: node('same', { NeedReview: 'C' }) }, + ]; + + expect(getNextUiTreeNodeTarget(duplicateTargets, null)?.treeId).toBe( + 'page-a', + ); + expect( + getNextUiTreeNodeTarget(duplicateTargets, { + treeId: 'page-a', + nodeId: 'same', + })?.treeId, + ).toBe('page-b'); + expect( + getNextUiTreeNodeTarget(duplicateTargets, { + treeId: 'page-b', + nodeId: 'same', + })?.treeId, + ).toBe('page-c'); + expect( + getNextUiTreeNodeTarget(duplicateTargets, { + treeId: 'page-c', + nodeId: 'same', + })?.treeId, + ).toBe('page-a'); + }); }); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index f720d993d..d321a3748 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -395,6 +395,112 @@ describe('UiEditorPage', () => { ).toBeNull(); }); + it('restarts inspector animation for every item in a multi-item cycle', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [ + node('review-a'), + node('review-b'), + node('review-c'), + ]; + for (const [index, child] of state.ui_trees[0]!.root.children.entries()) { + child.metadata.layout_status = { NeedReview: `请检查 ${index}` }; + } + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + const scrollSpy = vi.fn(); + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: scrollSpy, + }); + const cancel = vi.fn(); + const play = vi.fn(); + const getAnimationsSpy = vi.fn(() => [ + { cancel, play } as unknown as Animation, + ]); + Object.defineProperty(Element.prototype, 'getAnimations', { + configurable: true, + value: getAnimationsSpy, + }); + try { + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-cycle-animation', + resourceId: 'ui-resource', + stateStore, + initialStep: 'structure-recognition', + initialFurthestStepIndex: 1, + }), + ); + const button = await screen.findByRole('button', { + name: '待用户检查 3,定位下一项', + }); + for (const reason of ['请检查 0', '请检查 1', '请检查 2', '请检查 0']) { + fireEvent.click(button); + await screen.findByText(reason); + } + expect(getAnimationsSpy).toHaveBeenCalledTimes(4); + expect(cancel).toHaveBeenCalledTimes(4); + expect(play).toHaveBeenCalledTimes(4); + expect(scrollSpy).toHaveBeenCalledTimes(4); + } finally { + delete (Element.prototype as Element & { getAnimations?: unknown }) + .getAnimations; + delete (Element.prototype as Element & { scrollIntoView?: unknown }) + .scrollIntoView; + } + }); + + it('keeps the status highlight after the tree handles controlled selection', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [ + node('review-a'), + node('review-b'), + node('review-c'), + ]; + for (const child of state.ui_trees[0]!.root.children) { + child.metadata.layout_status = { NeedReview: '请检查' }; + } + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-cycle-highlight', + resourceId: 'ui-resource', + stateStore, + initialStep: 'structure-recognition', + initialFurthestStepIndex: 1, + }), + ); + + const button = await screen.findByRole('button', { + name: '待用户检查 3,定位下一项', + }); + try { + for (let index = 0; index < 4; index += 1) { + fireEvent.click(button); + await waitFor(() => { + expect( + document.querySelector('[data-status-attention]'), + ).not.toBeNull(); + }); + } + } finally { + delete (Element.prototype as Element & { scrollIntoView?: unknown }) + .scrollIntoView; + } + }); + it('switches tools freely without inventing completed workflow state', () => { render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' }));