From 1e2537e0361b3d4249f749ccb8b9bbf6ead1467b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 18 Aug 2026 18:49:36 +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=E4=BB=8E=E5=B1=9E=E5=85=B3=E7=B3=BB=E4=B8=8E=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 状态层和持久化层拒绝 slave_to 循环,Inspector 过滤后代候选 统一节点拖拽缩放手势状态机并校验几何值 保存失败改为页面级安全提示并支持稳定重试 补充回归测试及 UI Editor 决策文档 --- .../src-tauri/src/ui_editor/persistence.rs | 57 ++++ .../features/ui-editor/useUiEditorState.ts | 23 ++ .../preview/useNodeTransformInteraction.ts | 269 ++++++++++++------ .../src/view/ui-editor/index.tsx | 29 +- .../src/view/ui-editor/useUiEditorPage.ts | 25 +- .../tests/uiEditorPage.test.ts | 41 +++ .../tests/uiEditorState.test.ts | 27 ++ .../useNodeTransformInteraction.test.tsx | 187 ++++++++++++ .../shared-memory/decision-log.md | 7 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 + 10 files changed, 578 insertions(+), 95 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 5757fe02f..76c9428f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -465,6 +465,7 @@ fn validate_state(state: &State) -> Result<(), String> { return Err("界面图 slaveTo 引用了不存在的界面图".to_string()); } } + validate_slave_to_acyclic(state)?; for (id, sprite) in &state.sprite_assets { validate_id(id.as_str(), "独立素材 ID")?; if sprite.asset_id != *id { @@ -490,6 +491,23 @@ fn validate_state(state: &State) -> Result<(), String> { Ok(()) } +fn validate_slave_to_acyclic(state: &State) -> Result<(), String> { + for start in state.ui_design_images.keys() { + let mut current = Some(start); + let mut visited = HashSet::new(); + while let Some(id) = current { + if !visited.insert(id) { + return Err("界面图 slaveTo 不能形成循环".to_string()); + } + current = state + .ui_design_images + .get(id) + .and_then(|image| image.metadata.slave_to.as_ref()); + } + } + Ok(()) +} + fn validate_node( node: &Node, state: &State, @@ -951,6 +969,45 @@ mod tests { ); } + #[test] + fn rejects_cyclic_slave_to_relationships() { + let state: State = serde_json::from_value(serde_json::json!({ + "ui_trees": [], + "ui_design_images": { + "page": { + "metadata": { + "name": "主界面", + "description": "", + "role": "Page", + "slave_to": "section" + }, + "path": "page.png", + "pixel_size": [1280.0, 720.0], + "pixels_per_unit": 1.0 + }, + "section": { + "metadata": { + "name": "子页面", + "description": "", + "role": "Section", + "slave_to": "page" + }, + "path": "section.png", + "pixel_size": [1280.0, 720.0], + "pixels_per_unit": 1.0 + } + }, + "sprite_assets": {}, + "font_assets": {} + })) + .expect("deserialize cyclic state"); + + assert_eq!( + validate_state(&state), + Err("界面图 slaveTo 不能形成循环".to_string()) + ); + } + #[test] fn recovers_last_valid_state_when_primary_is_corrupt() { let (directory, asset_id) = fixture(); 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 9e62335d4..33319f4f7 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 @@ -69,6 +69,23 @@ type NodeLocation = { index: number; }; +function wouldCreateSlaveToCycle( + images: State['ui_design_images'], + id: UIDesignImageId, + slaveTo: UIDesignImageId, +): boolean { + let current: UIDesignImageId | null = slaveTo; + const visited = new Set(); + while (current !== null) { + if (current === id || visited.has(current)) return true; + visited.add(current); + const image: UIDesignImage | undefined = images[current]; + if (!image) return true; + current = image.metadata.slave_to; + } + return false; +} + function visitNodes(node: Node, visit: (node: Node) => void): void { visit(node); for (const child of node.children) visitNodes(child, visit); @@ -657,6 +674,12 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (slaveTo !== null && !(slaveTo in current.ui_design_images)) { return { ok: false, reason: 'missing' }; } + if ( + slaveTo !== null && + wouldCreateSlaveToCycle(current.ui_design_images, id, slaveTo) + ) { + return { ok: false, reason: 'invalid:slave_to 不能形成循环' }; + } const next = cloneState(current); next.ui_design_images[id]!.metadata.slave_to = slaveTo; commit(next); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index 3393e9863..87bdeb9bb 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -2,6 +2,7 @@ import { type PointerEvent as ReactPointerEvent, type RefObject, useCallback, + useEffect, useRef, } from 'react'; @@ -22,44 +23,61 @@ import { type ViewportScale = { scale: number }; -type NodeDragState = { +type GestureBase = { + treeId: string; nodeId: string; pointerId: number; + target: HTMLDivElement; startClientX: number; startClientY: number; startTransform: UiNode['layout']['transform']; hasMoved: boolean; }; -type NodeResizeState = { - nodeId: string; - pointerId: number; - handle: ResizeHandle; - startClientX: number; - startClientY: number; - startTransform: UiNode['layout']['transform']; - startRect: PageRect; - parentRect: PageRect; - ratioAxis: ResizeAxis | null; - hasMoved: boolean; -}; +type ActiveGesture = + | (GestureBase & { kind: 'drag' }) + | (GestureBase & { + kind: 'resize'; + handle: ResizeHandle; + startRect: PageRect; + parentRect: PageRect; + ratioAxis: ResizeAxis | null; + }); const DRAG_THRESHOLD_SCREEN_PX = 2; function passedDragThreshold( - startClientX: number, - startClientY: number, + gesture: GestureBase, event: ReactPointerEvent, ) { return ( - Math.hypot(event.clientX - startClientX, event.clientY - startClientY) >= - DRAG_THRESHOLD_SCREEN_PX + Math.hypot( + event.clientX - gesture.startClientX, + event.clientY - gesture.startClientY, + ) >= DRAG_THRESHOLD_SCREEN_PX ); } -function releasePointer(event: ReactPointerEvent) { - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); +function isFinitePoint(point: readonly number[]) { + return point.length === 2 && point.every(Number.isFinite); +} + +function isFiniteRect(rect: PageRect) { + return isFinitePoint(rect.min) && isFinitePoint(rect.max); +} + +function isFiniteTransform(transform: UiNode['layout']['transform']) { + return ( + isFinitePoint(transform.anchor_min) && + isFinitePoint(transform.anchor_max) && + isFinitePoint(transform.offset_min) && + isFinitePoint(transform.offset_max) + ); +} + +function releasePointer(target: HTMLDivElement, pointerId: number) { + if (target.hasPointerCapture(pointerId)) { + target.releasePointerCapture(pointerId); } } @@ -78,60 +96,114 @@ export function useNodeTransformInteraction({ tree: UITree | null; viewportRef: RefObject; }) { - const nodeDragRef = useRef(null); - const nodeResizeRef = useRef(null); + const activeGestureRef = useRef(null); + + const cancelGesture = useCallback(() => { + const gesture = activeGestureRef.current; + if (gesture) releasePointer(gesture.target, gesture.pointerId); + activeGestureRef.current = null; + }, []); + + useEffect(() => cancelGesture, [cancelGesture]); + + useEffect(() => { + const gesture = activeGestureRef.current; + if ( + gesture && + (gesture.treeId !== activeImageId || + tree?.src_ui_design !== gesture.treeId) + ) { + cancelGesture(); + } + }, [activeImageId, cancelGesture, tree]); + + const acceptsGestureEvent = useCallback( + (event: ReactPointerEvent) => { + const gesture = activeGestureRef.current; + return ( + gesture !== null && + gesture.pointerId === event.pointerId && + gesture.treeId === activeImageId + ); + }, + [activeImageId], + ); const onNodePointerDown = useCallback( (event: ReactPointerEvent, node: UiNode) => { - if (event.button !== 0 || spaceHeld || node.id === tree?.root.id) return; + if ( + activeGestureRef.current !== null || + event.button !== 0 || + spaceHeld || + !activeImageId || + node.id === tree?.root.id || + !isFiniteTransform(node.layout.transform) + ) { + return; + } event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); controller.selectNode(node.id); - nodeDragRef.current = { + activeGestureRef.current = { + kind: 'drag', + treeId: activeImageId, nodeId: node.id, pointerId: event.pointerId, + target: event.currentTarget, startClientX: event.clientX, startClientY: event.clientY, startTransform: structuredClone(node.layout.transform), hasMoved: false, }; }, - [controller, spaceHeld, tree?.root.id], + [activeImageId, controller, spaceHeld, tree?.root.id], ); const onNodePointerMove = useCallback( (event: ReactPointerEvent) => { - event.stopPropagation(); - const drag = nodeDragRef.current; - if (!drag || drag.pointerId !== event.pointerId || !activeImageId) return; - if ( - !drag.hasMoved && - !passedDragThreshold(drag.startClientX, drag.startClientY, event) - ) { + const gesture = activeGestureRef.current; + if (!gesture || gesture.kind !== 'drag' || !acceptsGestureEvent(event)) { return; } - drag.hasMoved = true; - const nextTransform = structuredClone(drag.startTransform); - const scale = viewportRef.current?.scale ?? 1; - const logicalDeltaX = (event.clientX - drag.startClientX) / scale; - const logicalDeltaY = (event.clientY - drag.startClientY) / scale; + event.stopPropagation(); + if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return; + const scale = viewportRef.current?.scale; + if (!Number.isFinite(scale) || scale <= 0) { + cancelGesture(); + return; + } + const logicalDeltaX = (event.clientX - gesture.startClientX) / scale; + const logicalDeltaY = (event.clientY - gesture.startClientY) / scale; + if (!Number.isFinite(logicalDeltaX) || !Number.isFinite(logicalDeltaY)) { + cancelGesture(); + return; + } + gesture.hasMoved = true; + const nextTransform = structuredClone(gesture.startTransform); nextTransform.offset_min[0] += logicalDeltaX; nextTransform.offset_min[1] += logicalDeltaY; nextTransform.offset_max[0] += logicalDeltaX; nextTransform.offset_max[1] += logicalDeltaY; - controller.updateNodeTransform(activeImageId, drag.nodeId, nextTransform); + if (!isFiniteTransform(nextTransform)) { + cancelGesture(); + return; + } + controller.updateNodeTransform( + gesture.treeId, + gesture.nodeId, + nextTransform, + ); }, - [activeImageId, controller, viewportRef], + [acceptsGestureEvent, cancelGesture, controller, viewportRef], ); const onNodePointerUp = useCallback( (event: ReactPointerEvent) => { + if (!acceptsGestureEvent(event)) return; event.stopPropagation(); - if (nodeDragRef.current?.pointerId !== event.pointerId) return; - nodeDragRef.current = null; - releasePointer(event); + cancelGesture(); }, - [], + [acceptsGestureEvent, cancelGesture], ); const onNodeResizePointerDown = useCallback( @@ -141,11 +213,18 @@ export function useNodeTransformInteraction({ handle: ResizeHandle, ) => { if ( + activeGestureRef.current !== null || event.button !== 0 || spaceHeld || + !activeImageId || node.id === tree?.root.id || !tree || - !logicalSize + !logicalSize || + !Number.isFinite(logicalSize.width) || + !Number.isFinite(logicalSize.height) || + logicalSize.width <= 0 || + logicalSize.height <= 0 || + !isFiniteTransform(node.layout.transform) ) { return; } @@ -154,14 +233,23 @@ export function useNodeTransformInteraction({ max: [logicalSize.width, logicalSize.height], }; const context = findNodePageContext(tree.root, node.id, pageRect); - if (!context) return; + if ( + !context || + !isFiniteRect(context.rect) || + !isFiniteRect(context.parentRect) + ) { + return; + } event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); controller.selectNode(node.id); - nodeResizeRef.current = { + activeGestureRef.current = { + kind: 'resize', + treeId: activeImageId, nodeId: node.id, pointerId: event.pointerId, + target: event.currentTarget, handle, startClientX: event.clientX, startClientY: event.clientY, @@ -172,65 +260,70 @@ export function useNodeTransformInteraction({ hasMoved: false, }; }, - [controller, logicalSize, spaceHeld, tree], + [activeImageId, controller, logicalSize, spaceHeld, tree], ); const onNodeResizePointerMove = useCallback( (event: ReactPointerEvent) => { - event.stopPropagation(); - const resize = nodeResizeRef.current; - if (!resize || resize.pointerId !== event.pointerId || !activeImageId) { - return; - } + const gesture = activeGestureRef.current; if ( - !resize.hasMoved && - !passedDragThreshold(resize.startClientX, resize.startClientY, event) + !gesture || + gesture.kind !== 'resize' || + !acceptsGestureEvent(event) ) { return; } - resize.hasMoved = true; - const scale = viewportRef.current?.scale ?? 1; + event.stopPropagation(); + if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return; + const scale = viewportRef.current?.scale; + if (!Number.isFinite(scale) || scale <= 0) { + cancelGesture(); + return; + } const delta: [number, number] = [ - (event.clientX - resize.startClientX) / scale, - (event.clientY - resize.startClientY) / scale, + (event.clientX - gesture.startClientX) / scale, + (event.clientY - gesture.startClientY) / scale, ]; - if (event.shiftKey && !resize.ratioAxis) { - resize.ratioAxis = resolveProportionalResizeAxis( - [ - resize.startRect.max[0] - resize.startRect.min[0], - resize.startRect.max[1] - resize.startRect.min[1], - ], - delta, - ); + if (!isFinitePoint(delta)) { + cancelGesture(); + return; } const nextRect = resizePageRect( - resize.startRect, - resize.handle, + gesture.startRect, + gesture.handle, delta, event.shiftKey, - resize.ratioAxis ?? undefined, + event.shiftKey + ? (gesture.ratioAxis ??= resolveProportionalResizeAxis( + [ + gesture.startRect.max[0] - gesture.startRect.min[0], + gesture.startRect.max[1] - gesture.startRect.min[1], + ], + delta, + )) + : undefined, ); + if (!isFiniteRect(nextRect)) { + cancelGesture(); + return; + } + const nextTransform = setOffsetsForPageRect( + gesture.startTransform, + nextRect, + gesture.parentRect, + ); + if (!isFiniteTransform(nextTransform)) { + cancelGesture(); + return; + } + gesture.hasMoved = true; controller.updateNodeTransform( - activeImageId, - resize.nodeId, - setOffsetsForPageRect( - resize.startTransform, - nextRect, - resize.parentRect, - ), + gesture.treeId, + gesture.nodeId, + nextTransform, ); }, - [activeImageId, controller, viewportRef], - ); - - const onNodeResizePointerUp = useCallback( - (event: ReactPointerEvent) => { - event.stopPropagation(); - if (nodeResizeRef.current?.pointerId !== event.pointerId) return; - nodeResizeRef.current = null; - releasePointer(event); - }, - [], + [acceptsGestureEvent, cancelGesture, controller, viewportRef], ); return { @@ -239,6 +332,6 @@ export function useNodeTransformInteraction({ onNodePointerUp, onNodeResizePointerDown, onNodeResizePointerMove, - onNodeResizePointerUp, + onNodeResizePointerUp: onNodePointerUp, }; } 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 b0bd21b3b..1483c8fdd 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 @@ -68,6 +68,10 @@ export default function UiEditorPage({ savedStateSignature !== null && savedStateSignature !== stateSignature; + function saveFailureMessage() { + return '保存失败,请稍后重试。'; + } + async function save(afterReturn = false) { if ( !resourceId || @@ -101,8 +105,8 @@ export default function UiEditorPage({ setReturnConfirmOpen(false); onBack?.(); } - } catch (error) { - setSaveError(error instanceof Error ? error.message : String(error)); + } catch { + setSaveError(saveFailureMessage()); } finally { setIsSaving(false); } @@ -112,7 +116,6 @@ export default function UiEditorPage({ if (!resourceId || isSaving || controller.isAiRunning) return; setSaveAfterReturn(afterReturn); if (controller.postCheckIssuesForSave().length > 0) { - if (afterReturn) setReturnConfirmOpen(false); setSaveWarningOpen(true); return; } @@ -156,6 +159,22 @@ export default function UiEditorPage({ ) : null} + {saveError ? ( +
+ {saveError} + +
+ ) : null}

返回资源?

{saveError ? ( -

- {saveError} -

+

{saveError}

) : null}