From 03f900fbe5f11c7635924a1a7e031ae9f4c832d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 12 Sep 2026 17:42:04 +0800 Subject: [PATCH 001/199] =?UTF-8?q?=E5=B8=83=E5=B1=80=E9=87=8D=E6=9E=84:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 预览改为全区域背景层。 - 左右面板、顶部流程条改为覆盖层,可遮挡预览。 - 左右面板开合状态仅保存在当前 React 会话。 - 增加左右面板“收起/展开”入口。 - 编辑器关闭入口改为单独的 ×,继续通过 onBack 注入关闭逻辑。 - 保留全局顶部栏与现有快捷键、业务状态、保存流程。 - 底层页面仍由现有父级路由控制,不新增 URL 状态。 --- .../src/view/ui-editor/index.tsx | 112 +++++++++++------- 1 file changed, 69 insertions(+), 43 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index 4696d76f9..4e551bb6a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft, Redo2, Undo2 } from 'lucide-react'; +import { Redo2, Undo2, X } from 'lucide-react'; import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { ThemedModal } from '../../components/modal/ThemedModal'; @@ -71,6 +71,8 @@ export default function UiEditorPage({ const [generateAfterWarning, setGenerateAfterWarning] = useState(false); const [generateSuccess, setGenerateSuccess] = useState(null); const [returnConfirmOpen, setReturnConfirmOpen] = useState(false); + const [leftPanelOpen, setLeftPanelOpen] = useState(true); + const [rightPanelOpen, setRightPanelOpen] = useState(true); const saveDisabled = session.save.isSaving || session.save.isGenerating || @@ -162,16 +164,16 @@ export default function UiEditorPage({ } return ( -
+
{resourceId ? (
{resourceLabel ?? 'UI 设计'}
@@ -244,45 +246,69 @@ export default function UiEditorPage({ {generateSuccess}
) : null} - -
- -
- - +
+ +
+
+
+ {leftPanelOpen ? : null} +
-
- {session.input.activeStep === 'structure-recognition' ? ( - { - session.input.focusNode(treeId, nodeId); - session.input.highlightStatusField(nodeId, 'layout_status'); - }} - /> - ) : session.input.activeStep === 'asset-separation' ? ( - { - session.input.focusNode(treeId, nodeId); - session.input.highlightStatusField(nodeId, 'component_status'); - }} - /> - ) : ( - - )} - +
+ + +
+
+ {rightPanelOpen ? ( + <> + {session.input.activeStep === 'structure-recognition' ? ( + { + session.input.focusNode(treeId, nodeId); + session.input.highlightStatusField(nodeId, 'layout_status'); + }} + /> + ) : session.input.activeStep === 'asset-separation' ? ( + { + session.input.focusNode(treeId, nodeId); + session.input.highlightStatusField( + nodeId, + 'component_status', + ); + }} + /> + ) : ( + + )} + + + ) : null} +
From 84c780a6f807629fcbc5b40f1787f1aba7460b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 12 Sep 2026 20:45:36 +0800 Subject: [PATCH 002/199] =?UTF-8?q?=E9=87=8D=E6=9E=84=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=85=A8=E5=B1=8F=E5=A4=96=E5=A3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 portal 将资源编辑器挂载到 body 并覆盖标题栏以下区域 移除外层 FocusTrap,保留内部确认对话框的模态行为 统一左右浮动面板的顶部与底部留白 --- .../src/view/ui-editor/index.tsx | 211 +++++++++++------- 1 file changed, 134 insertions(+), 77 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index 4e551bb6a..c03871d8a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -1,5 +1,6 @@ import { Redo2, Undo2, X } from 'lucide-react'; import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; import { ThemedModal } from '../../components/modal/ThemedModal'; import { @@ -11,7 +12,10 @@ import { EditorDialogs } from './components/EditorDialogs'; import { ImportOverview } from './components/ImportOverview'; import { InputSidebar } from './components/InputSidebar'; import { InspectorSidebar } from './components/Inspector/InspectorSidebar'; -import { PreviewWorkspace } from './components/preview/PreviewWorkspace'; +import { + PreviewWorkspace, + type UiEditorRenderMode, +} from './components/preview/PreviewWorkspace'; import { RecognitionOverview } from './components/RecognitionOverview'; import { SeparationOverview } from './components/SeparationOverview'; import { ToolNavigation } from './components/ToolNavigation'; @@ -71,8 +75,34 @@ export default function UiEditorPage({ const [generateAfterWarning, setGenerateAfterWarning] = useState(false); const [generateSuccess, setGenerateSuccess] = useState(null); const [returnConfirmOpen, setReturnConfirmOpen] = useState(false); - const [leftPanelOpen, setLeftPanelOpen] = useState(true); - const [rightPanelOpen, setRightPanelOpen] = useState(true); + const [renderMode, setRenderMode] = + useState('editor-overlay'); + const [showFrame, setShowFrame] = useState(false); + const overview = + session.input.activeStep === 'structure-recognition' ? ( + { + session.input.focusNode(treeId, nodeId); + session.input.highlightStatusField(nodeId, 'layout_status'); + }} + /> + ) : session.input.activeStep === 'asset-separation' ? ( + { + session.input.focusNode(treeId, nodeId); + session.input.highlightStatusField(nodeId, 'component_status'); + }} + /> + ) : ( + + ); const saveDisabled = session.save.isSaving || session.save.isGenerating || @@ -163,20 +193,41 @@ export default function UiEditorPage({ onBack?.(); } - return ( + const editorContent = (
{resourceId ? ( -
- +
{resourceLabel ?? 'UI 设计'}
+
+ + +
+ {renderMode === 'final-preview' ? ( + + ) : null}
) : null} +
) : null} + {resourceId ? ( + + ) : null} {session.save.saveError ? (
{session.save.saveError} @@ -232,7 +301,7 @@ export default function UiEditorPage({ ) : null} {session.save.generateError ? (
代码生成失败:{session.save.generateError} @@ -240,75 +309,50 @@ export default function UiEditorPage({ ) : null} {generateSuccess ? (
{generateSuccess}
) : null}
- -
-
-
- {leftPanelOpen ? : null} - -
-
- - -
-
- {rightPanelOpen ? ( - <> - {session.input.activeStep === 'structure-recognition' ? ( - { - session.input.focusNode(treeId, nodeId); - session.input.highlightStatusField(nodeId, 'layout_status'); - }} - /> - ) : session.input.activeStep === 'asset-separation' ? ( - { - session.input.focusNode(treeId, nodeId); - session.input.highlightStatusField( - nodeId, - 'component_status', - ); - }} - /> - ) : ( - - )} - - - ) : null} - + +
+
+
event.preventDefault()} + > + + +
+
event.preventDefault()} + > + {overview} +
+
+
+ +
@@ -423,6 +467,19 @@ export default function UiEditorPage({
); + + if (!resourceId) return editorContent; + + if (typeof document === 'undefined') return editorContent; + + return createPortal( +
+
+ {editorContent} +
+
, + document.body, + ); } function stepLabel(step: UiEditorWorkflowProjection['activeStep'] | undefined) { From e389b54a3a99ee9de58235e5c83ee8fd3e2ce730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 12 Sep 2026 20:47:06 +0800 Subject: [PATCH 003/199] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20UI=20=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E7=94=BB=E5=B8=83=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为无限视口添加跟随平移和缩放的点阵背景 恢复画布快捷缩放与初始适配比例控制 保留节点平移选择并屏蔽浏览器原生拖拽选择 --- .../components/preview/PreviewWorkspace.tsx | 144 +++++++++--------- 1 file changed, 70 insertions(+), 74 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index 26203c35a..6bd4c674a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -1,4 +1,5 @@ import { + CANVAS_WORLD_SIZE, CANVAS_ZOOM_IN_FACTOR, CANVAS_ZOOM_OUT_FACTOR, canvasDisplayScaleToViewportScale, @@ -40,12 +41,41 @@ import { import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; +/** 画布初始适配比例;调小可为浮动面板预留更多可视空间。 */ +export const UI_EDITOR_FIT_DEGREE = 0.7; + +function fitEditorViewport( + bounds: { x: number; y: number; width: number; height: number }, + canvasSize: { width: number; height: number }, +): CanvasViewport { + const fit = fitViewportToBounds({ bounds, canvasSize }); + const scale = fit.scale * UI_EDITOR_FIT_DEGREE; + return { + scale, + x: canvasSize.width / 2 - (bounds.x + bounds.width / 2) * scale, + y: canvasSize.height / 2 - (bounds.y + bounds.height / 2) * scale, + }; +} + +export type { UiEditorRenderMode } from './UiTreeRenderer'; + export function PreviewWorkspace({ canvas, + renderMode: controlledRenderMode, + onRenderModeChange: _onRenderModeChange, + showFrame: controlledShowFrame, + onShowFrameChange: _onShowFrameChange, }: { canvas: UiEditorCanvasProjection; + renderMode?: UiEditorRenderMode; + onRenderModeChange?: (mode: UiEditorRenderMode) => void; + showFrame?: boolean; + onShowFrameChange?: (show: boolean) => void; }) { const { activeImage, activeImageId, previewUrls } = canvas; + const activePreviewUrl = activeImageId + ? previewUrls[activeImageId] + : undefined; const viewportElementRef = useRef(null); const viewportRef = useRef({ x: 0, y: 0, scale: 0.5 }); const handledFocusRequestIdRef = useRef(null); @@ -58,9 +88,8 @@ export function PreviewWorkspace({ const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); const canvasSizeRef = useRef(canvasSize); const [spaceHeld, setSpaceHeld] = useState(false); - const [renderMode, setRenderMode] = - useState('editor-overlay'); - const [showFrame, setShowFrame] = useState(false); + const renderMode = controlledRenderMode ?? 'editor-overlay'; + const showFrame = controlledShowFrame ?? false; const [previewTransforms, setPreviewTransforms] = useState< ReadonlyMap >(new Map()); @@ -146,25 +175,37 @@ export function PreviewWorkspace({ if (!logicalSize) return; const element = viewportElementRef.current; setViewport( - fitViewportToBounds({ - bounds: { - x: 0, - y: 0, - width: logicalSize.width, - height: logicalSize.height, - }, - canvasSize: { + fitEditorViewport( + { x: 0, y: 0, width: logicalSize.width, height: logicalSize.height }, + { width: element?.clientWidth || canvasSizeRef.current.width, height: element?.clientHeight || canvasSizeRef.current.height, }, - }), + ), ); }, [logicalSize, setViewport]); useEffect(() => { if (!activeImageId || !logicalSize) return; - fitToCanvas(); - }, [activeImageId, fitToCanvas, logicalSize]); + const element = viewportElementRef.current; + const canvasSize = { + width: element?.clientWidth || canvasSizeRef.current.width, + height: element?.clientHeight || canvasSizeRef.current.height, + }; + setViewport( + fitEditorViewport( + { x: 0, y: 0, width: logicalSize.width, height: logicalSize.height }, + canvasSize, + ), + ); + }, [ + activeImageId, + activePreviewUrl, + canvasSize.height, + canvasSize.width, + logicalSize, + setViewport, + ]); const scaleViewportFromCenter = useCallback( (nextScale: number) => { @@ -243,15 +284,15 @@ export function PreviewWorkspace({ }; handledFocusRequestIdRef.current = request.requestId; setViewport( - fitViewportToBounds({ - bounds: { + fitEditorViewport( + { x: context.rect.min[0], y: context.rect.min[1], width: context.rect.max[0] - context.rect.min[0], height: context.rect.max[1] - context.rect.min[1], }, - canvasSize: size, - }), + size, + ), ); }, [ activeImageId, @@ -385,45 +426,18 @@ export function PreviewWorkspace({ }, [renderMode]); return ( -
+
{activeImage && logicalSize && (renderMode === 'final-preview' || previewUrls[activeImageId ?? '']) ? (
-
- {renderMode === 'final-preview' ? ( - - ) : null} -
- - -
-
event.preventDefault()} onClick={(event) => { if (!(event.target as Element).closest('[data-node-id]')) { canvas.clearNodeSelection(); } }} > - +
) : ( @@ -518,7 +530,7 @@ export function PreviewWorkspace({ /> ) : null}
{ if ( event.target instanceof Element && @@ -527,6 +539,7 @@ export function PreviewWorkspace({ event.preventDefault(); } }} + onDragStart={(event) => event.preventDefault()} >
)} -
- {canvas.status ? ( - - {canvas.status} - - ) : ( - - )} - -
); } From e992e0b35cd310bced6ba592436ed2e84a4f90f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 12 Sep 2026 20:47:43 +0800 Subject: [PATCH 004/199] =?UTF-8?q?=E5=8E=8B=E7=BC=A9=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=A6=82=E8=A7=88=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将识别概览指标改为四列横向排列 将素材切分概览改为四列网格以减少纵向高度 --- .../src/view/ui-editor/components/RecognitionOverview.tsx | 2 +- .../src/view/ui-editor/components/SeparationOverview.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/RecognitionOverview.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/RecognitionOverview.tsx index 5ed5d6de5..7b805e626 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/RecognitionOverview.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/RecognitionOverview.tsx @@ -44,7 +44,7 @@ export function RecognitionOverview({ Overview

识别概览

-
+

自动切分素材概览

-
+
Date: Sat, 12 Sep 2026 20:48:07 +0800 Subject: [PATCH 005/199] =?UTF-8?q?=E8=A1=A5=E5=85=85=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E4=BA=A4=E4=BA=92=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从实际节点事件目标验证删除快捷键 验证 portal 右键菜单启用后会执行操作并关闭 --- .../tests/uiEditorPage.test.ts | 2 +- .../tests/uiEditorPreview.test.tsx | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index f9615a790..f720d993d 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -511,7 +511,7 @@ describe('UiEditorPage', () => { const child = await screen.findByText('page-child'); fireEvent.click(child); - fireEvent.keyDown(window, { key: 'Delete' }); + fireEvent.keyDown(child, { key: 'Delete' }); await waitFor(() => expect(screen.queryByText('page-child')).toBeNull()); }); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 487ea4fa4..21b40e692 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -152,6 +152,27 @@ describe('UI tree preview visibility', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('runs an enabled context-menu action from its portal', () => { + const onClose = vi.fn(); + const onInsertChild = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('menuitem', { name: '新增子节点' })); + + expect(onInsertChild).toHaveBeenCalledWith('node-1'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it('maps a Godot HBoxContainer to preview CSS and disables child free-transform handles', () => { const hboxTree: UITree = { src_ui_design: 'page', From a8f2ac17be2cd52f103d00c87cdfa55bd1bb0cd5 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 10:16:07 +0800 Subject: [PATCH 006/199] =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=8E=89=20Inspector?= =?UTF-8?q?Sidebar=20=E4=B8=AD=E7=9A=84=20LayoutEditor=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=BB=A5=E6=9A=82=E6=97=B6=E7=A6=81=E7=94=A8=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E7=BC=96=E8=BE=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Inspector/InspectorSidebar.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 15e77c8e7..64cf5c1b8 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 @@ -284,7 +284,7 @@ function NodeInspector({ onMetadataChange, highlightedStatusField, onTransformChange, - onLayoutChange, + // onLayoutChange, sprites, previewUrls, fonts, @@ -483,11 +483,11 @@ function NodeInspector({ readOnly={transformReadOnly || isReadOnly} onChange={onTransformChange} /> - + {/**/} Date: Mon, 14 Sep 2026 14:15:43 +0800 Subject: [PATCH 007/199] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=BE=85=E5=8A=9E=E5=BE=AA=E7=8E=AF=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=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' })); From cdf2882302cd0f954ff3d0a81767b8864ee2598d 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:16:24 +0800 Subject: [PATCH 008/199] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=88=87=E5=88=86=E6=A6=82=E8=A7=88=E5=AD=97=E4=BD=93=E5=B1=9E?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 向 SeparationOverview 传入 fonts 数据 修复 UI 编辑器 TS2741 类型错误 --- apps/ai-game-creator-shell/src/view/ui-editor/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index c03871d8a..c40179b7e 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -91,6 +91,7 @@ export default function UiEditorPage({ { session.input.focusNode(treeId, nodeId); session.input.highlightStatusField(nodeId, 'component_status'); From 46f85923753ac3e61f917f3b3840e5652d197ff0 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 17:50:05 +0800 Subject: [PATCH 009/199] =?UTF-8?q?UI=E7=BC=96=E8=BE=91=E5=99=A8=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E6=A0=91=E7=BA=A7=E5=81=8F=E7=A7=BB=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Node offset min/max 数据结构 统一新树默认横向布局入口 增加树级偏移状态更新操作与实施计划 --- .../src-tauri/src/ui_editor/commands/merge.rs | 2 + .../src/ui_editor/commands/recognition.rs | 3 + .../src-tauri/src/ui_editor/layout/mod.rs | 1 + .../src-tauri/src/ui_editor/layout/node.rs | 2 + .../src-tauri/src/ui_editor/layout/offset.rs | 18 +++++ .../src/features/ui-editor/types/Node.ts | 3 +- .../features/ui-editor/types/NodeOffset.ts | 4 + .../features/ui-editor/useUiEditorState.ts | 78 ++++++++++++++++++- .../ui-editor/components/InputSidebar.tsx | 1 + ...’】UI编辑器多树预览与独立显示开关-2026-09-14.md | 35 +++++++++ ...】UI编辑器多树预览与独立显示开关-2026-09-14.md | 47 +++++++++++ 11 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/offset.rs create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/NodeOffset.ts create mode 100644 docs/project-memory/plans/【实施计划】UI编辑器多树预览与独立显示开关-2026-09-14.md create mode 100644 docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs index 0a32724d6..e14781395 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs @@ -3,6 +3,7 @@ use crate::config::load_game_creator_app_config; use crate::ui_editor::commands::utils::{ parse_limited_llm_tool_arguments, request_ui_editor_llm, strict_json_schema, }; +use crate::ui_editor::layout::offset::NodeOffset; use crate::ui_editor::state::{State, UITree}; use platform_llm::{LlmFunctionTool, LlmMessage, LlmRunRequest, LlmToolChoice}; use serde::{Deserialize, Serialize}; @@ -302,6 +303,7 @@ mod materialize { component: None, children_display_mode: ChildrenDisplayMode::Exclusive, children: members.into_iter().map(|member| member.node).collect(), + offset: NodeOffset::default(), }, priority, src_ui_design, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs index 97e7ba78c..f7acf69e9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs @@ -9,6 +9,7 @@ use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::control_layout::ControlLayout; use crate::ui_editor::layout::dimension::UIRect; use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus}; +use crate::ui_editor::layout::offset::NodeOffset; use crate::ui_editor::layout::transform::Transform; use crate::ui_editor::resource::ui_design_image::UIDesignImage; use crate::ui_editor::state::{State, UITree}; @@ -322,6 +323,7 @@ fn convert_node( component: source.component.clone().into_option(), children_display_mode: ChildrenDisplayMode::Stack, children, + offset: NodeOffset::default(), }) } @@ -858,6 +860,7 @@ pub(crate) async fn recognize_ui_impl_with_provider( component: recognition_root.component.into_option(), children_display_mode: ChildrenDisplayMode::Stack, children, + offset: NodeOffset::default(), }; // Tree identity and root identity are assigned by Rust, never chosen by the model. ui_trees.push(UITree { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs index b0932dc49..661e31743 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs @@ -2,4 +2,5 @@ pub mod children_display_mode; pub mod control_layout; pub mod dimension; pub mod node; +pub mod offset; pub mod transform; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs index be70cdca6..49e499138 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs @@ -1,6 +1,7 @@ use crate::ui_editor::component::Component; use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::control_layout::ControlLayout; +use crate::ui_editor::layout::offset::NodeOffset; use crate::ui_editor::utils::NodeId; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -14,6 +15,7 @@ pub struct Node { pub component: Option, pub children_display_mode: ChildrenDisplayMode, pub children: Vec, + pub offset: NodeOffset, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/offset.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/offset.rs new file mode 100644 index 000000000..1bf0e0bcb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/offset.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct NodeOffset { + pub min: [f32; 2], + pub max: [f32; 2], +} + +impl Default for NodeOffset { + fn default() -> Self { + Self { + min: [0.0, 0.0], + max: [0.0, 0.0], + } + } +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts index bac60a9ca..dac14c50e 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts @@ -4,5 +4,6 @@ import type { Component } from "./Component"; import type { ControlLayout } from "./ControlLayout"; import type { NodeId } from "./NodeId"; import type { NodeMetadata } from "./NodeMetadata"; +import type { NodeOffset } from "./NodeOffset"; -export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array, }; +export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array, offset: NodeOffset }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeOffset.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeOffset.ts new file mode 100644 index 000000000..373483003 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeOffset.ts @@ -0,0 +1,4 @@ +export type NodeOffset = { + min: [number, number]; + max: [number, number]; +}; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index a83ab93b4..cb6eecdcd 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -17,6 +17,7 @@ import type { FontAssetId } from './types/FontAssetId'; import type { Node } from './types/Node'; import type { NodeId } from './types/NodeId'; import type { NodeMetadata } from './types/NodeMetadata'; +import type { NodeOffset } from './types/NodeOffset'; import type { SpriteAsset } from './types/SpriteAsset'; import type { SpriteAssetId } from './types/SpriteAssetId'; import type { SpriteBorder } from './types/SpriteBorder'; @@ -34,6 +35,7 @@ export const EMPTY_UI_EDITOR_STATE: State = { }; const MAX_HISTORY_LENGTH = 100; +export const UI_TREE_PADDING = 48; export type UiEditorOperationFailureReason = | 'locked' @@ -156,6 +158,7 @@ function createPageRoot(state: State): Node { component: null, children_display_mode: 'Stack', children: [], + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -187,9 +190,53 @@ function createHumanNode(state: State): Node { component: null, children_display_mode: 'Stack', children: [], + offset: { min: [0, 0], max: [0, 0] }, }; } +function treeSize(state: State, treeId: UIDesignImageId): [number, number] { + const image = state.ui_design_images[treeId]; + if ( + !image || + !Number.isFinite(image.pixels_per_unit) || + image.pixels_per_unit <= 0 + ) { + throw new Error(`界面图 ${treeId} 缺少合法尺寸`); + } + return [ + image.pixel_size[0] / image.pixels_per_unit, + image.pixel_size[1] / image.pixels_per_unit, + ]; +} + +function deriveTreeOffset(state: State, treeId: UIDesignImageId): NodeOffset { + const [width, height] = treeSize(state, treeId); + const existing = state.ui_trees.filter( + (tree) => tree.src_ui_design !== treeId, + ); + if (existing.length === 0) return { min: [0, 0], max: [width, height] }; + const maxX = Math.max( + ...existing.map( + (tree) => + tree.root.offset.min[0] + treeSize(state, tree.src_ui_design)[0], + ), + ); + const minY = Math.min(...existing.map((tree) => tree.root.offset.min[1])); + return { + min: [maxX + UI_TREE_PADDING, minY], + max: [maxX + UI_TREE_PADDING + width, minY + height], + }; +} + +export function createTree( + state: State, + treeId: UIDesignImageId, + root = createPageRoot(state), +) { + root.offset = deriveTreeOffset(state, treeId); + return { src_ui_design: treeId, root }; +} + function synchronizeDesignImageTrees(state: State): void { const imageIds = new Set(Object.keys(state.ui_design_images)); state.ui_trees = state.ui_trees.filter((tree) => @@ -197,7 +244,7 @@ function synchronizeDesignImageTrees(state: State): void { ); for (const [id] of Object.entries(state.ui_design_images)) { if (!state.ui_trees.some((tree) => tree.src_ui_design === id)) { - state.ui_trees.push({ src_ui_design: id, root: createPageRoot(state) }); + state.ui_trees.push(createTree(state, id)); } } } @@ -951,6 +998,34 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { [commit, guard], ); + const setTreeOffset = useCallback( + ( + treeId: UIDesignImageId, + min: [number, number], + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if (!min.every(Number.isFinite)) return { ok: false, reason: 'invalid' }; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const size = treeSize(current, treeId); + const next = cloneState(current); + const nextTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + )!; + nextTree.root.offset = { + min: [...min], + max: [min[0] + size[0], min[1] + size[1]], + }; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + const insertNodeAfter = useCallback( ( treeId: UIDesignImageId, @@ -1464,6 +1539,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { setSpriteBorder, insertNode, insertNodeAfter, + setTreeOffset, deleteNode, setNodeTransform, setNodeLayout, 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 79ba4206a..b84ca5252 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 @@ -70,6 +70,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) { component: null, children_display_mode: 'Stack', children: uiTrees.map((tree) => tree.root), + offset: { min: [0, 0], max: [0, 0] }, }; }, [uiTrees]); diff --git a/docs/project-memory/plans/【实施计划】UI编辑器多树预览与独立显示开关-2026-09-14.md b/docs/project-memory/plans/【实施计划】UI编辑器多树预览与独立显示开关-2026-09-14.md new file mode 100644 index 000000000..82a49e1f7 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】UI编辑器多树预览与独立显示开关-2026-09-14.md @@ -0,0 +1,35 @@ +# 【实施计划】UI编辑器多树预览与独立显示开关 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:AGC UI editor Rust Node/UITree DTO、前端 UI editor state/session、preview 组件、相关测试与当前专题文档。 +- 明确不修改:SpacetimeDB、External v1、代码生成 runtime 语义、用户 `.env`。 + +## 实现顺序 + +1. 更新 Rust Node 与生成的 TS 类型;补齐所有 Node 构造器。 +2. 在状态层集中实现 createTree 与 root offset 更新。 +3. 将 session canvas projection 改为全树投影并提供按 treeId 的节点/根操作。 +4. 重构 preview 为联合画布、多树 wrapper、独立三个显示开关和树级拖动。 +5. 修正左侧选择与 Inspector 的 tree 反查,不让预览选择修改 activeImageId。 +6. 增加/更新定向测试与文档。 + +## 验证命令 + +1. `npm --prefix apps/ai-game-creator-shell test -- --run` +2. `npm --prefix apps/ai-game-creator-shell run typecheck` +3. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` +4. `npm run check:encoding` +5. `git diff --check` + +## 风险与回滚点 + +- Node DTO 是生成绑定,Rust 与 TS 不一致会阻断编译;每次结构变更后立即生成并检查 diff。 +- 预览选择与 activeImageId 解耦可能暴露 Inspector 当前依赖;通过 treeId 反查补齐。 +- root 拖动需与画布平移和子节点拖动区分;若命中冲突,优先保留现有子节点手势。 diff --git a/docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md b/docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md new file mode 100644 index 000000000..6222e284c --- /dev/null +++ b/docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md @@ -0,0 +1,47 @@ +# 【里程碑】UI编辑器多树预览与独立显示开关 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | approved | +| Date | 2026-09-14 | +| Parent Spec | `docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md` | + +## 目标 + +UI 编辑器预览同时展示全部界面树。每棵树使用 root.offset 的 min 作为树级画布位置,支持整树拖动持久化;显示原图、组件和普通框线拆成三个独立开关。 + +## 范围 + +- `Node` 增加包含 min/max 的 offset 数据,读取只使用 min,max 由 min 与源图 logical size 派生。 +- 所有新树统一由 createTree 入口创建,并按现有树最右边界加 padding 横向排列。 +- 首次进入预览和手动适配使用全部树联合边界。 +- 左侧图片选择继续维护 activeImageId;预览选择不改变它,不因选择或拖动自动 fit。 +- root 可拖动但不可 resize;子节点维持现有选择、拖动和 resize 行为。 + +## 不在范围内 + +- 旧数据 migration、offset fallback、自动 revision 修复。 +- 删除/排序后的自动重排。 +- 代码生成 runtime 的树级布局语义。 +- root/max 的独立编辑控件。 + +## 依赖与前置条件 + +- 当前资源均符合最新 Node/UITree schema。 +- 对应界面图提供合法 pixel_size 与 pixels_per_unit。 + +## 验收标准 + +- [ ] 新树默认位于所有现有树右侧并与最小 top 对齐。 +- [ ] 原图与组件随 root 一起移动,拖动结束进入 undo/redo 并可保存恢复。 +- [ ] root 不显示 resize handles,子节点 resize 不回归。 +- [ ] 三个开关默认 showFrame=true、showOriginImage=true、showComponent=false,且互相独立。 +- [ ] 预览选择不修改 activeImageId;左侧图片选择和 Inspector 既有切换保持。 +- [ ] 仅首次进入和手动适配触发联合 fit。 + +## 证据要求 + +- 自动化:UI editor preview、state、transform 相关 Vitest;Tauri Rust 单元测试;类型检查。 +- 运行时:必要时执行 AGC UI 编辑器页面 smoke。 +- 边界:非法缺失 offset 不兜底;树重叠命中顺序;root 禁止 resize。 From ec94a4fe004e99be2890ff0ad1ba2c872725f638 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 18:05:50 +0800 Subject: [PATCH 010/199] =?UTF-8?q?UI=E7=BC=96=E8=BE=91=E5=99=A8=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E5=A4=9A=E6=A0=91=E9=A2=84=E8=A7=88=E4=B8=8E=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 预览画布同时渲染全部界面树并支持树级拖动 拆分显示框线、原图和组件开关 预览节点选择与左侧当前图片解耦 --- .../ui-editor/components/InputSidebar.tsx | 4 +- .../components/preview/PreviewWorkspace.tsx | 264 ++++++++++++------ .../components/preview/UiTreeRenderer.tsx | 15 +- .../src/view/ui-editor/index.tsx | 58 ++-- .../src/view/ui-editor/useUiEditorPage.ts | 80 ++++-- 5 files changed, 264 insertions(+), 157 deletions(-) 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 b84ca5252..6c2b83cf5 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 @@ -90,10 +90,8 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) { // 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); + if (!sameNode) input.selectNode(nodeId); }} onToggleNodeVisibility={(nodeId) => input.toggleNodePreviewVisibility(nodeId) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index 6bd4c674a..91c0c24a1 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -38,7 +38,7 @@ import { isPreviewZoomInteractiveTarget, previewZoomUsesMetaModifier, } from './previewZoomKeyboard'; -import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; +import { UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; /** 画布初始适配比例;调小可为浮动面板预留更多可视空间。 */ @@ -57,29 +57,34 @@ function fitEditorViewport( }; } -export type { UiEditorRenderMode } from './UiTreeRenderer'; - export function PreviewWorkspace({ canvas, - renderMode: controlledRenderMode, - onRenderModeChange: _onRenderModeChange, - showFrame: controlledShowFrame, - onShowFrameChange: _onShowFrameChange, + showFrame, + showOriginImage, + showComponent, }: { canvas: UiEditorCanvasProjection; - renderMode?: UiEditorRenderMode; - onRenderModeChange?: (mode: UiEditorRenderMode) => void; - showFrame?: boolean; - onShowFrameChange?: (show: boolean) => void; + showFrame: boolean; + showOriginImage: boolean; + showComponent: boolean; }) { - const { activeImage, activeImageId, previewUrls } = canvas; - const activePreviewUrl = activeImageId - ? previewUrls[activeImageId] - : undefined; + const { activeImage, activeImageId, previewUrls, uiTrees, images } = canvas; + const tree = canvas.tree ?? null; const viewportElementRef = useRef(null); const viewportRef = useRef({ x: 0, y: 0, scale: 0.5 }); const handledFocusRequestIdRef = useRef(null); + const didInitialFitRef = useRef(false); const panRef = useRef | null>(null); + const treeDragRef = useRef<{ + treeId: string; + pointerId: number; + startX: number; + startY: number; + origin: [number, number]; + } | null>(null); + const [previewTreeOffsets, setPreviewTreeOffsets] = useState< + ReadonlyMap + >(new Map()); const previewFocusedRef = useRef(false); const previewHoveredRef = useRef(false); const [viewport, setViewportState] = useState( @@ -88,8 +93,6 @@ export function PreviewWorkspace({ const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); const canvasSizeRef = useRef(canvasSize); const [spaceHeld, setSpaceHeld] = useState(false); - const renderMode = controlledRenderMode ?? 'editor-overlay'; - const showFrame = controlledShowFrame ?? false; const [previewTransforms, setPreviewTransforms] = useState< ReadonlyMap >(new Map()); @@ -99,7 +102,6 @@ export function PreviewWorkspace({ y: number; isPageRoot: boolean; } | null>(null); - const tree = canvas.tree ?? null; const updatePreviewTransform = useCallback( (nodeId: NodeId, transform: Node['layout']['transform'] | null) => { setPreviewTransforms((current) => { @@ -141,6 +143,38 @@ export function PreviewWorkspace({ } return { width, height }; }, [activeImagePixelHeight, activeImagePixelsPerUnit, activeImagePixelWidth]); + const treeLayouts = useMemo( + () => + uiTrees.map((item) => { + const image = images[item.src_ui_design]; + const ppu = image?.pixels_per_unit ?? 0; + const width = image ? image.pixel_size[0] / ppu : 0; + const height = image ? image.pixel_size[1] / ppu : 0; + return { + tree: item, + image, + width, + height, + validOffset: Boolean(item.root.offset?.min), + }; + }), + [images, uiTrees], + ); + const allBounds = useMemo(() => { + const valid = treeLayouts.filter( + (item) => item.validOffset && item.width > 0 && item.height > 0, + ); + if (valid.length === 0) return null; + const minX = Math.min(...valid.map(({ tree }) => tree.root.offset.min[0])); + const minY = Math.min(...valid.map(({ tree }) => tree.root.offset.min[1])); + const maxX = Math.max( + ...valid.map(({ tree, width }) => tree.root.offset.min[0] + width), + ); + const maxY = Math.max( + ...valid.map(({ tree, height }) => tree.root.offset.min[1] + height), + ); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + }, [treeLayouts]); const nodeInteractions = useNodeTransformInteraction({ activeImageId, @@ -172,40 +206,27 @@ export function PreviewWorkspace({ }, []); const fitToCanvas = useCallback(() => { - if (!logicalSize) return; + if (!allBounds) return; const element = viewportElementRef.current; setViewport( - fitEditorViewport( - { x: 0, y: 0, width: logicalSize.width, height: logicalSize.height }, - { - width: element?.clientWidth || canvasSizeRef.current.width, - height: element?.clientHeight || canvasSizeRef.current.height, - }, - ), + fitEditorViewport(allBounds, { + width: element?.clientWidth || canvasSizeRef.current.width, + height: element?.clientHeight || canvasSizeRef.current.height, + }), ); - }, [logicalSize, setViewport]); + }, [allBounds, setViewport]); useEffect(() => { - if (!activeImageId || !logicalSize) return; + if (!allBounds) return; + if (didInitialFitRef.current) return; + didInitialFitRef.current = true; const element = viewportElementRef.current; const canvasSize = { width: element?.clientWidth || canvasSizeRef.current.width, height: element?.clientHeight || canvasSizeRef.current.height, }; - setViewport( - fitEditorViewport( - { x: 0, y: 0, width: logicalSize.width, height: logicalSize.height }, - canvasSize, - ), - ); - }, [ - activeImageId, - activePreviewUrl, - canvasSize.height, - canvasSize.width, - logicalSize, - setViewport, - ]); + setViewport(fitEditorViewport(allBounds, canvasSize)); + }, [allBounds, canvasSize.height, canvasSize.width, setViewport]); const scaleViewportFromCenter = useCallback( (nextScale: number) => { @@ -389,6 +410,53 @@ export function PreviewWorkspace({ } }; + const handleTreePointerDown = ( + event: ReactPointerEvent, + treeId: string, + rootId: string, + origin: [number, number], + ) => { + if (event.button !== 0) return; + const hit = (event.target as Element).closest('[data-node-id]'); + if (!hit || hit.getAttribute('data-node-id') !== rootId) return; + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + treeDragRef.current = { + treeId, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + origin, + }; + }; + const handleTreePointerMove = (event: ReactPointerEvent) => { + const drag = treeDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const next: [number, number] = [ + drag.origin[0] + + (event.clientX - drag.startX) / viewportRef.current.scale, + drag.origin[1] + + (event.clientY - drag.startY) / viewportRef.current.scale, + ]; + setPreviewTreeOffsets((current) => new Map(current).set(drag.treeId, next)); + }; + const handleTreePointerUp = (event: ReactPointerEvent) => { + if (treeDragRef.current?.pointerId !== event.pointerId) return; + const drag = treeDragRef.current; + if (drag) { + const next = previewTreeOffsets.get(drag.treeId); + if (next) canvas.setTreeOffset(drag.treeId, next); + setPreviewTreeOffsets((current) => { + const result = new Map(current); + result.delete(drag.treeId); + return result; + }); + } + treeDragRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) + event.currentTarget.releasePointerCapture(event.pointerId); + }; + const handleWheel = (event: React.WheelEvent) => { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); @@ -421,15 +489,9 @@ export function PreviewWorkspace({ }); }; - useEffect(() => { - if (renderMode !== 'editor-overlay') setContextMenu(null); - }, [renderMode]); - return (
- {activeImage && - logicalSize && - (renderMode === 'final-preview' || previewUrls[activeImageId ?? '']) ? ( + {treeLayouts.length > 0 && allBounds ? (
-
- {renderMode === 'editor-overlay' ? ( - {activeImage.metadata.name} - ) : ( -
- )} - -
+ {treeLayouts + .filter((item) => item.validOffset) + .map(({ tree: item, image, width, height }) => { + const offset = + previewTreeOffsets.get(item.src_ui_design) ?? + item.root.offset.min; + return ( +
+ handleTreePointerDown( + event, + item.src_ui_design, + item.root.id, + offset, + ) + } + onPointerMove={handleTreePointerMove} + onPointerUp={handleTreePointerUp} + onPointerCancel={handleTreePointerUp} + > + {showOriginImage && previewUrls[item.src_ui_design] ? ( + {image?.metadata.name + ) : null} + +
+ ); + })} {contextMenu ? ( diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index c4ca88a09..cef8c3a01 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -29,8 +29,8 @@ type NodePointerDown = ( type UiTreeRendererProps = { tree: UITree | null; - renderMode: UiEditorRenderMode; showFrame: boolean; + showComponent: boolean; hiddenNodeIds: ReadonlySet; previewTransforms?: ReadonlyMap; selectedNodeId: NodeId | null; @@ -83,8 +83,8 @@ function RenderNode({ node, isRoot, parentContainer, - renderMode, showFrame, + showComponent, hiddenNodeIds, previewTransforms, selectedNodeId, @@ -124,8 +124,8 @@ function RenderNode({ return null; } - const isEditorOverlay = renderMode === 'editor-overlay'; - const isFrameVisible = isEditorOverlay || showFrame; + const isSelected = selectedNodeId === node.id; + const isFrameVisible = showFrame || isSelected; const receivesPointerGesture = parentContainer === undefined; const hasDirectPointerGesture = receivesPointerGesture && !isRoot; const exclusiveVisibleChildId = @@ -170,7 +170,6 @@ function RenderNode({ } } onContextMenu={(event) => { - if (!isEditorOverlay) return; event.preventDefault(); event.stopPropagation(); onSelectNode(node.id); @@ -191,10 +190,10 @@ function RenderNode({ {node.metadata.name} ) : null} - {node.component ? ( + {showComponent && node.component ? ( ) : null} - {renderMode === 'final-preview' && selectedNodeId === node.id ? ( + {isSelected ? ( nodeId === exclusiveVisibleChildId} @@ -212,8 +211,8 @@ function RenderNode({ ? node.layout.container : undefined } - renderMode={renderMode} showFrame={showFrame} + showComponent={showComponent} hiddenNodeIds={hiddenNodeIds} previewTransforms={activePreviewTransforms} selectedNodeId={selectedNodeId} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index c40179b7e..ff8b9f87a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -12,10 +12,7 @@ import { EditorDialogs } from './components/EditorDialogs'; import { ImportOverview } from './components/ImportOverview'; import { InputSidebar } from './components/InputSidebar'; import { InspectorSidebar } from './components/Inspector/InspectorSidebar'; -import { - PreviewWorkspace, - type UiEditorRenderMode, -} from './components/preview/PreviewWorkspace'; +import { PreviewWorkspace } from './components/preview/PreviewWorkspace'; import { RecognitionOverview } from './components/RecognitionOverview'; import { SeparationOverview } from './components/SeparationOverview'; import { ToolNavigation } from './components/ToolNavigation'; @@ -75,9 +72,9 @@ export default function UiEditorPage({ const [generateAfterWarning, setGenerateAfterWarning] = useState(false); const [generateSuccess, setGenerateSuccess] = useState(null); const [returnConfirmOpen, setReturnConfirmOpen] = useState(false); - const [renderMode, setRenderMode] = - useState('editor-overlay'); - const [showFrame, setShowFrame] = useState(false); + const [showFrame, setShowFrame] = useState(true); + const [showOriginImage, setShowOriginImage] = useState(true); + const [showComponent, setShowComponent] = useState(false); const overview = session.input.activeStep === 'structure-recognition' ? ( {resourceLabel ?? 'UI 设计'}
-
- - -
- {renderMode === 'final-preview' ? ( -
+ + ); +} + +function SimpleNotice({ + title, + onClose, +}: { + title: string; + onClose: () => void; +}) { + return ( + <> +

{title}

+
+ +
+ + ); +} + +function FailureNotice({ + notice, + onClose, +}: { + notice: Extract; + onClose: () => void; +}) { + return ( + <> +

操作失败

+

+ {notice.message} +

+
+ + {notice.onRetry ? ( + + ) : null} +
+ + ); +} + +function ariaLabelForNotice(notice: UiEditorSaveResultNotice | null) { + if (notice?.kind === 'generated') return '代码已生成'; + if (notice?.kind === 'saved') return '保存成功'; + if (notice?.kind === 'failure') return '操作失败'; + return '保存结果'; +} From 27859d3e19959403858fefc176c7e56684fab275 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 18:44:23 +0800 Subject: [PATCH 016/199] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E4=BF=9D=E5=AD=98=E7=BB=93=E6=9E=9C=E5=BC=B9?= =?UTF-8?q?=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让普通保存和保存并生成流程返回结构化结果 将成功与失败反馈接入独立弹窗并保留保存并返回直返行为 --- .../src/view/ui-editor/index.tsx | 74 ++++++++++--------- .../src/view/ui-editor/useUiEditorPage.ts | 58 ++++++++++++--- 2 files changed, 87 insertions(+), 45 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index ff8b9f87a..0f688c61e 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -16,6 +16,10 @@ import { PreviewWorkspace } from './components/preview/PreviewWorkspace'; import { RecognitionOverview } from './components/RecognitionOverview'; import { SeparationOverview } from './components/SeparationOverview'; import { ToolNavigation } from './components/ToolNavigation'; +import { + UiEditorSaveResultModal, + type UiEditorSaveResultNotice, +} from './components/UiEditorSaveResultModal'; import { WorkflowActionCard } from './components/WorkflowActionCard'; import { WorkflowCompletionModal } from './components/WorkflowCompletionModal'; import { UI_EDITOR_STEPS, type UiEditorStepId } from './model'; @@ -70,7 +74,8 @@ export default function UiEditorPage({ const [saveWarningOpen, setSaveWarningOpen] = useState(false); const [saveAfterReturn, setSaveAfterReturn] = useState(false); const [generateAfterWarning, setGenerateAfterWarning] = useState(false); - const [generateSuccess, setGenerateSuccess] = useState(null); + const [saveResultNotice, setSaveResultNotice] = + useState(null); const [returnConfirmOpen, setReturnConfirmOpen] = useState(false); const [showFrame, setShowFrame] = useState(true); const [showOriginImage, setShowOriginImage] = useState(true); @@ -115,10 +120,6 @@ export default function UiEditorPage({ const activeImageId = session.input.activeImageId; const deleteNode = session.input.deleteNode; - useEffect(() => { - if (session.save.isDirty) setGenerateSuccess(null); - }, [session.save.isDirty]); - useEffect(() => { const onKeyDown = (event: KeyboardEvent) => handleUiEditorKeyDown(event, { @@ -133,28 +134,51 @@ export default function UiEditorPage({ }, [activeImageId, deleteNode, historyRedo, historyUndo, selectedNodeId]); async function save(afterReturn = false) { - if (await session.save.save()) { + const result = await session.save.save(); + if (result.status === 'saved') { if (afterReturn) { setReturnConfirmOpen(false); onBack?.(); + } else { + setSaveResultNotice({ kind: 'saved' }); } + return; } + setSaveResultNotice({ + kind: 'failure', + message: result.message, + retryLabel: '重试保存', + onRetry: () => void save(afterReturn), + }); } async function saveAndGenerate() { - setGenerateSuccess(null); + setSaveResultNotice(null); const result = await session.save.saveAndGenerateCode(); - if (result) { + if (result.status === 'generated') { setGenerateAfterWarning(false); - setGenerateSuccess(`代码已生成:${result.relativePath}`); + setSaveResultNotice({ + kind: 'generated', + relativePath: result.result.relativePath, + }); + return; } + setSaveResultNotice({ + kind: 'failure', + message: + result.phase === 'generate' + ? `项目已保存,但代码生成失败:${result.message}` + : result.message, + retryLabel: '重试保存并生成', + onRetry: () => void saveAndGenerate(), + }); } function requestSave(afterReturn = false) { if (!resourceId || session.save.isSaving || session.workflow.isAiRunning) { return; } - setGenerateSuccess(null); + setSaveResultNotice(null); setSaveAfterReturn(afterReturn); setGenerateAfterWarning(false); if (session.save.hasWarnings()) { @@ -174,7 +198,7 @@ export default function UiEditorPage({ return; } setSaveAfterReturn(false); - setGenerateSuccess(null); + setSaveResultNotice(null); setGenerateAfterWarning(true); if (session.save.hasWarnings()) { setSaveWarningOpen(true); @@ -289,30 +313,6 @@ export default function UiEditorPage({