From fbd30d7c6658deba671825dd906664c4f6aa1af1 Mon Sep 17 00:00:00 2001
From: kdletters <61648117+kdletters@users.noreply.github.com>
Date: Fri, 2 Oct 2026 11:17:28 +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=AA=E7=8E=AF=E6=B8=B8=E6=A0=87=E4=B8=8E=E5=A4=8D?=
=?UTF-8?q?=E5=88=B6=E6=80=81=E8=A2=AB=E5=BB=B6=E8=BF=9F=20effect=20?=
=?UTF-8?q?=E8=A6=86=E7=9B=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
界面树循环游标改为渲染期换算有效游标,不再用被动 effect 事后重置
复制路径按钮把复制态绑定到具体路径,去掉延迟重置的 effect
复制态断言改为等待异步落盘,避免同步断言抢跑
新增 useUiTreeNodeCycle 循环顺序与界面树替换重置用例
---
.../components/UiEditorCopyPathButton.tsx | 27 +++---
.../src/view/ui-editor/useUiTreeNodeCycle.ts | 24 ++++-
.../tests/uiEditorPage.test.ts | 3 +-
.../tests/uiTreeNodeCycle.test.ts | 96 +++++++++++++++++++
4 files changed, 133 insertions(+), 17 deletions(-)
create mode 100644 apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx
index ea19e48bf..b33d9e62a 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx
@@ -1,31 +1,36 @@
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { Copy } from 'lucide-react';
-import { useEffect, useState } from 'react';
+import { useState } from 'react';
+
+type CopyState = 'idle' | 'copied' | 'failed';
export function UiEditorCopyPathButton({
relativePath,
}: {
relativePath: string;
}) {
- const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
- 'idle',
- );
-
- useEffect(() => setCopyState('idle'), [relativePath]);
+ // 复制态跟着具体路径走:路径变了就等于回到未复制。这里不用 effect 事后重置,
+ // 因为延迟执行的 effect 会晚于复制结果落盘,把「已复制」覆盖回未复制。
+ const [copyState, setCopyState] = useState<{
+ relativePath: string;
+ value: CopyState;
+ }>({ relativePath, value: 'idle' });
+ const value =
+ copyState.relativePath === relativePath ? copyState.value : 'idle';
async function copyPath() {
- setCopyState('idle');
+ setCopyState({ relativePath, value: 'idle' });
try {
await writeText(relativePath);
- setCopyState('copied');
+ setCopyState({ relativePath, value: 'copied' });
} catch {
- setCopyState('failed');
+ setCopyState({ relativePath, value: 'failed' });
}
}
return (
<>
- {copyState === 'failed' ? (
+ {value === 'failed' ? (
复制失败,请手动复制路径。
@@ -36,7 +41,7 @@ export function UiEditorCopyPathButton({
onClick={() => void copyPath()}
>
- {copyState === 'copied' ? '已复制' : '复制路径'}
+ {value === 'copied' ? '已复制' : '复制路径'}
>
);
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 cb0053562..6d8c9bfb8 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
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useState } from 'react';
import {
getNextMatchingUiTreeNodeTarget,
@@ -9,6 +9,11 @@ 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';
+type UiTreeNodeCycleState = {
+ trees: UITree[];
+ cursor: UiTreeNodeCursor | null;
+};
+
export function useUiTreeNodeCycle({
uiTrees,
matches,
@@ -18,9 +23,15 @@ export function useUiTreeNodeCycle({
matches: (target: UiTreeNodeTarget) => boolean;
onFocusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
}) {
- const [lastCursor, setLastCursor] = useState(null);
-
- useEffect(() => setLastCursor(null), [uiTrees]);
+ // 界面树整体被替换时,新一轮循环要从第一项重新开始。这里把「游标属于哪一版
+ // 界面树」一起记下来,在渲染期直接换算成有效游标,而不是放进 useEffect:
+ // 被动 effect 的 flush 可能晚于紧随其后的点击,会把刚落盘的游标覆盖回 null,
+ // 让「定位下一项」反复停在第一项。
+ const [cycle, setCycle] = useState({
+ trees: uiTrees,
+ cursor: null,
+ });
+ const lastCursor = cycle.trees === uiTrees ? cycle.cursor : null;
const focusNext = useCallback(() => {
const target = getNextMatchingUiTreeNodeTarget(
@@ -29,7 +40,10 @@ export function useUiTreeNodeCycle({
matches,
);
if (!target) return;
- setLastCursor({ treeId: target.treeId, nodeId: target.node.id });
+ setCycle({
+ trees: uiTrees,
+ cursor: { treeId: target.treeId, nodeId: target.node.id },
+ });
onFocusNode(target.treeId, target.node.id);
}, [lastCursor, matches, onFocusNode, uiTrees]);
diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
index 0bea3af72..6575d7c54 100644
--- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
+++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
@@ -916,7 +916,8 @@ describe('UiEditorPage', () => {
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith('ui/generated-example.js'),
);
- expect(screen.getByRole('button', { name: '已复制' })).toBeTruthy();
+ // 复制态在剪贴板写入 resolve 之后才提交,必须等它落盘再断言。
+ expect(await screen.findByRole('button', { name: '已复制' })).toBeTruthy();
});
it('shows a save-success modal without a generated path', async () => {
diff --git a/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts b/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts
new file mode 100644
index 000000000..fe73af837
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts
@@ -0,0 +1,96 @@
+// @vitest-environment jsdom
+
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ isNeedReview,
+ type UiTreeNodeTarget,
+} from '../src/features/ui-editor/stageStatusOverview';
+import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
+import type { UITree } from '../src/features/ui-editor/types/UITree';
+import { useUiTreeNodeCycle } from '../src/view/ui-editor/useUiTreeNodeCycle';
+
+function node(
+ id: string,
+ layoutStatus: UiNode['metadata']['layout_status'],
+): UiNode {
+ return {
+ id,
+ layout: {
+ transform: {
+ anchor_min: [0, 0],
+ anchor_max: [1, 1],
+ offset_min: [0, 0],
+ offset_max: [0, 0],
+ },
+ custom_minimum_size: [0, 0],
+ size_flags_horizontal: 1,
+ size_flags_vertical: 1,
+ size_flags_stretch_ratio: 1,
+ container: 'None',
+ },
+ metadata: {
+ name: id,
+ description: '',
+ layout_status: layoutStatus,
+ component_status: 'NoProblem',
+ allow_llm_edit_layout: true,
+ allow_llm_edit_component: true,
+ source: 'System',
+ },
+ component: null,
+ children_display_mode: undefined,
+ offset: { min: [0, 0], max: [0, 0] },
+ children: [],
+ };
+}
+
+function reviewTree(): UITree {
+ return {
+ src_ui_design: 'page',
+ root: {
+ ...node('page-root', 'NoProblem'),
+ children: [
+ node('review-a', { NeedReview: '请检查 0' }),
+ node('review-b', { NeedReview: '请检查 1' }),
+ node('review-c', { NeedReview: '请检查 2' }),
+ ],
+ },
+ };
+}
+
+const matchesReviewNode = ({ node }: UiTreeNodeTarget) =>
+ isNeedReview(node.metadata.layout_status);
+
+describe('useUiTreeNodeCycle', () => {
+ it('按顺序循环待检查节点,并在界面树整体替换后重新从第一项开始', () => {
+ const uiTrees = [reviewTree()];
+ const onFocusNode = vi.fn();
+ const { result, rerender } = renderHook(
+ ({ trees }: { trees: UITree[] }) =>
+ useUiTreeNodeCycle({
+ uiTrees: trees,
+ matches: matchesReviewNode,
+ onFocusNode,
+ }),
+ { initialProps: { trees: uiTrees } },
+ );
+
+ act(() => result.current.focusNext());
+ expect(onFocusNode).toHaveBeenLastCalledWith('page', 'review-a');
+
+ act(() => result.current.focusNext());
+ expect(onFocusNode).toHaveBeenLastCalledWith('page', 'review-b');
+
+ // 同一版界面树上的无关渲染不得重置循环游标。
+ rerender({ trees: uiTrees });
+ act(() => result.current.focusNext());
+ expect(onFocusNode).toHaveBeenLastCalledWith('page', 'review-c');
+
+ // 界面树被整体替换时,下一项回到第一项。
+ rerender({ trees: [...uiTrees] });
+ act(() => result.current.focusNext());
+ expect(onFocusNode).toHaveBeenLastCalledWith('page', 'review-a');
+ });
+});